Skip to content

Commit 54cc9c6

Browse files
committed
Merge branch 'main' into 06/22/26/extract_request_logic
2 parents 70217f5 + 0d86ac6 commit 54cc9c6

6 files changed

Lines changed: 186 additions & 4 deletions

File tree

client-v2/src/main/java/com/clickhouse/client/api/ClientException.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,8 @@ public ClientException(String message) {
99
public ClientException(String message, Throwable cause) {
1010
super(message, cause);
1111
}
12+
13+
public ClientException(String message, Throwable cause, String queryId) {
14+
super(message, cause, queryId);
15+
}
1216
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package com.clickhouse.client.api;
2+
3+
/**
4+
* Transport-layer exception that is hard to categorize as connection initiation or data transfer.
5+
* These exceptions are not retryable by default.
6+
* Main purpose of this exception is to wrap transport-specific failures (e.g., SSL errors).
7+
*/
8+
public class TransportException extends ClickHouseException {
9+
public TransportException(String message, Throwable cause, String queryId) {
10+
super(message, cause, queryId);
11+
this.isRetryable = false;
12+
}
13+
}

client-v2/src/main/java/com/clickhouse/client/api/data_formats/internal/SerializerUtils.java

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,25 @@ public static void serializeData(OutputStream stream, Object value, ClickHouseCo
111111
}
112112
}
113113

114+
/**
115+
* Serializes a value that is nested inside a container ({@code Tuple} or {@code Map}). A nested
116+
* {@code Nullable} element is prefixed with its null-marker byte ({@code 0x00} when present,
117+
* {@code 0x01} when null), as the server expects for {@code Nullable} sub-columns in
118+
* {@code RowBinary}. For a top-level column this marker is instead written by
119+
* {@link com.clickhouse.client.api.data_formats.RowBinaryFormatSerializer#writeValuePreamble},
120+
* so this helper must only be used for nested elements.
121+
*/
122+
private static void serializeNestedData(OutputStream stream, Object value, ClickHouseColumn column) throws IOException {
123+
if (column.isNullable()) {
124+
if (value == null) {
125+
writeNull(stream);
126+
return;
127+
}
128+
writeNonNull(stream);
129+
}
130+
serializeData(stream, value, column);
131+
}
132+
114133
private static final Map<Class<?>, ClickHouseColumn> PREDEFINED_TYPE_COLUMNS = getPredefinedTypeColumnsMap();
115134

