Skip to content

Commit 6262ef9

Browse files
committed
Fixed issue with POJO reading when binary strings are enabled
1 parent c5b3173 commit 6262ef9

5 files changed

Lines changed: 153 additions & 3 deletions

File tree

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,17 @@
66
import java.util.Objects;
77

88
/**
9-
* Holder for ClickHouse {@code String} or {@code FixedString} values that preserves raw bytes
9+
* Read-time holder for ClickHouse {@code String} or {@code FixedString} values that preserves raw bytes
1010
* to avoid lossy decoding and unnecessary allocations.
1111
* <p>
12+
* <b>This is an internal value holder, not a general-purpose type for user code.</b> It is produced only
13+
* by the read path when the binary-string feature is enabled (for example {@code GenericRecord.getObject}
14+
* or {@code BinaryStreamReader.readValue} without a type hint), so that callers that need exact bytes can
15+
* obtain them via {@link #toByteArray()} and callers that need text can decode via {@link #asString()}.
16+
* It is <b>not</b> a supported field type for POJO binding: declare POJO fields for String/FixedString
17+
* columns as {@link String} or {@code byte[]} instead. Normal application code should generally consume
18+
* {@link String} or {@code byte[]} rather than holding onto a {@code StringValue}.
19+
* <p>
1220
* <b>This is a mutable structure and must be used with care.</b> To avoid copying, it does not
1321
* duplicate the bytes it is given: the constructor wraps the supplied array/buffer instead of
1422
* copying it, and {@link #toByteArray()} returns a direct reference to the backing array rather

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

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
import java.math.BigInteger;
2828
import java.net.Inet4Address;
2929
import java.net.Inet6Address;
30+
import java.nio.charset.StandardCharsets;
3031
import java.sql.Timestamp;
3132
import java.time.Duration;
3233
import java.time.Instant;
@@ -944,6 +945,48 @@ public static String convertToString(Object value) {
944945
return java.lang.String.valueOf(value);
945946
}
946947

948+
/**
949+
* Converts a value read for a top-level {@code String}/{@code FixedString} column into a {@link String}
950+
* for POJO binding. With the binary-string feature enabled the reader produces a {@link StringValue}
951+
* holder (a read-time value holder, not a POJO field type); otherwise it produces a plain {@link String}.
952+
* A {@code null} value (e.g. a SQL {@code NULL}) is passed through. Used from the bytecode generated by
953+
* {@link #compilePOJOSetter(Method, ClickHouseColumn)}.
954+
*
955+
* @param value value returned by {@code BinaryStreamReader.readValue}
956+
* @return the decoded string, or {@code null}
957+
*/
958+
public static String stringValueToString(Object value) {
959+
if (value == null) {
960+
return null;
961+
}
962+
if (value instanceof StringValue) {
963+
return ((StringValue) value).asString();
964+
}
965+
return (String) value;
966+
}
967+
968+
/**
969+
* Converts a value read for a top-level {@code String}/{@code FixedString} column into the raw bytes for
970+
* POJO binding. A {@link StringValue} yields its preserved bytes (no lossy decoding); a plain
971+
* {@link String} is encoded as UTF-8, matching the historical behaviour. A {@code null} value is passed
972+
* through. Used from the bytecode generated by {@link #compilePOJOSetter(Method, ClickHouseColumn)}.
973+
*
974+
* @param value value returned by {@code BinaryStreamReader.readValue}
975+
* @return the raw bytes, or {@code null}
976+
*/
977+
public static byte[] stringValueToByteArray(Object value) {
978+
if (value == null) {
979+
return null;
980+
}
981+
if (value instanceof StringValue) {
982+
return ((StringValue) value).toByteArray();
983+
}
984+
if (value instanceof String) {
985+
return ((String) value).getBytes(StandardCharsets.UTF_8);
986+
}
987+
return (byte[]) value;
988+
}
989+
947990
/**
948991
* Writes raw bytes as a ClickHouse {@code FixedString(length)} value. The bytes are written as-is and
949992
* right-padded with zero bytes when shorter than {@code length}.
@@ -1073,7 +1116,26 @@ public static POJOFieldDeserializer compilePOJOSetter(Method setterMethod, Click
10731116
Type.getType(Class.class)),
10741117
false);
10751118

1076-
if (List.class.isAssignableFrom(targetType) && column.getDataType() == ClickHouseDataType.Tuple) {
1119+
boolean stringLikeColumn = column.getDataType() == ClickHouseDataType.String
1120+
|| column.getDataType() == ClickHouseDataType.FixedString;
1121+
1122+
if (stringLikeColumn && targetType == String.class) {
1123+
// With binary-string support enabled readValue yields a StringValue holder; convert it (or a
1124+
// plain String when the feature is off) to the String the setter expects. StringValue is a
1125+
// read-time holder, not a supported POJO field type.
1126+
mv.visitMethodInsn(INVOKESTATIC,
1127+
Type.getInternalName(SerializerUtils.class),
1128+
"stringValueToString",
1129+
Type.getMethodDescriptor(Type.getType(String.class), Type.getType(Object.class)),
1130+
false);
1131+
} else if (stringLikeColumn && targetType == byte[].class) {
1132+
// Convert the StringValue (or plain String) to raw bytes for a byte[] field.
1133+
mv.visitMethodInsn(INVOKESTATIC,
1134+
Type.getInternalName(SerializerUtils.class),
1135+
"stringValueToByteArray",
1136+
Type.getMethodDescriptor(Type.getType(byte[].class), Type.getType(Object.class)),
1137+
false);
1138+
} else if (List.class.isAssignableFrom(targetType) && column.getDataType() == ClickHouseDataType.Tuple) {
10771139
mv.visitTypeInsn(CHECKCAST, Type.getInternalName(Object[].class));
10781140
mv.visitMethodInsn(INVOKESTATIC,
10791141
Type.getInternalName(Arrays.class),

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

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import com.clickhouse.client.api.data_formats.internal.SerializerUtils;
77
import com.clickhouse.client.api.metadata.TableSchema;
88
import com.clickhouse.client.api.query.QuerySettings;
9+
import com.clickhouse.client.api.serde.POJOFieldDeserializer;
910
import com.clickhouse.data.ClickHouseColumn;
1011
import com.clickhouse.data.format.BinaryStreamUtils;
1112
import org.testng.Assert;
@@ -15,6 +16,7 @@
1516
import java.io.ByteArrayInputStream;
1617
import java.io.ByteArrayOutputStream;
1718
import java.io.IOException;
19+
import java.lang.reflect.Method;
1820
import java.nio.ByteBuffer;
1921
import java.nio.charset.Charset;
2022
import java.nio.charset.StandardCharsets;
@@ -286,6 +288,83 @@ public void testDefaultBehaviorReturnsString() throws IOException {
286288
Assert.assertEquals(read, "still a string");
287289
}
288290

291+
// ---- POJO binding (queryAll/readToPOJO) over String columns with the feature enabled ----
292+
293+
/**
294+
* Minimal POJO with the only two field representations supported for top-level String/FixedString
295+
* columns: {@link String} and {@code byte[]}. {@link StringValue} is a read-time holder and is not a
296+
* supported POJO field type.
297+
*/
298+
public static class StringPojo {
299+
private String asString;
300+
private byte[] asBytes;
301+
302+
public void setAsString(String asString) { this.asString = asString; }
303+
public void setAsBytes(byte[] asBytes) { this.asBytes = asBytes; }
304+
}
305+
306+
private static byte[] stringWire(byte[] value) throws IOException {
307+
ByteArrayOutputStream baos = new ByteArrayOutputStream();
308+
BinaryStreamUtils.writeString(baos, value);
309+
return baos.toByteArray();
310+
}
311+
312+
private static POJOFieldDeserializer setterFor(String name, ClickHouseColumn column) throws Exception {
313+
Method setter = StringPojo.class.getMethod(name,
314+
name.equals("setAsString") ? String.class : byte[].class);
315+
return SerializerUtils.compilePOJOSetter(setter, column);
316+
}
317+
318+
@Test
319+
public void testPojoSetterStringFieldDecodesWhenFeatureEnabled() throws Exception {
320+
// Regression: with the feature enabled the reader produces StringValue, but a String setter must
321+
// still receive a decoded String (the compiled setter casts the readValue result to String).
322+
ClickHouseColumn column = ClickHouseColumn.of("s", "String");
323+
byte[] wire = stringWire("hello".getBytes(StandardCharsets.UTF_8));
324+
325+
StringPojo pojo = new StringPojo();
326+
setterFor("setAsString", column).setValue(pojo, reader(wire, true), column);
327+
Assert.assertEquals(pojo.asString, "hello");
328+
}
329+
330+
@Test
331+
public void testPojoSetterByteArrayFieldReceivesRawBytesWhenFeatureEnabled() throws Exception {
332+
// A byte[] setter must receive the raw bytes (preserving non-UTF-8 content) instead of a StringValue.
333+
ClickHouseColumn column = ClickHouseColumn.of("s", "String");
334+
byte[] binary = {(byte) 0xDE, (byte) 0xAD, (byte) 0x00, (byte) 0xBE, (byte) 0xEF};
335+
byte[] wire = stringWire(binary);
336+
337+
StringPojo pojo = new StringPojo();
338+
setterFor("setAsBytes", column).setValue(pojo, reader(wire, true), column);
339+
Assert.assertEquals(pojo.asBytes, binary);
340+
}
341+
342+
@Test
343+
public void testPojoSetterFixedStringByteArrayFieldWhenFeatureEnabled() throws Exception {
344+
// A byte[] setter over a FixedString column must receive the raw bytes, preserving binary content.
345+
ClickHouseColumn column = ClickHouseColumn.of("s", "FixedString(3)");
346+
byte[] binary = {(byte) 0xAA, (byte) 0xBB, (byte) 0xCC};
347+
ByteArrayOutputStream baos = new ByteArrayOutputStream();
348+
baos.write(binary); // FixedString(3) is exactly 3 raw bytes on the wire
349+
350+
StringPojo pojo = new StringPojo();
351+
setterFor("setAsBytes", column).setValue(pojo, reader(baos.toByteArray(), true), column);
352+
Assert.assertEquals(pojo.asBytes, binary);
353+
}
354+
355+
@Test
356+
public void testPojoSetterFixedStringStringFieldWhenFeatureEnabled() throws Exception {
357+
// A String setter over a FixedString column must receive the decoded String.
358+
ClickHouseColumn column = ClickHouseColumn.of("s", "FixedString(3)");
359+
byte[] bytes = "abc".getBytes(StandardCharsets.UTF_8);
360+
ByteArrayOutputStream baos = new ByteArrayOutputStream();
361+
baos.write(bytes);
362+
363+
StringPojo pojo = new StringPojo();
364+
setterFor("setAsString", column).setValue(pojo, reader(baos.toByteArray(), true), column);
365+
Assert.assertEquals(pojo.asString, "abc");
366+
}
367+
289368
// ---- Writing binary String values ----
290369

