This is an automated email from the ASF dual-hosted git repository.
fresh-borzoni pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/fluss-rust.git
The following commit(s) were added to refs/heads/main by this push:
new be158805 [tests] Add integration tests for array data type (#523)
be158805 is described below
commit be1588053ca968cf6e51ea86f0b401ad21f3c7c8
Author: Kaiqi Dong <[email protected]>
AuthorDate: Sun May 10 13:52:32 2026 +0200
[tests] Add integration tests for array data type (#523)
* Add integration tests for array data type
* Address review comments
* Consolidate tests
* Conslidate tests
* resolve conflicts
---
bindings/python/test/test_kv_table.py | 181 +++++++++++-
crates/fluss/tests/integration/kv_table.rs | 204 ++++++++++++-
crates/fluss/tests/integration/log_table.rs | 439 +++++++++++++++++++++++++++-
crates/fluss/tests/integration/utils.rs | 26 +-
4 files changed, 837 insertions(+), 13 deletions(-)
diff --git a/bindings/python/test/test_kv_table.py
b/bindings/python/test/test_kv_table.py
index 39407375..a17b0ad3 100644
--- a/bindings/python/test/test_kv_table.py
+++ b/bindings/python/test/test_kv_table.py
@@ -30,6 +30,17 @@ import pyarrow as pa
import fluss
+async def _upsert_and_wait(writer, row):
+ handle = writer.upsert(row)
+ await handle.wait()
+
+
+def _assert_float_specials(values):
+ assert math.isnan(values[0])
+ assert math.isinf(values[1]) and values[1] > 0
+ assert math.isinf(values[2]) and values[2] < 0
+
+
async def test_upsert_delete_and_lookup(connection, admin):
"""Test upsert, lookup, update, delete, and non-existent key lookup."""
table_path = fluss.TablePath("fluss", "py_test_upsert_and_lookup")
@@ -335,6 +346,167 @@ async def
test_partitioned_table_upsert_and_lookup(connection, admin):
await admin.drop_table(table_path, ignore_if_not_exists=False)
+async def test_upsert_and_lookup_with_array(connection, admin):
+ """Test upsert and lookup with flat, nested, and null-pattern arrays in KV
tables."""
+ table_path = fluss.TablePath("fluss", "py_test_kv_arrays")
+ await admin.drop_table(table_path, ignore_if_not_exists=True)
+
+ schema = fluss.Schema(
+ pa.schema(
+ [
+ pa.field("id", pa.int32()),
+ pa.field("tags", pa.list_(pa.string())),
+ pa.field("scores", pa.list_(pa.int32())),
+ pa.field("matrix", pa.list_(pa.list_(pa.int32()))),
+ ]
+ ),
+ primary_keys=["id"],
+ )
+ table_descriptor = fluss.TableDescriptor(schema)
+ await admin.create_table(table_path, table_descriptor,
ignore_if_exists=False)
+
+ table = await connection.get_table(table_path)
+ upsert_writer = table.new_upsert().create_writer()
+
+ await _upsert_and_wait(
+ upsert_writer,
+ {
+ "id": 1,
+ "tags": ["hello", "world"],
+ "scores": [10, 20, 30],
+ "matrix": [[1, 2], [3, 4]],
+ },
+ )
+ await _upsert_and_wait(
+ upsert_writer,
+ {"id": 2, "tags": [None], "scores": [], "matrix": None},
+ )
+ await _upsert_and_wait(
+ upsert_writer,
+ {"id": 3, "tags": None, "scores": [42], "matrix": [[], [5], [6, 7,
8]]},
+ )
+ await _upsert_and_wait(
+ upsert_writer,
+ {"id": 4, "tags": None, "scores": None, "matrix": [[1, None], None,
[]]},
+ )
+
+ lookuper = table.new_lookup().create_lookuper()
+
+ result1 = await lookuper.lookup({"id": 1})
+ assert result1 is not None
+ assert result1["tags"] == ["hello", "world"]
+ assert result1["scores"] == [10, 20, 30]
+ assert result1["matrix"] == [[1, 2], [3, 4]]
+
+ result2 = await lookuper.lookup({"id": 2})
+ assert result2 is not None
+ assert result2["tags"] == [None]
+ assert result2["scores"] == []
+ assert result2["matrix"] is None
+
+ result3 = await lookuper.lookup({"id": 3})
+ assert result3 is not None
+ assert result3["tags"] is None
+ assert result3["scores"] == [42]
+ assert result3["matrix"] == [[], [5], [6, 7, 8]]
+
+ result4 = await lookuper.lookup({"id": 4})
+ assert result4 is not None
+ assert result4["tags"] is None
+ assert result4["scores"] is None
+ assert result4["matrix"] == [[1, None], None, []]
+
+ await admin.drop_table(table_path, ignore_if_not_exists=False)
+
+
+async def test_upsert_and_lookup_with_array_rich_types(connection, admin):
+ """Test upsert/lookup for arrays with rich element types and encoding edge
cases."""
+ table_path = fluss.TablePath("fluss", "py_test_kv_arrays_rich_types")
+ await admin.drop_table(table_path, ignore_if_not_exists=True)
+
+ schema = fluss.Schema(
+ pa.schema(
+ [
+ pa.field("id", pa.int32()),
+ pa.field("arr_bytes", pa.list_(pa.binary())),
+ pa.field("arr_date", pa.list_(pa.date32())),
+ pa.field("arr_time", pa.list_(pa.time32("ms"))),
+ pa.field("arr_ts_ntz", pa.list_(pa.timestamp("us"))),
+ pa.field("arr_ts_ltz", pa.list_(pa.timestamp("us", tz="UTC"))),
+ pa.field("arr_decimal", pa.list_(pa.decimal128(10, 2))),
+ pa.field("arr_long_str", pa.list_(pa.string())),
+ pa.field("arr_big_decimal", pa.list_(pa.decimal128(22, 5))),
+ pa.field("arr_ts_nano", pa.list_(pa.timestamp("ns"))),
+ pa.field("arr_float", pa.list_(pa.float32())),
+ pa.field("arr_double", pa.list_(pa.float64())),
+ # TODO(fluss-python#524): support PyArrow FixedSizeBinary in
schema
+ # conversion. Then switch to pa.binary(4).
+ pa.field("arr_binary", pa.list_(pa.binary())),
+ ]
+ ),
+ primary_keys=["id"],
+ )
+ table_descriptor = fluss.TableDescriptor(schema)
+ await admin.create_table(table_path, table_descriptor,
ignore_if_exists=False)
+
+ table = await connection.get_table(table_path)
+ upsert_writer = table.new_upsert().create_writer()
+
+ await _upsert_and_wait(
+ upsert_writer,
+ {
+ "id": 1,
+ "arr_bytes": [b"\x10\x20\x30", None],
+ "arr_date": [date(2026, 1, 23), None],
+ "arr_time": [dt_time(10, 13, 47, 123000), None],
+ "arr_ts_ntz": [datetime(2026, 1, 23, 10, 13, 47, 123000)],
+ "arr_ts_ltz": [
+ datetime(2026, 1, 23, 10, 13, 47, 123000, tzinfo=timezone.utc)
+ ],
+ "arr_decimal": [Decimal("123.45"), None],
+ "arr_long_str": [
+ "abcdefgh",
+ "this is a much longer string that definitely exceeds inline",
+ ],
+ "arr_big_decimal": [
+ Decimal("12345678901234567.12345"),
+ Decimal("-99999999999999999.99999"),
+ ],
+ "arr_ts_nano": [datetime(2026, 1, 23, 10, 13, 47, 123456)],
+ "arr_float": [float("nan"), float("inf"), float("-inf")],
+ "arr_double": [float("nan"), float("inf"), float("-inf")],
+ "arr_binary": [b"\xde\xad\xbe\xef", b"\x00\x01\x02\x03"],
+ },
+ )
+
+ lookuper = table.new_lookup().create_lookuper()
+ result = await lookuper.lookup({"id": 1})
+ assert result is not None
+
+ assert result["arr_bytes"] == [b"\x10\x20\x30", None]
+ assert result["arr_date"] == [date(2026, 1, 23), None]
+ assert result["arr_time"] == [dt_time(10, 13, 47, 123000), None]
+ assert result["arr_ts_ntz"] == [datetime(2026, 1, 23, 10, 13, 47, 123000)]
+ assert result["arr_ts_ltz"] == [
+ datetime(2026, 1, 23, 10, 13, 47, 123000, tzinfo=timezone.utc)
+ ]
+ assert result["arr_decimal"] == [Decimal("123.45"), None]
+ assert result["arr_long_str"] == [
+ "abcdefgh",
+ "this is a much longer string that definitely exceeds inline",
+ ]
+ assert result["arr_big_decimal"] == [
+ Decimal("12345678901234567.12345"),
+ Decimal("-99999999999999999.99999"),
+ ]
+ assert result["arr_ts_nano"] == [datetime(2026, 1, 23, 10, 13, 47, 123456)]
+ _assert_float_specials(result["arr_float"])
+ _assert_float_specials(result["arr_double"])
+ assert result["arr_binary"] == [b"\xde\xad\xbe\xef", b"\x00\x01\x02\x03"]
+
+ await admin.drop_table(table_path, ignore_if_not_exists=False)
+
+
async def test_all_supported_datatypes(connection, admin):
"""Test upsert/lookup for all supported data types, including nulls."""
table_path = fluss.TablePath("fluss", "py_test_kv_all_datatypes")
@@ -358,6 +530,7 @@ async def test_all_supported_datatypes(connection, admin):
pa.field("col_timestamp_ntz", pa.timestamp("us")),
pa.field("col_timestamp_ltz", pa.timestamp("us", tz="UTC")),
pa.field("col_bytes", pa.binary()),
+ pa.field("col_array", pa.list_(pa.string())),
pa.field("col_binary", pa.binary(16)),
]
),
@@ -386,11 +559,11 @@ async def test_all_supported_datatypes(connection, admin):
"col_timestamp_ntz": datetime(2026, 1, 23, 10, 13, 47, 123000),
"col_timestamp_ltz": datetime(2026, 1, 23, 10, 13, 47, 123000),
"col_bytes": b"binary data",
+ "col_array": ["fluss", "python"],
"col_binary": b"binary_data_0123",
}
- handle = upsert_writer.upsert(row_data)
- await handle.wait()
+ await _upsert_and_wait(upsert_writer, row_data)
lookuper = table.new_lookup().create_lookuper()
result = await lookuper.lookup({"pk_int": 1})
@@ -413,6 +586,7 @@ async def test_all_supported_datatypes(connection, admin):
2026, 1, 23, 10, 13, 47, 123000, tzinfo=timezone.utc
)
assert result["col_bytes"] == b"binary data"
+ assert result["col_array"] == ["fluss", "python"]
assert result["col_binary"] == b"binary_data_0123"
# Test with null values for all nullable columns
@@ -420,8 +594,7 @@ async def test_all_supported_datatypes(connection, admin):
for col in row_data:
if col != "pk_int":
null_row[col] = None
- handle = upsert_writer.upsert(null_row)
- await handle.wait()
+ await _upsert_and_wait(upsert_writer, null_row)
result = await lookuper.lookup({"pk_int": 2})
assert result is not None, "Row with nulls should exist"
diff --git a/crates/fluss/tests/integration/kv_table.rs
b/crates/fluss/tests/integration/kv_table.rs
index 1979b973..8787e881 100644
--- a/crates/fluss/tests/integration/kv_table.rs
+++ b/crates/fluss/tests/integration/kv_table.rs
@@ -18,12 +18,19 @@
#[cfg(test)]
mod kv_table_test {
- use crate::integration::utils::{create_partitions, create_table,
get_shared_cluster};
+ use crate::integration::utils::{
+ create_partitions, create_table, get_shared_cluster, make_int_array,
make_string_array,
+ };
use fluss::metadata::{DataTypes, Schema, TableDescriptor, TablePath};
- use fluss::row::{GenericRow, InternalRow};
+ use fluss::row::binary_array::FlussArrayWriter;
+ use fluss::row::{FlussArray, GenericRow, InternalRow};
fn make_key(id: i32) -> GenericRow<'static> {
- let mut row = GenericRow::new(3);
+ make_key_with_field_count(id, 3)
+ }
+
+ fn make_key_with_field_count(id: i32, field_count: usize) ->
GenericRow<'static> {
+ let mut row = GenericRow::new(field_count);
row.set_field(0, id);
row
}
@@ -606,6 +613,7 @@ mod kv_table_test {
// Binary types
.column("col_bytes", DataTypes::bytes())
.column("col_binary", DataTypes::binary(20))
+ .column("col_array", DataTypes::array(DataTypes::string()))
.primary_key(vec!["pk_int"])
.build()
.expect("Failed to build schema"),
@@ -644,8 +652,10 @@ mod kv_table_test {
let col_bytes: &[u8] = b"binary data";
let col_binary: &[u8] = b"fixed binary data!!!";
+ let col_array = make_string_array(&[Some("fluss"), Some("rust")]);
+
// Upsert a row with all datatypes
- let mut row = GenericRow::new(17);
+ let mut row = GenericRow::new(18);
row.set_field(0, pk_int);
row.set_field(1, col_boolean);
row.set_field(2, col_tinyint);
@@ -663,6 +673,7 @@ mod kv_table_test {
row.set_field(14, col_timestamp_ltz);
row.set_field(15, col_bytes);
row.set_field(16, col_binary);
+ row.set_field(17, col_array);
upsert_writer
.upsert(&row)
@@ -677,7 +688,7 @@ mod kv_table_test {
.create_lookuper()
.expect("Failed to create lookuper");
- let mut key = GenericRow::new(17);
+ let mut key = GenericRow::new(18);
key.set_field(0, pk_int);
let result = lookuper.lookup(&key).await.expect("Failed to lookup");
@@ -772,10 +783,14 @@ mod kv_table_test {
col_binary,
"col_binary mismatch"
);
+ let arr = found_row.get_array(17).unwrap();
+ assert_eq!(arr.size(), 2, "col_array size mismatch");
+ assert_eq!(arr.get_string(0).unwrap(), "fluss", "col_array[0]
mismatch");
+ assert_eq!(arr.get_string(1).unwrap(), "rust", "col_array[1]
mismatch");
// Test with null values for nullable columns
let pk_int_2 = 2i32;
- let mut row_with_nulls = GenericRow::new(17);
+ let mut row_with_nulls = GenericRow::new(18);
row_with_nulls.set_field(0, pk_int_2);
row_with_nulls.set_field(1, Datum::Null); // col_boolean
row_with_nulls.set_field(2, Datum::Null); // col_tinyint
@@ -793,6 +808,7 @@ mod kv_table_test {
row_with_nulls.set_field(14, Datum::Null); // col_timestamp_ltz
row_with_nulls.set_field(15, Datum::Null); // col_bytes
row_with_nulls.set_field(16, Datum::Null); // col_binary
+ row_with_nulls.set_field(17, Datum::Null); // col_array
upsert_writer
.upsert(&row_with_nulls)
@@ -801,7 +817,7 @@ mod kv_table_test {
.expect("Failed to wait for upsert acknowledgment");
// Lookup row with nulls
- let mut key2 = GenericRow::new(17);
+ let mut key2 = GenericRow::new(18);
key2.set_field(0, pk_int_2);
let result = lookuper.lookup(&key2).await.expect("Failed to lookup");
@@ -880,6 +896,180 @@ mod kv_table_test {
found_row_nulls.is_null_at(16).unwrap(),
"col_binary should be null"
);
+ assert!(
+ found_row_nulls.is_null_at(17).unwrap(),
+ "col_array should be null"
+ );
+
+ admin
+ .drop_table(&table_path, false)
+ .await
+ .expect("Failed to drop table");
+ }
+
+ #[tokio::test]
+ async fn upsert_and_lookup_with_array() {
+ use fluss::row::Datum;
+
+ let cluster = get_shared_cluster();
+ let connection = cluster.get_fluss_connection().await;
+ let admin = connection.get_admin().expect("Failed to get admin");
+
+ let table_path = TablePath::new("fluss", "test_kv_arrays");
+ let inner_array_type = DataTypes::array(DataTypes::int());
+
+ let table_descriptor = TableDescriptor::builder()
+ .schema(
+ Schema::builder()
+ .column("id", DataTypes::int())
+ .column("tags", DataTypes::array(DataTypes::string()))
+ .column("scores", DataTypes::array(DataTypes::int()))
+ .column("matrix",
DataTypes::array(inner_array_type.clone()))
+ .primary_key(vec!["id"])
+ .build()
+ .expect("Failed to build schema"),
+ )
+ .build()
+ .expect("Failed to build table descriptor");
+
+ create_table(&admin, &table_path, &table_descriptor).await;
+
+ let table = connection
+ .get_table(&table_path)
+ .await
+ .expect("Failed to get table");
+
+ let upsert = table.new_upsert().expect("Failed to create upsert");
+ let upsert_writer = upsert.create_writer().expect("Failed to create
writer");
+
+ // Row 1: id=1, tags=["hello", "world"], scores=[10, 20, 30],
matrix=[[1,2],[3,4]]
+ let mut row1 = GenericRow::new(4);
+ row1.set_field(0, 1_i32);
+ row1.set_field(1, make_string_array(&[Some("hello"), Some("world")]));
+ row1.set_field(2, make_int_array(&[Some(10), Some(20), Some(30)]));
+ let m1 = {
+ let mut w = FlussArrayWriter::new(2, &inner_array_type);
+ w.write_array(0, &make_int_array(&[Some(1), Some(2)]));
+ w.write_array(1, &make_int_array(&[Some(3), Some(4)]));
+ w.complete().expect("matrix1")
+ };
+ row1.set_field(3, m1);
+
+ upsert_writer
+ .upsert(&row1)
+ .expect("upsert row1")
+ .await
+ .expect("ack row1");
+
+ // Row 2: id=2, tags=[null element], scores=[] (empty), matrix=null
+ let mut row2 = GenericRow::new(4);
+ row2.set_field(0, 2_i32);
+ row2.set_field(1, make_string_array(&[None]));
+ row2.set_field(2, make_int_array(&[]));
+ row2.set_field(3, Datum::Null);
+
+ upsert_writer
+ .upsert(&row2)
+ .expect("upsert row2")
+ .await
+ .expect("ack row2");
+
+ // Row 3: id=3, tags=null, scores=[42], matrix=[[5], null, []]
+ let mut row3 = GenericRow::new(4);
+ row3.set_field(0, 3_i32);
+ row3.set_field(1, Datum::Null);
+ row3.set_field(2, make_int_array(&[Some(42)]));
+ let m3 = {
+ let mut w = FlussArrayWriter::new(3, &inner_array_type);
+ w.write_array(0, &make_int_array(&[Some(5)]));
+ w.set_null_at(1);
+ w.write_array(2, &make_int_array(&[]));
+ w.complete().expect("matrix3")
+ };
+ row3.set_field(3, m3);
+
+ upsert_writer
+ .upsert(&row3)
+ .expect("upsert row3")
+ .await
+ .expect("ack row3");
+
+ // Lookup and verify
+ let mut lookuper = table
+ .new_lookup()
+ .expect("Failed to create lookup")
+ .create_lookuper()
+ .expect("Failed to create lookuper");
+
+ // Verify row 1: populated flat arrays + nested array
+ let result1 = lookuper
+ .lookup(&make_key_with_field_count(1, 4))
+ .await
+ .expect("lookup row1");
+ let r1 = result1
+ .get_single_row()
+ .expect("get row1")
+ .expect("row1 should exist");
+ assert_eq!(r1.get_int(0).unwrap(), 1);
+ let tags_r1 = r1.get_array(1).unwrap();
+ assert_eq!(tags_r1.size(), 2);
+ assert_eq!(tags_r1.get_string(0).unwrap(), "hello");
+ assert_eq!(tags_r1.get_string(1).unwrap(), "world");
+ let scores_r1 = r1.get_array(2).unwrap();
+ assert_eq!(scores_r1.size(), 3);
+ assert_eq!(scores_r1.get_int(0).unwrap(), 10);
+ assert_eq!(scores_r1.get_int(1).unwrap(), 20);
+ assert_eq!(scores_r1.get_int(2).unwrap(), 30);
+ let matrix_r1: FlussArray = r1.get_array(3).unwrap();
+ assert_eq!(matrix_r1.size(), 2);
+ let mr1_0 = matrix_r1.get_array(0).unwrap();
+ assert_eq!(mr1_0.size(), 2);
+ assert_eq!(mr1_0.get_int(0).unwrap(), 1);
+ assert_eq!(mr1_0.get_int(1).unwrap(), 2);
+ let mr1_1 = matrix_r1.get_array(1).unwrap();
+ assert_eq!(mr1_1.size(), 2);
+ assert_eq!(mr1_1.get_int(0).unwrap(), 3);
+ assert_eq!(mr1_1.get_int(1).unwrap(), 4);
+
+ // Verify row 2: null element in array, empty array, null nested column
+ let result2 = lookuper
+ .lookup(&make_key_with_field_count(2, 4))
+ .await
+ .expect("lookup row2");
+ let r2 = result2
+ .get_single_row()
+ .expect("get row2")
+ .expect("row2 should exist");
+ assert_eq!(r2.get_int(0).unwrap(), 2);
+ let tags_r2 = r2.get_array(1).unwrap();
+ assert_eq!(tags_r2.size(), 1);
+ assert!(tags_r2.is_null_at(0));
+ let scores_r2 = r2.get_array(2).unwrap();
+ assert_eq!(scores_r2.size(), 0);
+ assert!(r2.is_null_at(3).unwrap());
+
+ // Verify row 3: null flat column, nested array with mixed inner
(value, null, empty)
+ let result3 = lookuper
+ .lookup(&make_key_with_field_count(3, 4))
+ .await
+ .expect("lookup row3");
+ let r3 = result3
+ .get_single_row()
+ .expect("get row3")
+ .expect("row3 should exist");
+ assert_eq!(r3.get_int(0).unwrap(), 3);
+ assert!(r3.is_null_at(1).unwrap());
+ let scores_r3 = r3.get_array(2).unwrap();
+ assert_eq!(scores_r3.size(), 1);
+ assert_eq!(scores_r3.get_int(0).unwrap(), 42);
+ let matrix_r3 = r3.get_array(3).unwrap();
+ assert_eq!(matrix_r3.size(), 3);
+ let mr3_0 = matrix_r3.get_array(0).unwrap();
+ assert_eq!(mr3_0.size(), 1);
+ assert_eq!(mr3_0.get_int(0).unwrap(), 5);
+ assert!(matrix_r3.is_null_at(1));
+ let mr3_2 = matrix_r3.get_array(2).unwrap();
+ assert_eq!(mr3_2.size(), 0);
admin
.drop_table(&table_path, false)
diff --git a/crates/fluss/tests/integration/log_table.rs
b/crates/fluss/tests/integration/log_table.rs
index d10834e8..1a6b514b 100644
--- a/crates/fluss/tests/integration/log_table.rs
+++ b/crates/fluss/tests/integration/log_table.rs
@@ -18,7 +18,9 @@
#[cfg(test)]
mod table_test {
- use crate::integration::utils::{create_partitions, create_table,
get_shared_cluster};
+ use crate::integration::utils::{
+ create_partitions, create_table, get_shared_cluster, make_int_array,
make_string_array,
+ };
use arrow::array::record_batch;
use fluss::client::{EARLIEST_OFFSET, FlussTable, TableScan};
use fluss::metadata::{DataTypes, Schema, TableDescriptor, TablePath};
@@ -658,6 +660,7 @@ mod table_test {
"col_timestamp_ltz_ns_neg",
DataTypes::timestamp_ltz_with_precision(9),
)
+ .column("col_array", DataTypes::array(DataTypes::string()))
.build()
.expect("Failed to build schema"),
)
@@ -719,6 +722,8 @@ mod table_test {
let col_timestamp_ltz_ns_neg =
TimestampLtz::from_millis_nanos(-301234154877, 999_999).unwrap();
+ let col_array = make_string_array(&[Some("fluss"), Some("rust")]);
+
// Append a row with all datatypes
let mut row = GenericRow::new(field_count);
row.set_field(0, col_tinyint);
@@ -750,6 +755,7 @@ mod table_test {
row.set_field(26, col_timestamp_ns_neg);
row.set_field(27, col_timestamp_ltz_us_neg);
row.set_field(28, col_timestamp_ltz_ns_neg);
+ row.set_field(29, col_array);
append_writer
.append(&row)
@@ -994,6 +1000,11 @@ mod table_test {
"col_timestamp_ltz_ns_neg nanos mismatch"
);
+ let arr = found_row.get_array(29).unwrap();
+ assert_eq!(arr.size(), 2, "col_array size mismatch");
+ assert_eq!(arr.get_string(0).unwrap(), "fluss", "col_array[0]
mismatch");
+ assert_eq!(arr.get_string(1).unwrap(), "rust", "col_array[1]
mismatch");
+
// Verify row with all nulls (record index 1)
let found_row_nulls = records[1].row();
for i in 0..field_count {
@@ -1373,4 +1384,430 @@ mod table_test {
.await
.expect("Failed to drop table");
}
+
+ #[tokio::test]
+ async fn append_and_scan_with_array() {
+ use fluss::row::binary_array::FlussArrayWriter;
+ use fluss::row::{Datum, FlussArray, GenericRow};
+
+ let cluster = get_shared_cluster();
+ let connection = cluster.get_fluss_connection().await;
+ let admin = connection.get_admin().expect("Failed to get admin");
+
+ let table_path = TablePath::new("fluss", "test_log_arrays");
+ let inner_array_type = DataTypes::array(DataTypes::int());
+
+ let schema = Schema::builder()
+ .column("id", DataTypes::int())
+ .column("tags", DataTypes::array(DataTypes::string()))
+ .column("scores", DataTypes::array(DataTypes::int()))
+ .column("matrix", DataTypes::array(inner_array_type.clone()))
+ .build()
+ .expect("Failed to build schema");
+
+ let table_descriptor = TableDescriptor::builder()
+ .schema(schema)
+ .build()
+ .expect("Failed to build table descriptor");
+
+ create_table(&admin, &table_path, &table_descriptor).await;
+
+ let table = connection
+ .get_table(&table_path)
+ .await
+ .expect("Failed to get table");
+
+ let append_writer = table
+ .new_append()
+ .expect("Failed to create append")
+ .create_writer()
+ .expect("Failed to create writer");
+
+ // Row 1: id=1, tags=["hello", "world"], scores=[10, 20, 30],
matrix=[[1,2],[3,4]]
+ let mut row1 = GenericRow::new(4);
+ row1.set_field(0, 1_i32);
+ row1.set_field(1, make_string_array(&[Some("hello"), Some("world")]));
+ row1.set_field(2, make_int_array(&[Some(10), Some(20), Some(30)]));
+ let m1 = {
+ let mut w = FlussArrayWriter::new(2, &inner_array_type);
+ w.write_array(0, &make_int_array(&[Some(1), Some(2)]));
+ w.write_array(1, &make_int_array(&[Some(3), Some(4)]));
+ w.complete().expect("matrix1")
+ };
+ row1.set_field(3, m1);
+
+ // Row 2: id=2, tags=[null], scores=[], matrix=[[5], null, []]
+ let mut row2 = GenericRow::new(4);
+ row2.set_field(0, 2_i32);
+ row2.set_field(1, make_string_array(&[None]));
+ row2.set_field(2, make_int_array(&[]));
+ let m2 = {
+ let mut w = FlussArrayWriter::new(3, &inner_array_type);
+ w.write_array(0, &make_int_array(&[Some(5)]));
+ w.set_null_at(1);
+ w.write_array(2, &make_int_array(&[]));
+ w.complete().expect("matrix2")
+ };
+ row2.set_field(3, m2);
+
+ // Row 3: id=3, tags=null, scores=[42], matrix=null
+ let mut row3 = GenericRow::new(4);
+ row3.set_field(0, 3_i32);
+ row3.set_field(1, Datum::Null);
+ row3.set_field(2, make_int_array(&[Some(42)]));
+ row3.set_field(3, Datum::Null);
+
+ append_writer.append(&row1).expect("append row1");
+ append_writer.append(&row2).expect("append row2");
+ append_writer.append(&row3).expect("append row3");
+ append_writer.flush().await.expect("Failed to flush");
+
+ let records = scan_table(&table, |scan| scan).await;
+ assert_eq!(records.len(), 3, "expected three log records");
+
+ // Verify row 1: populated flat arrays + nested array
+ let r0 = records[0].row();
+ assert_eq!(r0.get_int(0).unwrap(), 1);
+ let tags_r0 = r0.get_array(1).unwrap();
+ assert_eq!(tags_r0.size(), 2);
+ assert_eq!(tags_r0.get_string(0).unwrap(), "hello");
+ assert_eq!(tags_r0.get_string(1).unwrap(), "world");
+ let scores_r0 = r0.get_array(2).unwrap();
+ assert_eq!(scores_r0.size(), 3);
+ assert_eq!(scores_r0.get_int(0).unwrap(), 10);
+ assert_eq!(scores_r0.get_int(1).unwrap(), 20);
+ assert_eq!(scores_r0.get_int(2).unwrap(), 30);
+ let matrix_r0: FlussArray = r0.get_array(3).unwrap();
+ assert_eq!(matrix_r0.size(), 2);
+ let mr0_0 = matrix_r0.get_array(0).unwrap();
+ assert_eq!(mr0_0.size(), 2);
+ assert_eq!(mr0_0.get_int(0).unwrap(), 1);
+ assert_eq!(mr0_0.get_int(1).unwrap(), 2);
+ let mr0_1 = matrix_r0.get_array(1).unwrap();
+ assert_eq!(mr0_1.size(), 2);
+ assert_eq!(mr0_1.get_int(0).unwrap(), 3);
+ assert_eq!(mr0_1.get_int(1).unwrap(), 4);
+
+ // Verify row 2: null element in array, empty array, nested with mixed
inner
+ let r1 = records[1].row();
+ assert_eq!(r1.get_int(0).unwrap(), 2);
+ let tags_r1 = r1.get_array(1).unwrap();
+ assert_eq!(tags_r1.size(), 1);
+ assert!(tags_r1.is_null_at(0));
+ let scores_r1 = r1.get_array(2).unwrap();
+ assert_eq!(scores_r1.size(), 0);
+ let matrix_r1 = r1.get_array(3).unwrap();
+ assert_eq!(matrix_r1.size(), 3);
+ let mr1_0 = matrix_r1.get_array(0).unwrap();
+ assert_eq!(mr1_0.size(), 1);
+ assert_eq!(mr1_0.get_int(0).unwrap(), 5);
+ assert!(matrix_r1.is_null_at(1));
+ let mr1_2 = matrix_r1.get_array(2).unwrap();
+ assert_eq!(mr1_2.size(), 0);
+
+ // Verify row 3: null flat column, null nested column
+ let r2 = records[2].row();
+ assert_eq!(r2.get_int(0).unwrap(), 3);
+ assert!(r2.is_null_at(1).unwrap());
+ let scores_r2 = r2.get_array(2).unwrap();
+ assert_eq!(scores_r2.size(), 1);
+ assert_eq!(scores_r2.get_int(0).unwrap(), 42);
+ assert!(r2.is_null_at(3).unwrap());
+
+ admin
+ .drop_table(&table_path, false)
+ .await
+ .expect("Failed to drop table");
+ }
+
+ #[tokio::test]
+ async fn append_and_scan_with_array_rich_types() {
+ use fluss::row::binary_array::FlussArrayWriter;
+ use fluss::row::{Date, Decimal, FlussArray, GenericRow, Time,
TimestampNtz};
+
+ fn assert_f32_special(actual: f32, expected: f32) {
+ if expected.is_nan() {
+ assert!(actual.is_nan(), "expected NaN");
+ } else if expected.is_infinite() {
+ assert!(actual.is_infinite());
+ assert_eq!(actual.signum(), expected.signum());
+ } else {
+ assert!((actual - expected).abs() < f32::EPSILON);
+ }
+ }
+
+ fn assert_f64_special(actual: f64, expected: f64) {
+ if expected.is_nan() {
+ assert!(actual.is_nan(), "expected NaN");
+ } else if expected.is_infinite() {
+ assert!(actual.is_infinite());
+ assert_eq!(actual.signum(), expected.signum());
+ } else {
+ assert!((actual - expected).abs() < f64::EPSILON);
+ }
+ }
+
+ let cluster = get_shared_cluster();
+ let connection = cluster.get_fluss_connection().await;
+ let admin = connection.get_admin().expect("Failed to get admin");
+
+ let table_path = TablePath::new("fluss", "test_log_arrays_rich_types");
+
+ // Compact types: DECIMAL(10,2) precision<=18, TIMESTAMP(6)
precision<=3 for millis
+ let dec_compact = Decimal::from_unscaled_long(12345, 10, 2).unwrap();
+ let ts_compact = TimestampNtz::from_millis_nanos(1769163227123,
456000).unwrap();
+
+ // Non-compact types: DECIMAL(22,5) precision>18, TIMESTAMP(9)
precision>3
+ let dec_big = Decimal::from_unscaled_bytes(&[66, 237, 18, 59, 11, 216,
31, 4, 244], 22, 5)
+ .expect("big decimal");
+ let ts_nano = TimestampNtz::from_millis_nanos(1769163227123,
999_999).unwrap();
+
+ let d = Date::new(20476);
+ let t = Time::new(36827123);
+ let elem_bytes = &[0_u8, 1, 2, 255];
+ let fixed_a: Vec<u8> = vec![0xDE, 0xAD, 0xBE, 0xEF];
+ let fixed_b: Vec<u8> = vec![0x01, 0x02, 0x03, 0x04];
+
+ let schema = Schema::builder()
+ .column("id", DataTypes::int())
+ .column("arr_bytes", DataTypes::array(DataTypes::bytes()))
+ .column("arr_date", DataTypes::array(DataTypes::date()))
+ .column(
+ "arr_time",
+ DataTypes::array(DataTypes::time_with_precision(3)),
+ )
+ .column(
+ "arr_ts_compact",
+ DataTypes::array(DataTypes::timestamp_with_precision(6)),
+ )
+ .column(
+ "arr_ts_nano",
+ DataTypes::array(DataTypes::timestamp_with_precision(9)),
+ )
+ .column(
+ "arr_decimal_compact",
+ DataTypes::array(DataTypes::decimal(10, 2)),
+ )
+ .column(
+ "arr_decimal_big",
+ DataTypes::array(DataTypes::decimal(22, 5)),
+ )
+ .column("arr_long_str", DataTypes::array(DataTypes::string()))
+ .column("arr_float", DataTypes::array(DataTypes::float()))
+ .column("arr_double", DataTypes::array(DataTypes::double()))
+ .column("arr_binary", DataTypes::array(DataTypes::binary(4)))
+ .build()
+ .expect("Failed to build schema");
+
+ let table_descriptor = TableDescriptor::builder()
+ .schema(schema)
+ .build()
+ .expect("Failed to build table descriptor");
+
+ create_table(&admin, &table_path, &table_descriptor).await;
+
+ let table = connection
+ .get_table(&table_path)
+ .await
+ .expect("Failed to get table");
+
+ let append_writer = table
+ .new_append()
+ .expect("Failed to create append")
+ .create_writer()
+ .expect("Failed to create writer");
+
+ let mut row = GenericRow::new(12);
+ row.set_field(0, 1_i32);
+
+ // col 1: arr_bytes — binary with null element
+ let arr_bytes = {
+ let mut w = FlussArrayWriter::new(2, &DataTypes::bytes());
+ w.write_binary_bytes(0, elem_bytes);
+ w.set_null_at(1);
+ w.complete().expect("arr_bytes")
+ };
+ row.set_field(1, arr_bytes);
+
+ // col 2: arr_date
+ let arr_date = {
+ let mut w = FlussArrayWriter::new(2, &DataTypes::date());
+ w.write_date(0, d);
+ w.set_null_at(1);
+ w.complete().expect("arr_date")
+ };
+ row.set_field(2, arr_date);
+
+ // col 3: arr_time
+ let arr_time = {
+ let mut w = FlussArrayWriter::new(2,
&DataTypes::time_with_precision(3));
+ w.write_time(0, t);
+ w.set_null_at(1);
+ w.complete().expect("arr_time")
+ };
+ row.set_field(3, arr_time);
+
+ // col 4: arr_ts_compact — compact timestamp (precision 6,
millis+nanos)
+ let arr_ts_compact = {
+ let mut w = FlussArrayWriter::new(2,
&DataTypes::timestamp_with_precision(6));
+ w.write_timestamp_ntz(0, &ts_compact, 6);
+ w.set_null_at(1);
+ w.complete().expect("arr_ts_compact")
+ };
+ row.set_field(4, arr_ts_compact);
+
+ // col 5: arr_ts_nano — non-compact timestamp (precision 9)
+ let arr_ts_nano = {
+ let mut w = FlussArrayWriter::new(1,
&DataTypes::timestamp_with_precision(9));
+ w.write_timestamp_ntz(0, &ts_nano, 9);
+ w.complete().expect("arr_ts_nano")
+ };
+ row.set_field(5, arr_ts_nano);
+
+ // col 6: arr_decimal_compact — compact decimal (precision 10)
+ let arr_decimal_compact = {
+ let mut w = FlussArrayWriter::new(2, &DataTypes::decimal(10, 2));
+ w.write_decimal(0, &dec_compact, 10);
+ w.set_null_at(1);
+ w.complete().expect("arr_decimal_compact")
+ };
+ row.set_field(6, arr_decimal_compact);
+
+ // col 7: arr_decimal_big — non-compact decimal (precision 22)
+ let arr_decimal_big = {
+ let mut w = FlussArrayWriter::new(1, &DataTypes::decimal(22, 5));
+ w.write_decimal(0, &dec_big, 22);
+ w.complete().expect("arr_decimal_big")
+ };
+ row.set_field(7, arr_decimal_big);
+
+ // col 8: arr_long_str — heap-backed strings (>= 8 bytes)
+ let arr_long_str = {
+ let mut w = FlussArrayWriter::new(2, &DataTypes::string());
+ w.write_string(0, "abcdefghi");
+ w.write_string(1, "longstring_here");
+ w.complete().expect("arr_long_str")
+ };
+ row.set_field(8, arr_long_str);
+
+ // col 9: arr_float — IEEE 754 specials
+ let arr_float = {
+ let mut w = FlussArrayWriter::new(3, &DataTypes::float());
+ w.write_float(0, f32::NAN);
+ w.write_float(1, f32::INFINITY);
+ w.write_float(2, f32::NEG_INFINITY);
+ w.complete().expect("arr_float")
+ };
+ row.set_field(9, arr_float);
+
+ // col 10: arr_double — IEEE 754 specials
+ let arr_double = {
+ let mut w = FlussArrayWriter::new(3, &DataTypes::double());
+ w.write_double(0, f64::NAN);
+ w.write_double(1, f64::INFINITY);
+ w.write_double(2, f64::NEG_INFINITY);
+ w.complete().expect("arr_double")
+ };
+ row.set_field(10, arr_double);
+
+ // col 11: arr_binary — fixed-size binary(4)
+ let arr_binary = {
+ let mut w = FlussArrayWriter::new(2, &DataTypes::binary(4));
+ w.write_binary_bytes(0, &fixed_a);
+ w.write_binary_bytes(1, &fixed_b);
+ w.complete().expect("arr_binary")
+ };
+ row.set_field(11, arr_binary);
+
+ append_writer.append(&row).expect("append");
+ append_writer.flush().await.expect("Failed to flush");
+
+ let records = scan_table(&table, |scan| scan).await;
+ assert_eq!(records.len(), 1);
+ let r = records[0].row();
+
+ // Verify arr_bytes
+ let ab = r.get_array(1).unwrap();
+ assert_eq!(ab.size(), 2);
+ assert_eq!(ab.get_binary(0).unwrap(), elem_bytes);
+ assert!(ab.is_null_at(1));
+
+ // Verify arr_date
+ let ad = r.get_array(2).unwrap();
+ assert_eq!(ad.size(), 2);
+ assert_eq!(ad.get_date(0).unwrap().get_inner(), d.get_inner());
+ assert!(ad.is_null_at(1));
+
+ // Verify arr_time
+ let at = r.get_array(3).unwrap();
+ assert_eq!(at.size(), 2);
+ assert_eq!(at.get_time(0).unwrap().get_inner(), t.get_inner());
+ assert!(at.is_null_at(1));
+
+ // Verify arr_ts_compact
+ let ats = r.get_array(4).unwrap();
+ assert_eq!(ats.size(), 2);
+ let read_ts_compact = ats.get_timestamp_ntz(0, 6).unwrap();
+ assert_eq!(
+ read_ts_compact.get_millisecond(),
+ ts_compact.get_millisecond()
+ );
+ assert_eq!(
+ read_ts_compact.get_nano_of_millisecond(),
+ ts_compact.get_nano_of_millisecond()
+ );
+ assert!(ats.is_null_at(1));
+
+ // Verify arr_ts_nano
+ let ats_nano = r.get_array(5).unwrap();
+ assert_eq!(ats_nano.size(), 1);
+ let read_ts_nano = ats_nano.get_timestamp_ntz(0, 9).unwrap();
+ assert_eq!(read_ts_nano.get_millisecond(), ts_nano.get_millisecond());
+ assert_eq!(
+ read_ts_nano.get_nano_of_millisecond(),
+ ts_nano.get_nano_of_millisecond()
+ );
+
+ // Verify arr_decimal_compact
+ let adc = r.get_array(6).unwrap();
+ assert_eq!(adc.size(), 2);
+ assert_eq!(adc.get_decimal(0, 10, 2).unwrap(), dec_compact);
+ assert!(adc.is_null_at(1));
+
+ // Verify arr_decimal_big
+ let adb = r.get_array(7).unwrap();
+ assert_eq!(adb.size(), 1);
+ assert_eq!(adb.get_decimal(0, 22, 5).unwrap(), dec_big);
+
+ // Verify arr_long_str
+ let als = r.get_array(8).unwrap();
+ assert_eq!(als.size(), 2);
+ assert_eq!(als.get_string(0).unwrap(), "abcdefghi");
+ assert_eq!(als.get_string(1).unwrap(), "longstring_here");
+
+ // Verify arr_float — IEEE 754 specials
+ let af = r.get_array(9).unwrap();
+ assert_eq!(af.size(), 3);
+ assert_f32_special(af.get_float(0).unwrap(), f32::NAN);
+ assert_f32_special(af.get_float(1).unwrap(), f32::INFINITY);
+ assert_f32_special(af.get_float(2).unwrap(), f32::NEG_INFINITY);
+
+ // Verify arr_double — IEEE 754 specials
+ let adbl = r.get_array(10).unwrap();
+ assert_eq!(adbl.size(), 3);
+ assert_f64_special(adbl.get_double(0).unwrap(), f64::NAN);
+ assert_f64_special(adbl.get_double(1).unwrap(), f64::INFINITY);
+ assert_f64_special(adbl.get_double(2).unwrap(), f64::NEG_INFINITY);
+
+ // Verify arr_binary — fixed-size binary(4)
+ let fb: FlussArray = r.get_array(11).unwrap();
+ assert_eq!(fb.size(), 2);
+ assert_eq!(fb.get_binary(0).unwrap(), fixed_a.as_slice());
+ assert_eq!(fb.get_binary(1).unwrap(), fixed_b.as_slice());
+
+ admin
+ .drop_table(&table_path, false)
+ .await
+ .expect("Failed to drop table");
+ }
}
diff --git a/crates/fluss/tests/integration/utils.rs
b/crates/fluss/tests/integration/utils.rs
index dc2876f8..81a7c0b1 100644
--- a/crates/fluss/tests/integration/utils.rs
+++ b/crates/fluss/tests/integration/utils.rs
@@ -17,7 +17,9 @@
*/
use crate::integration::fluss_cluster::{FlussTestingCluster,
FlussTestingClusterBuilder};
use fluss::client::FlussAdmin;
-use fluss::metadata::{PartitionSpec, TableDescriptor, TablePath};
+use fluss::metadata::{DataTypes, PartitionSpec, TableDescriptor, TablePath};
+use fluss::row::FlussArray;
+use fluss::row::binary_array::FlussArrayWriter;
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::LazyLock;
@@ -94,6 +96,28 @@ pub async fn create_table(
.expect("Failed to create table");
}
+pub fn make_string_array(values: &[Option<&str>]) -> FlussArray {
+ let mut writer = FlussArrayWriter::new(values.len(), &DataTypes::string());
+ for (idx, value) in values.iter().enumerate() {
+ match value {
+ Some(v) => writer.write_string(idx, v),
+ None => writer.set_null_at(idx),
+ }
+ }
+ writer.complete().expect("Failed to build string array")
+}
+
+pub fn make_int_array(values: &[Option<i32>]) -> FlussArray {
+ let mut writer = FlussArrayWriter::new(values.len(), &DataTypes::int());
+ for (idx, value) in values.iter().enumerate() {
+ match value {
+ Some(v) => writer.write_int(idx, *v),
+ None => writer.set_null_at(idx),
+ }
+ }
+ writer.complete().expect("Failed to build int array")
+}
+
/// Similar to wait_for_cluster_ready but connects with SASL credentials.
pub async fn wait_for_cluster_ready_with_sasl(cluster: &FlussTestingCluster) {
let timeout = Duration::from_secs(30);