116135
private static Map<Class<?>, ClickHouseColumn> getPredefinedTypeColumnsMap() {
@@ -460,12 +479,12 @@ public static void serializeTupleData(OutputStream stream, Object value, ClickHo
460479
if (value instanceof List) {
461480
List<?> values = (List<?>) value;
462481
for (int i = 0; i < values.size(); i++) {
463-
serializeData(stream, values.get(i), column.getNestedColumns().get(i));
482+
serializeNestedData(stream, values.get(i), column.getNestedColumns().get(i));
464483
}
465484
} else if (value.getClass().isArray()) {
466485
// TODO: this code uses reflection - we might need to measure it and find faster solution.
467486
for (int i = 0; i < Array.getLength(value); i++) {
468-
serializeData(stream, Array.get(value, i), column.getNestedColumns().get(i));
487+
serializeNestedData(stream, Array.get(value, i), column.getNestedColumns().get(i));
469488
}
470489
} else {
471490
throw new IllegalArgumentException("Cannot serialize " + value + " as a tuple");
@@ -483,7 +502,7 @@ public static void serializeMapData(OutputStream stream, Object value, ClickHous
483502
map.forEach((key, val) -> {
484503
try {
485504
serializePrimitiveData(stream, key, Objects.requireNonNull(column.getKeyInfo()));
486-
serializeData(stream, val, Objects.requireNonNull(column.getValueInfo()));
505+
serializeNestedData(stream, val, Objects.requireNonNull(column.getValueInfo()));
487506
} catch (IOException e) {
488507
throw new RuntimeException(e);
489508
}

client-v2/src/main/java/com/clickhouse/client/api/internal/HttpAPIClientHelper.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import com.clickhouse.client.api.ConnectionReuseStrategy;
1111
import com.clickhouse.client.api.DataTransferException;
1212
import com.clickhouse.client.api.ServerException;
13+
import com.clickhouse.client.api.TransportException;
1314
import com.clickhouse.client.api.enums.ProxyType;
1415
import com.clickhouse.client.api.enums.SSLMode;
1516
import com.clickhouse.client.api.http.ClickHouseHttpProto;
@@ -988,7 +989,7 @@ public RuntimeException wrapException(String message, Exception cause, String qu
988989
}
989990

990991
if (cause instanceof SSLException) {
991-
return new ClickHouseException("SSL Problem", cause, queryId);
992+
return new TransportException("SSL Problem", cause, queryId);
992993
}
993994

994995
if (cause instanceof ConnectionRequestTimeoutException ||

client-v2/src/test/java/com/clickhouse/client/ErrorHandlingTests.java

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@
33
import com.clickhouse.client.api.Client;
44
import com.clickhouse.client.api.DataTransferException;
55
import com.clickhouse.client.api.ServerException;
6+
import com.clickhouse.client.api.TransportException;
67
import com.clickhouse.client.api.enums.Protocol;
78
import com.clickhouse.client.api.query.QuerySettings;
89
import org.testng.Assert;
10+
import org.testng.SkipException;
911
import org.testng.annotations.Test;
1012

1113
import java.time.temporal.ChronoUnit;
@@ -74,6 +76,31 @@ void testQueryTimeout() throws Exception {
7476
}
7577
}
7678

79+
@Test(groups = {"integration"})
80+
void testTransportException() throws Exception {
81+
if (isCloud()) {
82+
throw new SkipException("SSL Configuration tests - no need to test on cloud");
83+
}
84+
85+
ClickHouseNode secureServer = getSecureServer(ClickHouseProtocol.HTTP);
86+
87+
try (Client client = new Client.Builder()
88+
.addEndpoint("https://localhost:" + secureServer.getPort())
89+
.setUsername("default")
90+
.setPassword(ClickHouseServerForTest.getPassword())
91+
.compressClientRequest(true)
92+
.build()) {
93+
94+
final String queryId = "test-failure-query-id";
95+
TransportException tex = Assert.expectThrows(TransportException.class,
96+
() -> client.query("SELECT 1", new QuerySettings().setQueryId(queryId)).get());
97+
Assert.assertTrue(tex.getMessage().startsWith("SSL Problem"), "Unexpected message: " + tex.getMessage());
98+
Assert.assertEquals(tex.getQueryId(), queryId);
99+
Assert.assertTrue(tex.getCause() instanceof javax.net.ssl.SSLException,
100+
"Expected SSLException cause but was: " + tex.getCause());
101+
}
102+
}
103+
77104
protected Client.Builder newClient() {
78105
ClickHouseNode node = getServer(ClickHouseProtocol.HTTP);
79106
boolean isSecure = isCloud();

client-v2/src/test/java/com/clickhouse/client/api/data_formats/internal/SerializerUtilsTest.java

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,22 @@
44
import com.clickhouse.data.ClickHouseDataType;
55
import com.clickhouse.data.value.ClickHouseGeoPolygonValue;
66
import org.testng.Assert;
7+
import org.testng.annotations.DataProvider;
78
import org.testng.annotations.Test;
89

910
import java.io.ByteArrayInputStream;
1011
import java.io.ByteArrayOutputStream;
12+
import java.math.BigDecimal;
13+
import java.math.BigInteger;
14+
import java.net.InetAddress;
15+
import java.time.LocalDate;
16+
import java.util.ArrayList;
1117
import java.util.Arrays;
18+
import java.util.LinkedHashMap;
1219
import java.util.List;
20+
import java.util.Map;
1321
import java.util.TimeZone;
22+
import java.util.UUID;
1423

1524
@Test(groups = {"unit"})
1625
public class SerializerUtilsTest {
@@ -134,6 +143,115 @@ public void testGeometrySerializationRejectsMalformedList() {
134143
ClickHouseColumn.of("v", "Geometry")));
135144
}
136145

146+
@Test(dataProvider = "nestedNullableData")
147+
public void testNestedNullableRoundTrip(String typeName, Object value) throws Exception {
148+
ClickHouseColumn column = ClickHouseColumn.of("v", typeName);
149+
150+
ByteArrayOutputStream out = new ByteArrayOutputStream();
151+
SerializerUtils.serializeData(out, value, column);
152+
153+
Object actual = newReader(out.toByteArray()).readValue(column);
154+
Assert.assertEquals(normalize(actual), normalize(value));
155+
}
156+
157+
@DataProvider(name = "nestedNullableData")
158+
private Object[][] nestedNullableData() throws Exception {
159+
UUID uuid = UUID.fromString("61f0c404-5cb3-11e7-907b-a6006ad3dba0");
160+
InetAddress ipv4 = InetAddress.getByName("1.2.3.4");
161+
return new Object[][] {
162+
// Each present Nullable element sits in the MIDDLE of the schema: a non-nullable
163+
// leading column, the Nullable, then a trailing non-nullable Float64. If the
164+
// present-marker byte is dropped, every following byte shifts and the trailing
165+
// Float64 reads a wrong value, so a faulty serialization is detected positionally
166+
// rather than only by running out of bytes. The assertion compares the whole row.
167+
{"Tuple(Int32, Nullable(String), Float64)", Arrays.asList(7, "opt", 9.5d)},
168+
{"Tuple(Int32, Nullable(FixedString(3)), Float64)", Arrays.asList(7, "abc", 9.5d)},
169+
{"Tuple(Int32, Nullable(Int8), Float64)", Arrays.asList(7, (byte) -5, 9.5d)},
170+
{"Tuple(Int32, Nullable(UInt8), Float64)", Arrays.asList(7, (short) 200, 9.5d)},
171+
{"Tuple(Int32, Nullable(Int16), Float64)", Arrays.asList(7, (short) -1600, 9.5d)},
172+
{"Tuple(Int32, Nullable(UInt16), Float64)", Arrays.asList(7, 40000, 9.5d)},
173+
{"Tuple(Int32, Nullable(Int32), Float64)", Arrays.asList(7, 42, 9.5d)},
174+
{"Tuple(Int32, Nullable(UInt32), Float64)", Arrays.asList(7, 4_000_000_000L, 9.5d)},
175+
{"Tuple(Int32, Nullable(Int64), Float64)", Arrays.asList(7, -64L, 9.5d)},
176+
{"Tuple(Int32, Nullable(UInt64), Float64)", Arrays.asList(7, BigInteger.valueOf(64), 9.5d)},
177+
{"Tuple(Int32, Nullable(Float32), Float64)", Arrays.asList(7, 1.5f, 9.5d)},
178+
{"Tuple(Int32, Nullable(Float64), Float64)", Arrays.asList(7, 2.5d, 9.5d)},
179+
{"Tuple(Int32, Nullable(Bool), Float64)", Arrays.asList(7, true, 9.5d)},
180+
{"Tuple(Int32, Nullable(UUID), Float64)", Arrays.asList(7, uuid, 9.5d)},
181+
{"Tuple(Int32, Nullable(Date), Float64)", Arrays.asList(7, LocalDate.of(2021, 2, 3), 9.5d)},
182+
{"Tuple(Int32, Nullable(Decimal64(4)), Float64)", Arrays.asList(7, new BigDecimal("1.2345"), 9.5d)},
183+
{"Tuple(Int32, Nullable(IPv4), Float64)", Arrays.asList(7, ipv4, 9.5d)},
184+
185+
// A Tuple value given as a Java array (not a List) takes the other branch of
186+
// serializeTupleData, which is routed through the same nested-marker path, for
187+
// both a present value and a null.
188+
{"Tuple(Int32, Nullable(String), Float64)", new Object[] {7, "opt", 9.5d}},
189+
{"Tuple(Int32, Nullable(String), Float64)", new Object[] {7, null, 9.5d}},
190+
191+
// The Map value path: the Nullable map value sits between the key and a trailing
192+
// Float64, so a dropped value-marker misaligns the float.
193+
{"Tuple(Int32, Map(String, Nullable(String)), Float64)", Arrays.asList(7, newMap("k", "v"), 9.5d)},
194+
{"Tuple(Int32, Map(String, Nullable(Int32)), Float64)", Arrays.asList(7, newMap("k", 32), 9.5d)},
195+
{"Tuple(Int32, Map(String, Nullable(Float64)), Float64)", Arrays.asList(7, newMap("k", 2.5d), 9.5d)},
196+
{"Tuple(Int32, Map(String, Nullable(UUID)), Float64)", Arrays.asList(7, newMap("k", uuid), 9.5d)},
197+
198+
// Null elements/values still serialize a single null-marker byte; the trailing
199+
// Float64 confirms the following data stays aligned.
200+
{"Tuple(Int32, Nullable(String), Float64)", Arrays.asList(7, null, 9.5d)},
201+
{"Tuple(Int32, Nullable(Int32), Nullable(String), Float64)", Arrays.asList(7, null, null, 9.5d)},
202+
{"Tuple(Int32, Map(String, Nullable(String)), Float64)", Arrays.asList(7, newMap("k", null), 9.5d)},
203+
204+
// Containers compose: marker handling threads through nested Tuple/Map/Array,
205+
// including Array(Tuple(Nullable)) which is how Nested columns are encoded. A
206+
// trailing Float64 after each nested container detects misalignment.
207+
{"Array(Tuple(Int32, Nullable(String), Float64))",
208+
Arrays.asList(Arrays.asList(7, "a", 9.5d), Arrays.asList(7, null, 8.5d))},
209+
{"Tuple(String, Map(String, Nullable(Int32)), Float64)",
210+
Arrays.asList("id", newMap("k1", 7, "k2", null), 9.5d)},
211+
{"Tuple(Array(Nullable(Int32)), Float64)", Arrays.asList(Arrays.asList(1, null, 3), 9.5d)},
212+
213+
// Contrast: non-nullable nested elements must keep serializing without a marker,
214+
// so these rows round-trip identically with or without the fix.
215+
{"Tuple(Int32, String, Float64)", Arrays.asList(7, "tail", 9.5d)},
216+
{"Tuple(Int32, Map(String, String), Float64)", Arrays.asList(7, newMap("k", "v"), 9.5d)},
217+
};
218+
}
219+
220+
// Normalizes Tuple (Object[]) and Array (ArrayValue / List) results to nested Lists so
221+
// round-tripped values compare structurally regardless of the container representation the
222+
// reader returns.
223+
@SuppressWarnings("unchecked")
224+
private static Object normalize(Object value) {
225+
if (value instanceof BinaryStreamReader.ArrayValue) {
226+
return normalizeList(((BinaryStreamReader.ArrayValue) value).asList());
227+
} else if (value instanceof Object[]) {
228+
return normalizeList(Arrays.asList((Object[]) value));
229+
} else if (value instanceof List) {
230+
return normalizeList((List<Object>) value);
231+
} else if (value instanceof Map) {
232+
Map<Object, Object> result = new LinkedHashMap<>();
233+
((Map<Object, Object>) value).forEach((k, v) -> result.put(k, normalize(v)));
234+
return result;
235+
}
236+
return value;
237+
}
238+
239+
private static List<Object> normalizeList(List<Object> values) {
240+
List<Object> result = new ArrayList<>(values.size());
241+
for (Object v : values) {
242+
result.add(normalize(v));
243+
}
244+
return result;
245+
}
246+
247+
private static Map<Object, Object> newMap(Object... kv) {
248+
Map<Object, Object> map = new LinkedHashMap<>();
249+
for (int i = 0; i < kv.length; i += 2) {
250+
map.put(kv[i], kv[i + 1]);
251+
}
252+
return map;
253+
}
254+
137255
private void assertCustomGeoTypeTag(String typeName) throws Exception {
138256
ByteArrayOutputStream out = new ByteArrayOutputStream();
139257
SerializerUtils.writeDynamicTypeTag(out, ClickHouseColumn.of("v", typeName));

0 commit comments

Comments
 (0)