291370
@Test

docs/features.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ This document lists stable, user-visible behavior in `client-v2` and `jdbc-v2` t
1616
- Parameterized SQL: Accepts named query parameters and can send them through supported HTTP request encodings.
1717
- Result materialization helpers: Provides streaming `Records`, generic row access, and convenience APIs that materialize all rows into generic records or typed POJOs.
1818
- Binary format readers: Reads ClickHouse binary result formats including `Native`, `RowBinary`, `RowBinaryWithNames`, and `RowBinaryWithNamesAndTypes`.
19-
- Binary string support: Opt-in through the `binary_string_support` property (or `Client.Builder#binaryStringSupport(boolean)`), disabled by default. When enabled, top-level `String` and `FixedString` columns are read as `StringValue`, which preserves the raw bytes (`toByteArray()`/`asByteBuffer()`) and lazily decodes a `String` (`asString()`), instead of eagerly decoding to a `String`. Strings nested inside containers (`Array`, `Map`, `Tuple`, `Nested`, `Variant`) are still read as `String`.
19+
- Binary string support: Opt-in through the `binary_string_support` property (or `Client.Builder#binaryStringSupport(boolean)`), disabled by default. When enabled, untyped reads (e.g. `GenericRecord.getObject(...)`/`BinaryStreamReader.readValue(...)`) of top-level `String` and `FixedString` columns return a `StringValue`, which preserves the raw bytes (`toByteArray()`/`asByteBuffer()`) and lazily decodes a `String` (`asString()`), instead of eagerly decoding to a `String`. `StringValue` is a read-time holder, not a supported POJO field type: typed `queryAll(...)`/`readToPOJO` binding still maps these columns to `String` (decoded) or `byte[]` (raw bytes) according to the POJO field type. Strings nested inside containers (`Array`, `Map`, `Tuple`, `Nested`, `Variant`) are still read as `String`.
2020
- JSONEachRow text reader: Can stream `JSONEachRow` responses through a caller-supplied `JsonParser`, with Jackson and Gson parser factory implementations available as optional classpath dependencies, and infers a best-effort schema from the first row.
2121
- Data type conversion: Maps ClickHouse types to Java values for binary reads, POJO binding, and SQL parameter formatting, including date/time handling.
2222
- Geometry type support: For ClickHouse `25.11+`, where `Geometry` changed from a string alias to `Variant(Point, Ring, LineString, MultiLineString, Polygon, MultiPolygon)`, the client reads and writes `Geometry` values through generic records, binary readers, POJO binding, and SQL parameter formatting, using Java array dimensionality to represent the geometry shape.

jdbc-v2/src/main/java/com/clickhouse/jdbc/ResultSetImpl.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,7 @@ public InputStream getUnicodeStream(int columnIndex) throws SQLException {
287287

288288
@Override
289289
public InputStream getBinaryStream(int columnIndex) throws SQLException {
290+
checkClosed();
290291
try {
291292
if (reader.hasValue(columnIndex)) {
292293
byte[] bytes = reader.getByteArray(columnIndex);

0 commit comments

Comments
 (0)