diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e4497ab4..2cb31e54e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,6 +78,13 @@ which inlines parameters as SQL literals and already escaped the backslash and single quote, is unchanged and covered by a new regression test. (https://github.com/ClickHouse/clickhouse-java/issues/2781) +- **[client-v2]** Fixed a `null` query-parameter value being sent as the literal string `"null"`, so + `Client.query(sql, params, ...)` binding a Java `null` to a scalar placeholder such as + `{x:Nullable(Decimal128(8))}` was rejected by the server with `BAD_QUERY_PARAMETER` + (`Value null cannot be parsed as Nullable(...)`). A top-level scalar `null` is now sent as the ClickHouse + `\N` NULL sentinel so it binds SQL `NULL`; a `null` nested inside an `Array`/`Map` parameter value + continues to render as the SQL `NULL` keyword. (https://github.com/ClickHouse/clickhouse-java/issues/2977) + - **[client-v2]** Fixed binary array decoding for nullable element types so `Array(Nullable(Float64))` and similar columns now return boxed arrays such as `Double[]` instead of `Object[]`. This keeps null-supporting arrays aligned with their element type while preserving the existing `Object[]` fallback for Variant/Dynamic/Geometry arrays. (https://github.com/ClickHouse/clickhouse-java/issues/2846) - **[client-v2]** Fixed `Float32`/`Float64` columns throwing `ClassCastException` when a value of a 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 0492091bc..086c499fb 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 @@ -37,6 +37,10 @@ public class DataTypeConverter { private static final String NULL = "NULL"; + // NULL sentinel for a top-level scalar param_ value: the server accepts \N (but not the + // literal "null") for a scalar Nullable(T) placeholder; a nested null uses the SQL NULL keyword. + private static final String NULL_SCALAR_PARAM = "\\N"; + public static final DataTypeConverter INSTANCE = new DataTypeConverter(); private final ListAsStringWriter listAsStringWriter = new ListAsStringWriter(); @@ -251,7 +255,9 @@ public String arrayToString(Object value, String columnDef) { * *

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 2026-05-13}, not {@code '2026-05-13'}). A top-level {@code null} becomes the + * {@code \N} NULL sentinel, which the server accepts for a scalar {@code Nullable(T)} + * placeholder (the literal {@code "null"} would be rejected). 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.

@@ -260,6 +266,12 @@ public String arrayToString(Object value, String columnDef) { * @return the formatted {@code param_} value */ public String convertParameterToString(Object value) { + if (value == null) { + // A top-level scalar null must be sent as \N; the server rejects the literal "null" + // (String.valueOf(null)). Nested nulls inside containers use the SQL NULL keyword and + // are handled by convertParameterContainer. + return NULL_SCALAR_PARAM; + } if (isParameterContainer(value)) { return convertParameterContainer(value); } @@ -270,8 +282,8 @@ public String convertParameterToString(Object value) { // the value). Escape those three characters so any String value round-trips. return escapeStringParameter((CharSequence) value); } - // Other scalars (and null) have no escapable characters and are read verbatim by the server, - // so they are passed through unquoted (e.g. Date, numbers, Identifier). + // Other scalars have no escapable characters and are read verbatim by the server, so they + // are passed through unquoted (e.g. Date, numbers, Identifier). return String.valueOf(value); } diff --git a/client-v2/src/test/java/com/clickhouse/client/ParameterizedQueryTest.java b/client-v2/src/test/java/com/clickhouse/client/ParameterizedQueryTest.java index 916c4a8cd..8b1d32035 100644 --- a/client-v2/src/test/java/com/clickhouse/client/ParameterizedQueryTest.java +++ b/client-v2/src/test/java/com/clickhouse/client/ParameterizedQueryTest.java @@ -355,4 +355,28 @@ private static Object[][] createStringParameterValues() { }; } + @Test(groups = {"integration"}, dataProvider = "nullableParamTypes") + void testNullParam(String paramType) throws Exception { + Map params = new HashMap<>(); + params.put("x", null); + try (QueryResponse response = client.query( + "SELECT isNull({x:" + paramType + "})", params).get(); + ClickHouseBinaryFormatReader reader = client.newBinaryFormatReader(response)) + { + reader.next(); + Assert.assertTrue(reader.getBoolean(1), + "null query parameter should bind SQL NULL for " + paramType); + } + } + + @DataProvider(name = "nullableParamTypes") + private static Object[][] nullableParamTypes() { + return new Object[][] { + { "Nullable(Int32)" }, + { "Nullable(String)" }, + { "Nullable(Decimal128(8))" }, + { "Nullable(Date)" }, + }; + } + } 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 e305740e7..8b9bf4681 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 @@ -186,7 +186,9 @@ public static Object[][] queryParameters() { {"hello", "hello"}, {42, "42"}, {new BigDecimal("1.50"), "1.50"}, - {null, "null"}, + // A scalar null is sent as the \N sentinel, which the server accepts for a scalar + // Nullable(T) placeholder; the literal "null" is rejected with BAD_QUERY_PARAMETER. + {null, "\\N"}, // --- Scalar String special characters: the server parses a {name:String} value with // deserializeTextEscaped, so the client escapes the three characters that reader treats diff --git a/docs/features.md b/docs/features.md index 6be995840..915e8ac9e 100644 --- a/docs/features.md +++ b/docs/features.md @@ -42,6 +42,7 @@ Compatibility-sensitive traits: - Named parameter typing is part of the contract: placeholders are written as `{name:Type}` and the supplied value must match the expected ClickHouse textual representation for that type. - String query parameters are expected to round-trip correctly for ordinary text, Unicode, slashes, dashes, leading or trailing spaces, and characters the server's escaped-text parameter parser treats as structural — a backslash, tab, or newline — which the client escapes before sending. +- A `null` query-parameter value is sent as the ClickHouse `\N` NULL sentinel, so a top-level `{name:Nullable(T)}` placeholder binds SQL `NULL`; a `null` nested inside an `Array`/`Map` parameter value renders as the SQL `NULL` keyword. - Runtime authentication changes are compatibility-sensitive: after `updateUserAndPassword()`, `updateAccessToken()`, or `updateBearerToken()`, subsequent requests from the same `Client` are expected to use the updated credentials. The authentication method itself is fixed at construction time; calling a runtime updater that does not match the configured method throws `ClientMisconfigurationException`. - String escaping behavior in `SQLUtils` is compatibility-sensitive: `enquoteLiteral()` uses SQL-style doubled single quotes, while `escapeSingleQuotes()` escapes both backslashes and single quotes with backslashes. - Identifier quoting behavior is stable API for helper callers: identifiers are double-quoted, embedded double quotes are doubled, and optional quoting keeps simple identifiers unchanged.