From f0d48209d7f8ea8a3198bd5fc7c2f6dc0c5a855b Mon Sep 17 00:00:00 2001 From: Polyglot AI <293096396+polyglotAI-bot@users.noreply.github.com> Date: Fri, 26 Jun 2026 16:40:32 +0000 Subject: [PATCH 1/3] Fix client-v2: quote String/temporal elements in container query parameters addStatementParams formatted every HTTP param_ value with a blanket String.valueOf(v). For a container value (List/array/Map) this left inner elements unquoted (e.g. param_l=[2026-05-13] for an Array(Date) parameter), which the server rejects with HTTP 400 CANNOT_PARSE_INPUT_ASSERTION_FAILED. The server's array/map text parser requires String/temporal leaves to be single-quoted (['2026-05-13']) while numeric leaves must stay unquoted. Format container parameters type-awarely: frame Collection/array as [..] and Map as {..}, and render scalar leaves via ClickHouseValues.convertToSqlExpression (String/temporal single-quoted, numeric/boolean unquoted, null -> NULL). Scalar parameters are unchanged (still emitted unquoted, as the server requires). Both transport paths (URL query params and multipart body) funnel through this method. Fixes: https://github.com/ClickHouse/clickhouse-java/issues/2897 --- .../api/internal/HttpAPIClientHelper.java | 72 ++++++++++- .../api/internal/HttpAPIClientHelperTest.java | 116 +++++++++++++++++- .../clickhouse/client/query/QueryTests.java | 29 +++++ 3 files changed, 211 insertions(+), 6 deletions(-) diff --git a/client-v2/src/main/java/com/clickhouse/client/api/internal/HttpAPIClientHelper.java b/client-v2/src/main/java/com/clickhouse/client/api/internal/HttpAPIClientHelper.java index ab4b0153c..4238b5cb0 100644 --- a/client-v2/src/main/java/com/clickhouse/client/api/internal/HttpAPIClientHelper.java +++ b/client-v2/src/main/java/com/clickhouse/client/api/internal/HttpAPIClientHelper.java @@ -16,6 +16,7 @@ import com.clickhouse.client.api.http.ClickHouseHttpProto; import com.clickhouse.client.api.transport.Endpoint; import com.clickhouse.data.ClickHouseFormat; +import com.clickhouse.data.ClickHouseValues; import net.jpountz.lz4.LZ4Factory; import org.apache.commons.compress.compressors.CompressorStreamFactory; import org.apache.hc.client5.http.ConnectTimeoutException; @@ -765,10 +766,79 @@ private void addRequestParams(Map requestConfig, BiConsumer requestConfig, BiConsumer consumer) { if (requestConfig.containsKey(KEY_STATEMENT_PARAMS)) { Map params = (Map) requestConfig.get(KEY_STATEMENT_PARAMS); - params.forEach((k, v) -> consumer.accept("param_" + k, String.valueOf(v))); + params.forEach((k, v) -> consumer.accept("param_" + k, formatStatementParam(v))); } } + /** + * Formats a value for ClickHouse's HTTP {@code param_} query-parameter interface. + * + *

A top-level scalar is sent 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'} - the latter is rejected). When the value is a + * container ({@link Collection}, object array or {@link Map}, i.e. an {@code Array}, {@code Tuple} + * or {@code Map} parameter) it is rendered as a ClickHouse text literal in which {@code String} + * and temporal leaves are single-quoted while numeric/boolean leaves are left unquoted, as + * required by the server's array/map text parser. Previously this method used a blanket + * {@code String.valueOf(v)}, which left inner elements unquoted (e.g. {@code [2026-05-13]}) and + * caused the server to reject {@code Array(Date)}/{@code Array(String)} parameters.

