Skip to content

Commit c20b637

Browse files
Merge remote-tracking branch 'origin/main' into polyglot/enum-null-non-nullable-npe
# Conflicts: # CHANGELOG.md
2 parents 9ac3a01 + 39fed41 commit c20b637

5 files changed

Lines changed: 234 additions & 17 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,15 @@
44

55
### Bug Fixes
66

7+
- **[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)
8+
9+
- **[client-v2]** Fixed `Float32`/`Float64` columns throwing `ClassCastException` when a value of a
10+
non-matching boxed numeric type was supplied through the `Object`-typed insert surface — for example a
11+
`Double` (the natural type of a Java literal like `1.5`) for a `Float32` column, or a `Float` for a
12+
`Float64` column. The `RowBinary` serializer now narrows any `Number` (and, like the `Int*` columns,
13+
a `String`/`Boolean`) through `Number#floatValue()`/`Number#doubleValue()`, so the float columns accept
14+
the same value types the integer columns already did. (https://github.com/ClickHouse/clickhouse-java/issues/2930)
15+
716
- **[client-v2]** Fixed a `NullPointerException` when serializing a `null` value into a non-nullable
817
`Enum8`/`Enum16` column. `SerializerUtils.serializeEnumData` had no `null` guard, so a `null` in a
918
non-nullable enum column reached `value.getClass()` and failed the RowBinary insert path with a confusing

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

Lines changed: 67 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -609,8 +609,8 @@ public ArrayValue readArray(ClickHouseColumn column) throws IOException {
609609
ArrayValue array;
610610
ClickHouseColumn itemTypeColumn = column.getNestedColumns().get(0);
611611
if (len == 0) {
612-
Class<?> itemClass = itemTypeColumn.getDataType().getPrimitiveClass();
613-
array = new ArrayValue(itemClass == null ? Object.class : itemClass, 0);
612+
Class<?> itemClass = resolveArrayItemClass(itemTypeColumn);
613+
array = new ArrayValue(itemClass, 0);
614614
} else if (column.getArrayNestedLevel() == 1) {
615615
array = readArrayItem(itemTypeColumn, len);
616616
} else {
@@ -625,8 +625,13 @@ public ArrayValue readArray(ClickHouseColumn column) throws IOException {
625625

626626
public ArrayValue readArrayItem(ClickHouseColumn itemTypeColumn, int len) throws IOException {
627627
ArrayValue array;
628-
if (itemTypeColumn.isNullable()
629-
|| itemTypeColumn.getDataType() == ClickHouseDataType.Variant
628+
if (itemTypeColumn.isNullable()) {
629+
Class<?> itemClass = resolveArrayItemClass(itemTypeColumn);
630+
array = new ArrayValue(itemClass, len);
631+
for (int i = 0; i < len; i++) {
632+
array.set(i, readValue(itemTypeColumn));
633+
}
634+
} else if (itemTypeColumn.getDataType() == ClickHouseDataType.Variant
630635
|| itemTypeColumn.getDataType() == ClickHouseDataType.Dynamic
631636
|| itemTypeColumn.getDataType() == ClickHouseDataType.Geometry) {
632637
array = new ArrayValue(Object.class, len);
@@ -667,6 +672,64 @@ public ArrayValue readArrayItem(ClickHouseColumn itemTypeColumn, int len) throws
667672
return array;
668673
}
669674

675+
/**
676+
* Resolves the Java class that {@link #readValue} actually returns for a given column
677+
* so that it can be used as the component type of an array.
678+
*
679+
* <p>For unsigned integer types, {@code readValue} widens the value (e.g. UInt8 → Short,
680+
* UInt32 → Long), so we use {@link ClickHouseDataType#getWiderObjectClass()} or
681+
* {@link ClickHouseDataType#getWiderPrimitiveClass()} which mirrors that widening.
682+
* For Enum types, {@code readValue} returns {@link EnumValue} rather than the
683+
* declared {@code String.class}. All other types use {@link ClickHouseDataType#getObjectClass()}
684+
* or {@link ClickHouseDataType#getPrimitiveClass()}.
685+
*
686+
* @param itemTypeColumn the element column of the array
687+
* @return the Java class to use as the array component type; never {@code null}
688+
*/
689+
private static Class<?> resolveArrayItemClass(ClickHouseColumn itemTypeColumn) {
690+
ClickHouseDataType dataType = itemTypeColumn.getDataType();
691+
if (itemTypeColumn.isNullable()) {
692+
switch (dataType) {
693+
case UInt8:
694+
case UInt16:
695+
case UInt32:
696+
case UInt64:
697+
return dataType.getWiderObjectClass();
698+
case Enum8:
699+
case Enum16:
700+
return EnumValue.class;
701+
default:
702+
Class<?> cls = dataType.getObjectClass();
703+
return cls == null ? Object.class : cls;
704+
}
705+
} else {
706+
switch (dataType) {
707+
case UInt8:
708+
case UInt16:
709+
case UInt32:
710+
case UInt64:
711+
return dataType.getWiderPrimitiveClass();
712+
case Enum8:
713+
case Enum16:
714+
return EnumValue.class;
715+
case Variant:
716+
case Dynamic:
717+
case Geometry:
718+
return Object.class;
719+
case Array:
720+
return ArrayValue.class;
721+
case Tuple:
722+
case Nested:
723+
return Object[].class;
724+
case Map:
725+
return Map.class;
726+
default:
727+
Class<?> cls = dataType.getPrimitiveClass();
728+
return cls == null ? Object.class : cls;
729+
}
730+
}
731+
}
732+
670733
public void skipValue(ClickHouseColumn column) throws IOException {
671734
readValue(column, null);
672735
}

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -555,10 +555,10 @@ private static void serializePrimitiveData(OutputStream stream, Object value, Cl
555555
BinaryStreamUtils.writeUnsignedInt256(stream, NumberConverter.toBigInteger(value));
556556
break;
557557
case Float32:
558-
BinaryStreamUtils.writeFloat32(stream, (float) value);
558+
BinaryStreamUtils.writeFloat32(stream, NumberConverter.toFloat(value));
559559
break;
560560
case Float64:
561-
BinaryStreamUtils.writeFloat64(stream, (double) value);
561+
BinaryStreamUtils.writeFloat64(stream, NumberConverter.toDouble(value));
562562
break;
563563
case Decimal:
564564
case Decimal32:

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

Lines changed: 90 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.clickhouse.client.api.data_formats.internal;
22

33
import com.clickhouse.data.ClickHouseColumn;
4+
import com.clickhouse.data.format.BinaryStreamUtils;
45

56
import java.io.ByteArrayInputStream;
67
import java.io.ByteArrayOutputStream;
@@ -206,24 +207,102 @@ public void testReadNullVariantReturnsNull() throws Exception {
206207
}
207208

208209
@Test
209-
public void testReadVarIntReadsMaxInt() throws IOException {
210-
Assert.assertEquals(readVarInt((byte) 0xFF, (byte) 0xFF, (byte) 0xFF, (byte) 0xFF, (byte) 0x07),
211-
Integer.MAX_VALUE);
210+
public void testNullableArrayValueUsesBoxedComponentType() throws Exception {
211+
ByteArrayOutputStream baos = new ByteArrayOutputStream();
212+
BinaryStreamUtils.writeVarInt(baos, 2);
213+
BinaryStreamUtils.writeNonNull(baos);
214+
BinaryStreamUtils.writeFloat64(baos, 1.0);
215+
BinaryStreamUtils.writeNonNull(baos);
216+
BinaryStreamUtils.writeFloat64(baos, 2.0);
217+
218+
BinaryStreamReader reader = new BinaryStreamReader(
219+
new ByteArrayInputStream(baos.toByteArray()),
220+
TimeZone.getTimeZone("UTC"),
221+
null,
222+
new BinaryStreamReader.CachingByteBufferAllocator(),
223+
false,
224+
null);
225+
226+
BinaryStreamReader.ArrayValue array = (BinaryStreamReader.ArrayValue) reader.readValue(
227+
ClickHouseColumn.of("v", "Array(Nullable(Float64))"));
228+
229+
Assert.assertEquals(array.getArray().getClass().getComponentType(), Double.class);
212230
}
213231

214232
@Test
215-
public void testReadVarIntRejectsOverflow() {
216-
Assert.assertThrows(IOException.class,
217-
() -> readVarInt((byte) 0x80, (byte) 0x80, (byte) 0x80, (byte) 0x80, (byte) 0x08));
233+
public void testNullableUnsignedArrayUsesWidenedType() throws Exception {
234+
ByteArrayOutputStream baos = new ByteArrayOutputStream();
235+
BinaryStreamUtils.writeVarInt(baos, 2);
236+
BinaryStreamUtils.writeNonNull(baos);
237+
BinaryStreamUtils.writeUnsignedInt8(baos, 10);
238+
BinaryStreamUtils.writeNonNull(baos);
239+
BinaryStreamUtils.writeUnsignedInt8(baos, 20);
240+
241+
BinaryStreamReader reader = new BinaryStreamReader(
242+
new ByteArrayInputStream(baos.toByteArray()),
243+
TimeZone.getTimeZone("UTC"),
244+
null,
245+
new BinaryStreamReader.CachingByteBufferAllocator(),
246+
false,
247+
null);
248+
249+
BinaryStreamReader.ArrayValue array = (BinaryStreamReader.ArrayValue) reader.readValue(
250+
ClickHouseColumn.of("v", "Array(Nullable(UInt8))"));
251+
252+
Assert.assertEquals(array.getArray().getClass().getComponentType(), Short.class);
218253
}
219254

220255
@Test
221-
public void testReadVarIntRejectsOverlongValue() {
222-
Assert.assertThrows(IOException.class,
223-
() -> readVarInt((byte) 0x80, (byte) 0x80, (byte) 0x80, (byte) 0x80, (byte) 0x80, (byte) 0x01));
256+
public void testNullableEnumArrayUsesEnumValueType() throws Exception {
257+
ByteArrayOutputStream baos = new ByteArrayOutputStream();
258+
BinaryStreamUtils.writeVarInt(baos, 2);
259+
BinaryStreamUtils.writeNonNull(baos);
260+
baos.write(1); // enum ordinal for 'a'
261+
BinaryStreamUtils.writeNonNull(baos);
262+
baos.write(2); // enum ordinal for 'b'
263+
264+
BinaryStreamReader reader = new BinaryStreamReader(
265+
new ByteArrayInputStream(baos.toByteArray()),
266+
TimeZone.getTimeZone("UTC"),
267+
null,
268+
new BinaryStreamReader.CachingByteBufferAllocator(),
269+
false,
270+
null);
271+
272+
BinaryStreamReader.ArrayValue array = (BinaryStreamReader.ArrayValue) reader.readValue(
273+
ClickHouseColumn.of("v", "Array(Nullable(Enum8('a'=1,'b'=2)))"));
274+
275+
Assert.assertEquals(array.getArray().getClass().getComponentType(),
276+
BinaryStreamReader.EnumValue.class);
277+
}
278+
279+
@Test
280+
public void testEmptyArrayTypes() throws Exception {
281+
assertEmptyArrayComponentType("Array(UInt8)", short.class);
282+
assertEmptyArrayComponentType("Array(Nullable(UInt8))", Short.class);
283+
assertEmptyArrayComponentType("Array(String)", String.class);
284+
assertEmptyArrayComponentType("Array(Nullable(String))", String.class);
285+
assertEmptyArrayComponentType("Array(Enum8('a'=1))", BinaryStreamReader.EnumValue.class);
286+
assertEmptyArrayComponentType("Array(Nullable(Enum8('a'=1)))", BinaryStreamReader.EnumValue.class);
287+
assertEmptyArrayComponentType("Array(Variant(Int32, String))", Object.class);
288+
assertEmptyArrayComponentType("Array(Array(String))", BinaryStreamReader.ArrayValue.class);
224289
}
225290

226-
private static int readVarInt(byte... bytes) throws IOException {
227-
return BinaryStreamReader.readVarInt(new ByteArrayInputStream(bytes));
291+
private void assertEmptyArrayComponentType(String columnType, Class<?> expectedComponentType) throws Exception {
292+
ByteArrayOutputStream baos = new ByteArrayOutputStream();
293+
BinaryStreamUtils.writeVarInt(baos, 0);
294+
295+
BinaryStreamReader reader = new BinaryStreamReader(
296+
new ByteArrayInputStream(baos.toByteArray()),
297+
TimeZone.getTimeZone("UTC"),
298+
null,
299+
new BinaryStreamReader.CachingByteBufferAllocator(),
300+
false,
301+
null);
302+
303+
BinaryStreamReader.ArrayValue array = (BinaryStreamReader.ArrayValue) reader.readValue(
304+
ClickHouseColumn.of("v", columnType));
305+
306+
Assert.assertEquals(array.getArray().getClass().getComponentType(), expectedComponentType, "Failed for " + columnType);
228307
}
229308
}

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

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,14 @@
55
import com.clickhouse.data.ClickHouseColumn;
66
import com.clickhouse.data.format.BinaryStreamUtils;
77
import org.testng.Assert;
8+
import org.testng.annotations.DataProvider;
89
import org.testng.annotations.Test;
910

1011
import java.io.ByteArrayInputStream;
1112
import java.io.ByteArrayOutputStream;
1213
import java.io.IOException;
1314
import java.math.BigDecimal;
15+
import java.math.BigInteger;
1416
import java.util.TimeZone;
1517

1618
public class SerializerUtilsPrimitiveSerializationTests {
@@ -45,6 +47,70 @@ public void testSerializeDecimalTargetFromGenericNumberValue() throws IOExceptio
4547
Assert.assertEquals(reader.getBigDecimal("value"), new BigDecimal("987654.321"));
4648
}
4749

50+
@Test(dataProvider = "floatColumnValues")
51+
public void testSerializeFloatColumnFromVariousNumberTypes(String type, Object value, double expected)
52+
throws IOException {
53+
RowBinaryWithNamesAndTypesFormatReader reader = serializeSingleValue(type, value);
54+
55+
reader.next();
56+
57+
Assert.assertEquals(reader.getDouble("value"), expected, 0.0);
58+
}
59+
60+
@DataProvider(name = "floatColumnValues")
61+
private Object[][] floatColumnValues() {
62+
return new Object[][] {
63+
// A Double supplied for a Float32 column (and a Float for a Float64 column) used to
64+
// throw ClassCastException; any Number now narrows through Number#floatValue()/
65+
// doubleValue(), matching how the Int* branches accept any Number.
66+
{"Float32", 1.5d, 1.5},
67+
{"Float32", -2.5d, -2.5},
68+
{"Float64", 1.5f, 1.5},
69+
{"Float64", -2.5f, -2.5},
70+
71+
// Same-type value keeps working unchanged (these already passed before the fix).
72+
{"Float32", 1.5f, 1.5},
73+
{"Float64", 2.5d, 2.5},
74+
75+
// Other Number subtypes are accepted for both float widths.
76+
{"Float32", 3, 3.0},
77+
{"Float32", 7L, 7.0},
78+
{"Float32", (short) 5, 5.0},
79+
{"Float32", (byte) 2, 2.0},
80+
{"Float32", BigInteger.valueOf(9), 9.0},
81+
{"Float32", new BigDecimal("1.5"), 1.5},
82+
{"Float32", new CustomNumber("2.5"), 2.5},
83+
{"Float64", 3, 3.0},
84+
{"Float64", 7L, 7.0},
85+
{"Float64", (short) 5, 5.0},
86+
{"Float64", (byte) 2, 2.0},
87+
{"Float64", BigInteger.valueOf(9), 9.0},
88+
{"Float64", new BigDecimal("1.25"), 1.25},
89+
{"Float64", new CustomNumber("2.5"), 2.5},
90+
91+
// String and Boolean are accepted too, matching the other numeric column branches.
92+
{"Float32", "1.5", 1.5},
93+
{"Float32", true, 1.0},
94+
{"Float32", false, 0.0},
95+
{"Float64", "1.5", 1.5},
96+
{"Float64", true, 1.0},
97+
{"Float64", false, 0.0},
98+
99+
// Out-of-Float-range values (now reachable through the fix) narrow like a primitive
100+
// cast: a Double beyond Float range becomes Float32 infinity rather than throwing.
101+
{"Float32", Double.MAX_VALUE, Double.POSITIVE_INFINITY},
102+
{"Float32", -Double.MAX_VALUE, Double.NEGATIVE_INFINITY},
103+
};
104+
}
105+
106+
@Test
107+
public void testSerializeFloatColumnRejectsUnsupportedValue() {
108+
Assert.assertThrows(IllegalArgumentException.class,
109+
() -> serializeSingleValue("Float32", new Object()));
110+
Assert.assertThrows(IllegalArgumentException.class,
111+
() -> serializeSingleValue("Float64", new Object()));
112+
}
113+
48114
private RowBinaryWithNamesAndTypesFormatReader serializeSingleValue(String type, Object value)
49115
throws IOException {
50116
ByteArrayOutputStream out = new ByteArrayOutputStream();

0 commit comments

Comments
 (0)