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/5] 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/5] 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/5] 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()) { From d6676f19521c3d21691c508b656cb30c816ec573 Mon Sep 17 00:00:00 2001 From: Polyglot AI <293096396+polyglotAI-bot@users.noreply.github.com> Date: Wed, 8 Jul 2026 21:58:45 +0000 Subject: [PATCH 4/5] Parametrize container query-param test with an extensible dataset Convert testContainerQueryParamsQuoteInnerValues to a TestNG @DataProvider so each container/type case is a {type, value, expected} row and the query is constructed from the row's type. New cases extend by adding a row. Also broadens coverage with Array(DateTime) and Array(Float64). --- .../clickhouse/client/query/QueryTests.java | 62 ++++++++----------- 1 file changed, 27 insertions(+), 35 deletions(-) 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 bcbb970b7..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 @@ -1661,43 +1661,35 @@ 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); + @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); - 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]]"); + Assert.assertEquals(records.get(0).getString("v"), expected); } @Test(groups = {"integration"}) From c17591f4d33ff47b435d5bbac02c6f5146032473 Mon Sep 17 00:00:00 2001 From: Polyglot AI <293096396+polyglotAI-bot@users.noreply.github.com> Date: Wed, 8 Jul 2026 22:53:36 +0000 Subject: [PATCH 5/5] Use an explicit stack for container query-param formatting Replace the recursive convertParameterContainer with an explicit ArrayDeque work stack so deeply nested container parameters cannot overflow the call stack (review feedback on #2898). Output is unchanged and stays pinned by the exact-string DataTypeConverterTest data provider; adds a deep-nesting regression test and a CHANGELOG entry describing container parameter formatting via DataTypeConverter. --- CHANGELOG.md | 2 + .../api/internal/DataTypeConverter.java | 119 ++++++++++++------ .../api/internal/DataTypeConverterTest.java | 22 ++++ 3 files changed, 104 insertions(+), 39 deletions(-) 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/internal/DataTypeConverter.java b/client-v2/src/main/java/com/clickhouse/client/api/internal/DataTypeConverter.java index 259749181..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 @@ -18,8 +18,10 @@ 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; @@ -254,58 +256,97 @@ private boolean isParameterContainer(Object value) { || (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(); - if (value instanceof Map) { - sb.append('{'); - boolean first = true; - for (Map.Entry entry : ((Map) value).entrySet()) { - if (!first) { - sb.append(','); + 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); } - 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)); + 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); } - } 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))); + } + 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); } } - sb.append(']'); + stack.push(ARRAY_OPEN); } - return sb.toString(); } - private String convertParameterElement(Object value) { - if (isParameterContainer(value)) { - return convertParameterContainer(value); + /** + * 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; } - // 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); } + 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 d3c316670..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 @@ -219,4 +219,26 @@ public static Object[][] 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