@@ -335,6 +335,123 @@ private static Map<String, Object> singleEntryMap(String key, Object value) {
335335 return map ;
336336 }
337337
338+ @ DataProvider (name = "rowBinaryWriterFormats" )
339+ private Object [][] rowBinaryWriterFormats () {
340+ return new Object [][] {
341+ {ClickHouseFormat .RowBinary },
342+ {ClickHouseFormat .RowBinaryWithDefaults },
343+ };
344+ }
345+
346+ // A non-nullable Array column cannot represent a null, so writing a Java null into it must fail
347+ // loudly like every other non-nullable type instead of emitting a stray marker byte on top of the
348+ // array length. That stray byte was read by the server as a phantom extra row (single column) or
349+ // shifted every following column (multi column). 'tail' sits after the array so a stray byte corrupts it.
350+ @ Test (groups = { "integration" }, dataProvider = "rowBinaryWriterFormats" )
351+ public void writeNullIntoNonNullableArrayThrowsTest (ClickHouseFormat format ) throws Exception {
352+ String tableName = "rowBinaryFormatWriterTest_nonNullableArrayNull_" + UUID .randomUUID ().toString ().replace ('-' , '_' );
353+ initTable (tableName ,
354+ "CREATE TABLE \" " + tableName + "\" (id Int32, arr Array(Int32), tail Int32) Engine = MergeTree ORDER BY id" ,
355+ new CommandSettings ());
356+ TableSchema schema = client .getTableSchema (tableName );
357+
358+ Exception thrown = null ;
359+ try (InsertResponse response = client .insert (tableName , out -> {
360+ RowBinaryFormatWriter w = new RowBinaryFormatWriter (out , schema , format );
361+ w .setValue (schema .nameToColumnIndex ("id" ), 1 );
362+ w .setValue (schema .nameToColumnIndex ("arr" ), null );
363+ w .setValue (schema .nameToColumnIndex ("tail" ), 7 );
364+ w .commitRow ();
365+ }, format , settings ).get (EXECUTE_CMD_TIMEOUT , TimeUnit .SECONDS )) {
366+ // The insert must not succeed: a null in a non-nullable Array column is invalid.
367+ } catch (Exception e ) {
368+ thrown = e ;
369+ }
370+
371+ Assert .assertNotNull (thrown , "Expected the insert to fail for a null in a non-nullable Array column using " + format );
372+ boolean clearMessage = false ;
373+ for (Throwable t = thrown ; t != null ; t = t .getCause ()) {
374+ if (t .getMessage () != null && t .getMessage ().contains ("An attempt to write null into not nullable column" )) {
375+ clearMessage = true ;
376+ break ;
377+ }
378+ }
379+ Assert .assertTrue (clearMessage , "Expected a clear non-nullable column error using " + format + ", but got: " + thrown );
380+ }
381+
382+ // Contrast: an empty (and a populated) non-nullable Array must still round-trip, and the fixed-width
383+ // 'tail' column after it keeps its value - proving the array length is still written as a single
384+ // leading byte and that rejecting null did not perturb valid array writes.
385+ @ Test (groups = { "integration" }, dataProvider = "rowBinaryWriterFormats" )
386+ public void writeNonNullableArrayRoundTripsTest (ClickHouseFormat format ) throws Exception {
387+ String tableName = "rowBinaryFormatWriterTest_nonNullableArrayRoundTrip_" + UUID .randomUUID ().toString ().replace ('-' , '_' );
388+ initTable (tableName ,
389+ "CREATE TABLE \" " + tableName + "\" (id Int32, arr Array(Int32), tail Int32) Engine = MergeTree ORDER BY id" ,
390+ new CommandSettings ());
391+ TableSchema schema = client .getTableSchema (tableName );
392+
393+ try (InsertResponse response = client .insert (tableName , out -> {
394+ RowBinaryFormatWriter w = new RowBinaryFormatWriter (out , schema , format );
395+ w .setValue (schema .nameToColumnIndex ("id" ), 1 );
396+ w .setValue (schema .nameToColumnIndex ("arr" ), new ArrayList <Integer >());
397+ w .setValue (schema .nameToColumnIndex ("tail" ), 7 );
398+ w .commitRow ();
399+ w .setValue (schema .nameToColumnIndex ("id" ), 2 );
400+ w .setValue (schema .nameToColumnIndex ("arr" ), Arrays .asList (1 , 2 , 3 ));
401+ w .setValue (schema .nameToColumnIndex ("tail" ), 8 );
402+ w .commitRow ();
403+ }, format , settings ).get (EXECUTE_CMD_TIMEOUT , TimeUnit .SECONDS )) {
404+ // inserted
405+ }
406+
407+ List <GenericRecord > records = client .queryAll (
408+ "SELECT id, toInt32(length(arr)) AS alen, arrayStringConcat(arr, ',') AS acat, tail FROM \" "
409+ + tableName + "\" ORDER BY id" );
410+ Assert .assertEquals (records .size (), 2 );
411+
412+ GenericRecord row1 = records .get (0 );
413+ Assert .assertEquals (row1 .getInteger ("alen" ), 0 );
414+ Assert .assertEquals (row1 .getString ("acat" ), "" );
415+ Assert .assertEquals (row1 .getInteger ("tail" ), 7 );
416+
417+ GenericRecord row2 = records .get (1 );
418+ Assert .assertEquals (row2 .getInteger ("alen" ), 3 );
419+ Assert .assertEquals (row2 .getString ("acat" ), "1,2,3" );
420+ Assert .assertEquals (row2 .getInteger ("tail" ), 8 );
421+ }
422+
423+ // Contrast: with RowBinaryWithDefaults, a null into a non-nullable Array column that HAS a DDL
424+ // default must still fall back to that default (the null-to-default coercion is decided before the
425+ // type dispatch), not throw - proving the fix only rejects null for non-nullable arrays with no default.
426+ @ Test (groups = { "integration" })
427+ public void writeNullIntoDefaultedArrayUsesDefaultTest () throws Exception {
428+ String tableName = "rowBinaryFormatWriterTest_defaultedArrayNull_" + UUID .randomUUID ().toString ().replace ('-' , '_' );
429+ initTable (tableName ,
430+ "CREATE TABLE \" " + tableName + "\" (id Int32, arr Array(Int32) DEFAULT [1, 2], tail Int32) Engine = MergeTree ORDER BY id" ,
431+ new CommandSettings ());
432+ TableSchema schema = client .getTableSchema (tableName );
433+ ClickHouseFormat format = ClickHouseFormat .RowBinaryWithDefaults ;
434+
435+ try (InsertResponse response = client .insert (tableName , out -> {
436+ RowBinaryFormatWriter w = new RowBinaryFormatWriter (out , schema , format );
437+ w .setValue (schema .nameToColumnIndex ("id" ), 1 );
438+ w .setValue (schema .nameToColumnIndex ("arr" ), null );
439+ w .setValue (schema .nameToColumnIndex ("tail" ), 7 );
440+ w .commitRow ();
441+ }, format , settings ).get (EXECUTE_CMD_TIMEOUT , TimeUnit .SECONDS )) {
442+ // inserted; the null arr must fall back to the DDL default
443+ }
444+
445+ List <GenericRecord > records = client .queryAll (
446+ "SELECT toInt32(length(arr)) AS alen, arrayStringConcat(arr, ',') AS acat, tail FROM \" "
447+ + tableName + "\" ORDER BY id" );
448+ Assert .assertEquals (records .size (), 1 );
449+ GenericRecord row = records .get (0 );
450+ Assert .assertEquals (row .getInteger ("alen" ), 2 );
451+ Assert .assertEquals (row .getString ("acat" ), "1,2" );
452+ Assert .assertEquals (row .getInteger ("tail" ), 7 );
453+ }
454+
338455
339456 @ Test (groups = { "integration" })
340457 public void writeNumbersTest () throws Exception {
0 commit comments