+ * + * @param value parameter value, may be {@code null} + * @return formatted parameter value for the {@code param_} interface + */ + static String formatStatementParam(Object value) { + if (value instanceof Collection || value instanceof Map || value instanceof Object[]) { + return formatStatementParamContainer(value); + } + // Scalars (and null) are passed through unchanged: 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 static String formatStatementParamContainer(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(formatStatementParamElement(entry.getKey())) + .append(':') + .append(formatStatementParamElement(entry.getValue())); + } + sb.append('}'); + } else { + // Collection or object array -> ClickHouse Array text: [e1,e2,...] + sb.append('['); + Collection elements = value instanceof Collection + ? (Collection) value + : Arrays.asList((Object[]) value); + boolean first = true; + for (Object element : elements) { + if (!first) { + sb.append(','); + } + first = false; + sb.append(formatStatementParamElement(element)); + } + sb.append(']'); + } + return sb.toString(); + } + + private static String formatStatementParamElement(Object value) { + if (value instanceof Collection || value instanceof Map || value instanceof Object[]) { + return formatStatementParamContainer(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/tuple + // text parser expects for nested elements. + return ClickHouseValues.convertToSqlExpression(value); + } + private HttpEntity wrapRequestEntity(HttpEntity httpEntity, Map requestConfig) { boolean clientCompression = ClientConfigProperties.COMPRESS_CLIENT_REQUEST.getOrDefault(requestConfig); diff --git a/client-v2/src/test/java/com/clickhouse/client/api/internal/HttpAPIClientHelperTest.java b/client-v2/src/test/java/com/clickhouse/client/api/internal/HttpAPIClientHelperTest.java index f03e84af6..0d6b6b59f 100644 --- a/client-v2/src/test/java/com/clickhouse/client/api/internal/HttpAPIClientHelperTest.java +++ b/client-v2/src/test/java/com/clickhouse/client/api/internal/HttpAPIClientHelperTest.java @@ -1,5 +1,111 @@ -package com.clickhouse.client.api.internal; - -public class HttpAPIClientHelperTest { - -} \ No newline at end of file +package com.clickhouse.client.api.internal; + +import org.testng.Assert; +import org.testng.annotations.Test; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.Collections; + +public class HttpAPIClientHelperTest { + + // --- Contrast cases: scalar parameters keep their bare, UNQUOTED text form (unchanged behavior). + // The server rejects a quoted scalar (e.g. '2026-05-13' for a Date parameter), so these must not + // be touched by the container-quoting fix. + + @Test(groups = {"unit"}) + public void testScalarDateParamIsNotQuoted() { + Assert.assertEquals(HttpAPIClientHelper.formatStatementParam(LocalDate.of(2026, 5, 13)), + "2026-05-13"); + } + + @Test(groups = {"unit"}) + public void testScalarStringAndNumberParamsAreUnchanged() { + Assert.assertEquals(HttpAPIClientHelper.formatStatementParam("hello"), "hello"); + Assert.assertEquals(HttpAPIClientHelper.formatStatementParam(42), "42"); + Assert.assertEquals(HttpAPIClientHelper.formatStatementParam(new BigDecimal("1.50")), "1.50"); + } + + // --- Fix cases: container parameters single-quote their String/temporal leaves so the server's + // Array/Map/Tuple text parser accepts them (previously emitted e.g. [2026-05-13] -> 400). + + @Test(groups = {"unit"}) + public void testArrayOfDatesQuotesElements() { + Assert.assertEquals( + HttpAPIClientHelper.formatStatementParam( + Arrays.asList(LocalDate.of(2026, 5, 13), LocalDate.of(2026, 5, 14))), + "['2026-05-13','2026-05-14']"); + } + + @Test(groups = {"unit"}) + public void testArrayOfStringsQuotesElements() { + Assert.assertEquals( + HttpAPIClientHelper.formatStatementParam(Arrays.asList("a", "b")), + "['a','b']"); + } + + @Test(groups = {"unit"}) + public void testArrayOfDateTimesQuotesElements() { + Assert.assertEquals( + HttpAPIClientHelper.formatStatementParam( + Collections.singletonList(LocalDateTime.of(2026, 5, 13, 16, 10, 0))), + "['2026-05-13 16:10:00']"); + } + + @Test(groups = {"unit"}) + public void testObjectArrayOfDatesQuotesElements() { + Assert.assertEquals( + HttpAPIClientHelper.formatStatementParam(new LocalDate[]{LocalDate.of(2026, 5, 13)}), + "['2026-05-13']"); + } + + @Test(groups = {"unit"}) + public void testNestedArrayOfDatesQuotesElements() { + Assert.assertEquals( + HttpAPIClientHelper.formatStatementParam( + Collections.singletonList(Collections.singletonList(LocalDate.of(2026, 5, 13)))), + "[['2026-05-13']]"); + } + + @Test(groups = {"unit"}) + public void testMapWithDateValueQuotesKeyAndValue() { + Assert.assertEquals( + HttpAPIClientHelper.formatStatementParam( + Collections.singletonMap("k", LocalDate.of(2026, 5, 13))), + "{'k':'2026-05-13'}"); + } + + @Test(groups = {"unit"}) + public void testArrayStringElementWithEmbeddedQuoteIsEscaped() { + Assert.assertEquals( + HttpAPIClientHelper.formatStatementParam(Collections.singletonList("a'b")), + "['a\\'b']"); + } + + @Test(groups = {"unit"}) + public void testArrayNullElementRendersAsNull() { + Assert.assertEquals( + HttpAPIClientHelper.formatStatementParam(Arrays.asList(LocalDate.of(2026, 5, 13), null)), + "['2026-05-13',NULL]"); + } + + // --- Contrast cases: numeric containers must stay UNQUOTED (quoting them causes the server to + // reject the array, e.g. ['1','2'] for Array(Int32)). + + @Test(groups = {"unit"}) + public void testArrayOfIntegersIsNotQuoted() { + Assert.assertEquals( + HttpAPIClientHelper.formatStatementParam(Arrays.asList(1, 2, 3)), + "[1,2,3]"); + } + + @Test(groups = {"unit"}) + public void testArrayOfDecimalsIsNotQuoted() { + Assert.assertEquals( + HttpAPIClientHelper.formatStatementParam( + Collections.singletonList(new BigDecimal("1.50"))), + "[1.50]"); + } +} 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..3bda8c42c 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,34 @@ 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))); + + 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", + 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'}"); + } + @Test(groups = {"integration"}) public void testExecuteQueryParam() throws ExecutionException, InterruptedException, TimeoutException { From 0972134bc1070a76ae87c2a59e64d3811f7c4718 Mon Sep 17 00:00:00 2001 From: Polyglot AI <293096396+polyglotAI-bot@users.noreply.github.com> Date: Fri, 26 Jun 2026 19:02:30 +0000 Subject: [PATCH 2/3] Refactor container query-param formatting into reusable DataTypeConverter Address review feedback on the Array/Map query-parameter quoting fix: - Move the param formatting out of the HttpAPIClientHelper transport class into a reusable DataTypeConverter.convertParameterToString instance method, so the transport class no longer knows about ClickHouse parameter formatting and there are no static formatting helpers on it. - Format parameters upstream in Client.query so the transport layer receives already-formatted text; addStatementParams reverts to a plain passthrough. - Detect containers via getClass().isArray() and iterate via reflection so primitive arrays (int[], long[], ...) and nested primitive arrays are formatted correctly instead of being mis-rendered as a scalar ("[I@.."). - Parametrize the unit tests (DataTypeConverterTest) and extend the integration test with object array, primitive array and nested array cases. --- .../com/clickhouse/client/api/Client.java | 11 +- .../api/internal/DataTypeConverter.java | 85 +++++++++++++ .../api/internal/HttpAPIClientHelper.java | 72 +---------- .../api/internal/DataTypeConverterTest.java | 55 +++++++++ .../api/internal/HttpAPIClientHelperTest.java | 116 +----------------- .../clickhouse/client/query/QueryTests.java | 13 +- 6 files changed, 168 insertions(+), 184 deletions(-) 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..dc00eba64 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 needs + // (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/main/java/com/clickhouse/client/api/internal/HttpAPIClientHelper.java b/client-v2/src/main/java/com/clickhouse/client/api/internal/HttpAPIClientHelper.java index 4238b5cb0..ab4b0153c 100644 --- a/client-v2/src/main/java/com/clickhouse/client/api/internal/HttpAPIClientHelper.java +++ b/client-v2/src/main/java/com/clickhouse/client/api/internal/HttpAPIClientHelper.java @@ -16,7 +16,6 @@ import com.clickhouse.client.api.http.ClickHouseHttpProto; import com.clickhouse.client.api.transport.Endpoint; import com.clickhouse.data.ClickHouseFormat; -import com.clickhouse.data.ClickHouseValues; import net.jpountz.lz4.LZ4Factory; import org.apache.commons.compress.compressors.CompressorStreamFactory; import org.apache.hc.client5.http.ConnectTimeoutException; @@ -766,79 +765,10 @@ private void addRequestParams(Map requestConfig, BiConsumer requestConfig, BiConsumer consumer) { if (requestConfig.containsKey(KEY_STATEMENT_PARAMS)) { Map params = (Map) requestConfig.get(KEY_STATEMENT_PARAMS); - params.forEach((k, v) -> consumer.accept("param_" + k, formatStatementParam(v))); + params.forEach((k, v) -> consumer.accept("param_" + k, String.valueOf(v))); } } - /** - * Formats a value for ClickHouse's HTTP {@code param_} query-parameter interface. - * - *

A top-level scalar is sent 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'} - the latter is rejected). When the value is a - * container ({@link Collection}, object array or {@link Map}, i.e. an {@code Array}, {@code Tuple} - * or {@code Map} parameter) it is rendered as a ClickHouse text literal in which {@code String} - * and temporal leaves are single-quoted while numeric/boolean leaves are left unquoted, as - * required by the server's array/map text parser. Previously this method used a blanket - * {@code String.valueOf(v)}, which left inner elements unquoted (e.g. {@code [2026-05-13]}) and - * caused the server to reject {@code Array(Date)}/{@code Array(String)} parameters.

- * - * @param value parameter value, may be {@code null} - * @return formatted parameter value for the {@code param_} interface - */ - static String formatStatementParam(Object value) { - if (value instanceof Collection || value instanceof Map || value instanceof Object[]) { - return formatStatementParamContainer(value); - } - // Scalars (and null) are passed through unchanged: 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 static String formatStatementParamContainer(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(formatStatementParamElement(entry.getKey())) - .append(':') - .append(formatStatementParamElement(entry.getValue())); - } - sb.append('}'); - } else { - // Collection or object array -> ClickHouse Array text: [e1,e2,...] - sb.append('['); - Collection elements = value instanceof Collection - ? (Collection) value - : Arrays.asList((Object[]) value); - boolean first = true; - for (Object element : elements) { - if (!first) { - sb.append(','); - } - first = false; - sb.append(formatStatementParamElement(element)); - } - sb.append(']'); - } - return sb.toString(); - } - - private static String formatStatementParamElement(Object value) { - if (value instanceof Collection || value instanceof Map || value instanceof Object[]) { - return formatStatementParamContainer(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/tuple - // text parser expects for nested elements. - return ClickHouseValues.convertToSqlExpression(value); - } - private HttpEntity wrapRequestEntity(HttpEntity httpEntity, Map requestConfig) { boolean clientCompression = ClientConfigProperties.COMPRESS_CLIENT_REQUEST.getOrDefault(requestConfig); 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/api/internal/HttpAPIClientHelperTest.java b/client-v2/src/test/java/com/clickhouse/client/api/internal/HttpAPIClientHelperTest.java index 0d6b6b59f..f03e84af6 100644 --- a/client-v2/src/test/java/com/clickhouse/client/api/internal/HttpAPIClientHelperTest.java +++ b/client-v2/src/test/java/com/clickhouse/client/api/internal/HttpAPIClientHelperTest.java @@ -1,111 +1,5 @@ -package com.clickhouse.client.api.internal; - -import org.testng.Assert; -import org.testng.annotations.Test; - -import java.math.BigDecimal; -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.util.Arrays; -import java.util.Collections; - -public class HttpAPIClientHelperTest { - - // --- Contrast cases: scalar parameters keep their bare, UNQUOTED text form (unchanged behavior). - // The server rejects a quoted scalar (e.g. '2026-05-13' for a Date parameter), so these must not - // be touched by the container-quoting fix. - - @Test(groups = {"unit"}) - public void testScalarDateParamIsNotQuoted() { - Assert.assertEquals(HttpAPIClientHelper.formatStatementParam(LocalDate.of(2026, 5, 13)), - "2026-05-13"); - } - - @Test(groups = {"unit"}) - public void testScalarStringAndNumberParamsAreUnchanged() { - Assert.assertEquals(HttpAPIClientHelper.formatStatementParam("hello"), "hello"); - Assert.assertEquals(HttpAPIClientHelper.formatStatementParam(42), "42"); - Assert.assertEquals(HttpAPIClientHelper.formatStatementParam(new BigDecimal("1.50")), "1.50"); - } - - // --- Fix cases: container parameters single-quote their String/temporal leaves so the server's - // Array/Map/Tuple text parser accepts them (previously emitted e.g. [2026-05-13] -> 400). - - @Test(groups = {"unit"}) - public void testArrayOfDatesQuotesElements() { - Assert.assertEquals( - HttpAPIClientHelper.formatStatementParam( - Arrays.asList(LocalDate.of(2026, 5, 13), LocalDate.of(2026, 5, 14))), - "['2026-05-13','2026-05-14']"); - } - - @Test(groups = {"unit"}) - public void testArrayOfStringsQuotesElements() { - Assert.assertEquals( - HttpAPIClientHelper.formatStatementParam(Arrays.asList("a", "b")), - "['a','b']"); - } - - @Test(groups = {"unit"}) - public void testArrayOfDateTimesQuotesElements() { - Assert.assertEquals( - HttpAPIClientHelper.formatStatementParam( - Collections.singletonList(LocalDateTime.of(2026, 5, 13, 16, 10, 0))), - "['2026-05-13 16:10:00']"); - } - - @Test(groups = {"unit"}) - public void testObjectArrayOfDatesQuotesElements() { - Assert.assertEquals( - HttpAPIClientHelper.formatStatementParam(new LocalDate[]{LocalDate.of(2026, 5, 13)}), - "['2026-05-13']"); - } - - @Test(groups = {"unit"}) - public void testNestedArrayOfDatesQuotesElements() { - Assert.assertEquals( - HttpAPIClientHelper.formatStatementParam( - Collections.singletonList(Collections.singletonList(LocalDate.of(2026, 5, 13)))), - "[['2026-05-13']]"); - } - - @Test(groups = {"unit"}) - public void testMapWithDateValueQuotesKeyAndValue() { - Assert.assertEquals( - HttpAPIClientHelper.formatStatementParam( - Collections.singletonMap("k", LocalDate.of(2026, 5, 13))), - "{'k':'2026-05-13'}"); - } - - @Test(groups = {"unit"}) - public void testArrayStringElementWithEmbeddedQuoteIsEscaped() { - Assert.assertEquals( - HttpAPIClientHelper.formatStatementParam(Collections.singletonList("a'b")), - "['a\\'b']"); - } - - @Test(groups = {"unit"}) - public void testArrayNullElementRendersAsNull() { - Assert.assertEquals( - HttpAPIClientHelper.formatStatementParam(Arrays.asList(LocalDate.of(2026, 5, 13), null)), - "['2026-05-13',NULL]"); - } - - // --- Contrast cases: numeric containers must stay UNQUOTED (quoting them causes the server to - // reject the array, e.g. ['1','2'] for Array(Int32)). - - @Test(groups = {"unit"}) - public void testArrayOfIntegersIsNotQuoted() { - Assert.assertEquals( - HttpAPIClientHelper.formatStatementParam(Arrays.asList(1, 2, 3)), - "[1,2,3]"); - } - - @Test(groups = {"unit"}) - public void testArrayOfDecimalsIsNotQuoted() { - Assert.assertEquals( - HttpAPIClientHelper.formatStatementParam( - Collections.singletonList(new BigDecimal("1.50"))), - "[1.50]"); - } -} +package com.clickhouse.client.api.internal; + +public class HttpAPIClientHelperTest { + +} \ 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 3bda8c42c..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 @@ -1671,12 +1671,19 @@ public void testContainerQueryParamsQuoteInnerValues() { 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({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); @@ -1687,6 +1694,10 @@ public void testContainerQueryParamsQuoteInnerValues() { // 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"}) From 52d3aeaf33fae41fa64cc275ebea4d5ffa486b11 Mon Sep 17 00:00:00 2001 From: Polyglot AI <293096396+polyglotAI-bot@users.noreply.github.com> Date: Sun, 28 Jun 2026 14:24:25 +0000 Subject: [PATCH 3/3] Reword param-formatting comment in Client.query for clarity Address review feedback on PR #2898: the comment on the parameter pre-formatting block read 'rendered with the quoting the server's param_ parser needs', which stacked two noun phrases and read as a garden-path sentence. Reword to 'quoted the way the server's param_ parser expects'. Comment-only change; no behavior change. --- client-v2/src/main/java/com/clickhouse/client/api/Client.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 dc00eba64..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 @@ -1717,7 +1717,7 @@ public CompletableFuture query(String sqlQuery, Map parser needs + // Array/Map values are quoted the way the server's param_ 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()) {