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 0d24bddf [python] map and row complex types support (#601)
0d24bddf is described below
commit 0d24bddfe5b435750de7bf7e7ea9265098df5b6b
Author: Anton Borisov <[email protected]>
AuthorDate: Mon Jun 8 00:10:59 2026 +0100
[python] map and row complex types support (#601)
* [python] map and row complex type support
* add additional types to from_arrow_field
---
bindings/python/fluss/__init__.pyi | 11 +
bindings/python/src/table.rs | 320 +++++++++++++++++++----
bindings/python/src/utils.rs | 78 +-----
bindings/python/test/conftest.py | 333 +++++++++++++++++++++++-
bindings/python/test/test_kv_table.py | 375 +++++++++++++++++----------
bindings/python/test/test_log_table.py | 291 ++++++++++++++++-----
bindings/python/test/test_schema.py | 61 ++++-
crates/fluss/src/record/arrow.rs | 70 ++++-
crates/fluss/src/row/binary_array.rs | 25 +-
crates/fluss/src/row/binary_map.rs | 24 ++
crates/fluss/src/row/column_writer.rs | 12 +-
website/docs/user-guide/python/data-types.md | 86 ++++++
12 files changed, 1338 insertions(+), 348 deletions(-)
diff --git a/bindings/python/fluss/__init__.pyi
b/bindings/python/fluss/__init__.pyi
index b5bfdfab..4fbbc97e 100644
--- a/bindings/python/fluss/__init__.pyi
+++ b/bindings/python/fluss/__init__.pyi
@@ -639,11 +639,22 @@ class AppendWriter:
- Bytes, Binary (binary data)
- Date, Time, Timestamp, TimestampLTZ (temporal)
- Decimal (arbitrary precision)
+ - Array (Python list)
+ - Map (dict, or list of (key, value) tuples)
+ - Row (dict keyed by field name, or list/tuple by position)
- Null values
+ Nested combinations of Array, Map, and Row are supported. On read,
+ Array -> list, Map -> list of (key, value) tuples, Row -> dict.
+
+ When the row is a dict, a nullable column may be omitted (it defaults
to
+ null); a non-nullable or primary-key column must be present.
+
Example:
writer.append({'id': 1, 'name': 'Alice', 'score': 95.5})
writer.append([1, 'Alice', 95.5])
+ writer.append({'id': 2, 'tags': ['a', 'b'],
+ 'attrs': {'k': 1}, 'profile': {'age': 30}})
Note:
For high-throughput bulk loading, prefer write_arrow_batch().
diff --git a/bindings/python/src/table.rs b/bindings/python/src/table.rs
index 04f65a8b..e18c74d4 100644
--- a/bindings/python/src/table.rs
+++ b/bindings/python/src/table.rs
@@ -21,6 +21,10 @@ use arrow::array::RecordBatch as ArrowRecordBatch;
use arrow::record_batch::RecordBatchReader as _;
use arrow_pyarrow::{FromPyArrow, ToPyArrow};
use arrow_schema::SchemaRef;
+use fcore::metadata::{DataField, DataType, MapType, RowType};
+use fcore::row::binary_array::{FlussArray, FlussArrayWriter};
+use fcore::row::binary_map::{FlussMap, FlussMapWriter};
+use fcore::row::{Datum, F32, F64, GenericRow, InternalRow};
use fluss::record::to_arrow_schema;
use indexmap::IndexMap;
use pyo3::IntoPyObjectExt;
@@ -1196,12 +1200,19 @@ fn python_to_generic_row_inner(
for (i, &col_idx) in target_indices.iter().enumerate() {
let name = target_names[i];
let field = &fields[col_idx];
- let value = dict
- .get_item(name)?
- .ok_or_else(|| FlussError::new_err(format!("Missing field:
{name}")))?;
let dest = if sparse { col_idx } else { i };
- datums[dest] = python_value_to_datum(&value, field.data_type())
- .map_err(|e| FlussError::new_err(format!("Field '{name}':
{e}")))?;
+ match dict.get_item(name)? {
+ Some(value) => {
+ datums[dest] = python_value_to_datum(&value,
field.data_type())
+ .map_err(|e| FlussError::new_err(format!("Field
'{name}': {e}")))?;
+ }
+ None if field.data_type().is_nullable() => {}
+ None => {
+ return Err(FlussError::new_err(format!(
+ "Missing value for non-nullable field '{name}'"
+ )));
+ }
+ }
}
}
@@ -1230,22 +1241,17 @@ fn python_to_generic_row_inner(
}
/// Convert Python value to Datum based on data type
-fn python_value_to_datum(
- value: &Bound<PyAny>,
- data_type: &fcore::metadata::DataType,
-) -> PyResult<fcore::row::Datum<'static>> {
- use fcore::row::{Datum, F32, F64};
-
+fn python_value_to_datum(value: &Bound<PyAny>, data_type: &DataType) ->
PyResult<Datum<'static>> {
if value.is_none() {
return Ok(Datum::Null);
}
match data_type {
- fcore::metadata::DataType::Boolean(_) => {
+ DataType::Boolean(_) => {
let v: bool = value.extract()?;
Ok(Datum::Bool(v))
}
- fcore::metadata::DataType::TinyInt(_) => {
+ DataType::TinyInt(_) => {
// Strict type checking: reject bool for int columns
if value.is_instance_of::<PyBool>() {
return Err(FlussError::new_err(
@@ -1255,7 +1261,7 @@ fn python_value_to_datum(
let v: i8 = value.extract()?;
Ok(Datum::Int8(v))
}
- fcore::metadata::DataType::SmallInt(_) => {
+ DataType::SmallInt(_) => {
if value.is_instance_of::<PyBool>() {
return Err(FlussError::new_err(
"Expected int for SmallInt column, got bool. Use 0 or 1
explicitly."
@@ -1265,7 +1271,7 @@ fn python_value_to_datum(
let v: i16 = value.extract()?;
Ok(Datum::Int16(v))
}
- fcore::metadata::DataType::Int(_) => {
+ DataType::Int(_) => {
if value.is_instance_of::<PyBool>() {
return Err(FlussError::new_err(
"Expected int for Int column, got bool. Use 0 or 1
explicitly.".to_string(),
@@ -1274,7 +1280,7 @@ fn python_value_to_datum(
let v: i32 = value.extract()?;
Ok(Datum::Int32(v))
}
- fcore::metadata::DataType::BigInt(_) => {
+ DataType::BigInt(_) => {
if value.is_instance_of::<PyBool>() {
return Err(FlussError::new_err(
"Expected int for BigInt column, got bool. Use 0 or 1
explicitly.".to_string(),
@@ -1283,19 +1289,19 @@ fn python_value_to_datum(
let v: i64 = value.extract()?;
Ok(Datum::Int64(v))
}
- fcore::metadata::DataType::Float(_) => {
+ DataType::Float(_) => {
let v: f32 = value.extract()?;
Ok(Datum::Float32(F32::from(v)))
}
- fcore::metadata::DataType::Double(_) => {
+ DataType::Double(_) => {
let v: f64 = value.extract()?;
Ok(Datum::Float64(F64::from(v)))
}
- fcore::metadata::DataType::String(_) |
fcore::metadata::DataType::Char(_) => {
+ DataType::String(_) | DataType::Char(_) => {
let v: String = value.extract()?;
Ok(v.into())
}
- fcore::metadata::DataType::Bytes(_) |
fcore::metadata::DataType::Binary(_) => {
+ DataType::Bytes(_) | DataType::Binary(_) => {
// Efficient extraction: downcast to specific type and use bulk
copy.
// PyBytes::as_bytes() and PyByteArray::to_vec() are O(n) bulk
copies of the underlying data.
if let Ok(bytes) = value.downcast::<PyBytes>() {
@@ -1309,14 +1315,14 @@ fn python_value_to_datum(
)))
}
}
- fcore::metadata::DataType::Decimal(decimal_type) => {
+ DataType::Decimal(decimal_type) => {
python_decimal_to_datum(value, decimal_type.precision(),
decimal_type.scale())
}
- fcore::metadata::DataType::Date(_) => python_date_to_datum(value),
- fcore::metadata::DataType::Time(_) => python_time_to_datum(value),
- fcore::metadata::DataType::Timestamp(_) =>
python_datetime_to_timestamp_ntz(value),
- fcore::metadata::DataType::TimestampLTz(_) =>
python_datetime_to_timestamp_ltz(value),
- fcore::metadata::DataType::Array(array_type) => {
+ DataType::Date(_) => python_date_to_datum(value),
+ DataType::Time(_) => python_time_to_datum(value),
+ DataType::Timestamp(_) => python_datetime_to_timestamp_ntz(value),
+ DataType::TimestampLTz(_) => python_datetime_to_timestamp_ltz(value),
+ DataType::Array(array_type) => {
let element_type = array_type.get_element_type();
if value.is_instance_of::<PyString>() {
return Err(FlussError::new_err(format!(
@@ -1332,7 +1338,7 @@ fn python_value_to_datum(
})?;
let len = seq.len()?;
- let mut writer =
fcore::row::binary_array::FlussArrayWriter::new(len, element_type);
+ let mut writer = FlussArrayWriter::new(len, element_type);
for i in 0..len {
let item = seq.get_item(i)?;
@@ -1352,29 +1358,27 @@ fn python_value_to_datum(
Datum::String(v) => writer.write_string(i, &v),
Datum::Blob(v) => writer.write_binary_bytes(i,
v.as_ref()),
Datum::Decimal(v) => {
- if let fcore::metadata::DataType::Decimal(dt) =
element_type {
+ if let DataType::Decimal(dt) = element_type {
writer.write_decimal(i, &v, dt.precision());
}
}
Datum::Date(v) => writer.write_date(i, v),
Datum::Time(v) => writer.write_time(i, v),
Datum::TimestampNtz(v) => {
- if let fcore::metadata::DataType::Timestamp(dt) =
element_type {
+ if let DataType::Timestamp(dt) = element_type {
writer.write_timestamp_ntz(i, &v,
dt.precision());
}
}
Datum::TimestampLtz(v) => {
- if let fcore::metadata::DataType::TimestampLTz(dt)
= element_type {
+ if let DataType::TimestampLTz(dt) = element_type {
writer.write_timestamp_ltz(i, &v,
dt.precision());
}
}
Datum::Array(v) => writer.write_array(i, &v),
Datum::Map(v) => writer.write_map(i, &v),
- Datum::Row(_) => {
- return Err(FlussError::new_err(
- "Row datum is not supported as an array
element",
- ));
- }
+ Datum::Row(v) => writer
+ .write_row(i, v.as_ref())
+ .map_err(|e| FlussError::from_core_error(&e))?,
}
}
}
@@ -1384,22 +1388,158 @@ fn python_value_to_datum(
.map_err(|e| FlussError::from_core_error(&e))?;
Ok(Datum::Array(array))
}
- _ => Err(FlussError::new_err(format!(
- "Unsupported data type for row-level operations: {data_type}"
- ))),
+ DataType::Map(map_type) => {
+ let key_type = map_type.key_type();
+ let value_type = map_type.value_type();
+ let pairs = python_map_pairs(value)?;
+ let mut writer = FlussMapWriter::new(pairs.len(), key_type,
value_type);
+ for (k, v) in pairs {
+ let key_datum = python_value_to_datum(&k, key_type)?;
+ let value_datum = python_value_to_datum(&v, value_type)?;
+ writer
+ .write_entry(key_datum, value_datum)
+ .map_err(|e| FlussError::from_core_error(&e))?;
+ }
+ let map = writer
+ .complete()
+ .map_err(|e| FlussError::from_core_error(&e))?;
+ Ok(Datum::Map(map))
+ }
+ DataType::Row(row_type) => {
+ let nested = python_value_to_row_datum(value, row_type)?;
+ Ok(Datum::Row(Box::new(nested)))
+ }
+ }
+}
+
+/// Extract `(key, value)` pairs from a Python value representing a MAP column.
+/// Accepts a `dict`, or a sequence of `(key, value)` pairs — matching the
+/// shape pyarrow's `MapArray.to_pylist()` produces, so MAP values round-trip.
+fn python_map_pairs<'py>(
+ value: &Bound<'py, PyAny>,
+) -> PyResult<Vec<(Bound<'py, PyAny>, Bound<'py, PyAny>)>> {
+ if let Ok(dict) = value.downcast::<PyDict>() {
+ return Ok(dict.iter().collect());
+ }
+ if value.is_instance_of::<PyString>() {
+ return Err(FlussError::new_err(format!(
+ "Expected dict or sequence of (key, value) pairs for Map column,
got {}",
+ get_type_name(value)
+ )));
+ }
+ let seq = value.downcast::<PySequence>().map_err(|_| {
+ FlussError::new_err(format!(
+ "Expected dict or sequence of (key, value) pairs for Map column,
got {}",
+ get_type_name(value)
+ ))
+ })?;
+ let len = seq.len()?;
+ let mut pairs = Vec::with_capacity(len);
+ for i in 0..len {
+ let entry = seq.get_item(i)?;
+ let pair = entry.downcast::<PySequence>().map_err(|_| {
+ FlussError::new_err("Map entries must be (key, value)
pairs".to_string())
+ })?;
+ if pair.len()? != 2 {
+ return Err(FlussError::new_err(
+ "Map entries must be (key, value) pairs of length
2".to_string(),
+ ));
+ }
+ pairs.push((pair.get_item(0)?, pair.get_item(1)?));
+ }
+ Ok(pairs)
+}
+
+/// Convert a Python value (`dict` by field name, or `list`/`tuple` by
position)
+/// into a nested `GenericRow` for a ROW column.
+fn python_value_to_row_datum(
+ value: &Bound<PyAny>,
+ row_type: &RowType,
+) -> PyResult<GenericRow<'static>> {
+ let fields = row_type.fields();
+ let mut datums: Vec<Datum<'static>> = vec![Datum::Null; fields.len()];
+
+ let row_input: RowInput = value.extract().map_err(|_| {
+ FlussError::new_err(format!(
+ "Row column must be a dict, list, or tuple; got {}",
+ get_type_name(value)
+ ))
+ })?;
+
+ match row_input {
+ RowInput::Dict(dict) => {
+ for (k, _) in dict.iter() {
+ let key_str = k.extract::<&str>().map_err(|_| {
+ FlussError::new_err(format!(
+ "Row field keys must be strings; got {}",
+ get_type_name(&k)
+ ))
+ })?;
+ if !fields.iter().any(|f| f.name() == key_str) {
+ return Err(FlussError::new_err(format!(
+ "Unknown row field '{}'. Expected: {}",
+ key_str,
+ fields
+ .iter()
+ .map(|f| f.name())
+ .collect::<Vec<_>>()
+ .join(", ")
+ )));
+ }
+ }
+ for (i, field) in fields.iter().enumerate() {
+ match dict.get_item(field.name())? {
+ Some(v) => {
+ datums[i] = python_value_to_datum(&v,
field.data_type()).map_err(|e| {
+ FlussError::new_err(format!("Row field '{}': {}",
field.name(), e))
+ })?;
+ }
+ None if field.data_type().is_nullable() => {}
+ None => {
+ return Err(FlussError::new_err(format!(
+ "Missing value for non-nullable row field '{}'",
+ field.name()
+ )));
+ }
+ }
+ }
+ }
+ RowInput::List(list) => fill_row_fields(list.as_sequence(), fields,
&mut datums)?,
+ RowInput::Tuple(tuple) => fill_row_fields(tuple.as_sequence(), fields,
&mut datums)?,
}
+
+ Ok(GenericRow { values: datums })
+}
+
+/// Fill ROW field datums from a positional Python sequence.
+fn fill_row_fields(
+ seq: &Bound<PySequence>,
+ fields: &[DataField],
+ datums: &mut [Datum<'static>],
+) -> PyResult<()> {
+ if seq.len()? != fields.len() {
+ return Err(FlussError::new_err(format!(
+ "Expected {} row fields, got {}",
+ fields.len(),
+ seq.len()?
+ )));
+ }
+ for (i, field) in fields.iter().enumerate() {
+ let v = seq.get_item(i)?;
+ datums[i] = python_value_to_datum(&v, field.data_type())
+ .map_err(|e| FlussError::new_err(format!("Row field '{}': {}",
field.name(), e)))?;
+ }
+ Ok(())
}
/// Convert Rust Datum to Python value based on data type.
/// This is the reverse of python_value_to_datum.
pub fn datum_to_python_value(
py: Python,
- row: &dyn fcore::row::InternalRow,
+ row: &dyn InternalRow,
pos: usize,
- data_type: &fcore::metadata::DataType,
+ data_type: &DataType,
) -> PyResult<Py<PyAny>> {
- use fcore::metadata::DataType;
-
// Check for null first
if row
.is_null_at(pos)
@@ -1522,19 +1662,95 @@ pub fn datum_to_python_value(
.map_err(|e| FlussError::from_core_error(&e))?
.try_into_binary()
.map_err(|e| FlussError::from_core_error(&e))?;
+ array_to_pylist(py, &array_data, array_type.get_element_type())
+ }
+ DataType::Map(map_type) => {
+ let map_data = row
+ .get_map(pos)
+ .map_err(|e| FlussError::from_core_error(&e))?
+ .try_into_binary()
+ .map_err(|e| FlussError::from_core_error(&e))?;
+ map_to_pylist(py, &map_data, map_type)
+ }
+ DataType::Row(row_type) => {
+ let nested = row
+ .get_row(pos)
+ .map_err(|e| FlussError::from_core_error(&e))?
+ .try_into_generic(row_type)
+ .map_err(|e| FlussError::from_core_error(&e))?;
+ row_to_pydict(py, &nested, row_type)
+ }
+ }
+}
- let element_type = array_type.get_element_type();
- let py_list = pyo3::types::PyList::empty(py);
+/// Convert a binary `FlussArray` to a Python list.
+fn array_to_pylist(py: Python, arr: &FlussArray, element_type: &DataType) ->
PyResult<Py<PyAny>> {
+ let py_list = pyo3::types::PyList::empty(py);
+ for i in 0..arr.size() {
+ py_list.append(array_elem_to_python(py, arr, i, element_type)?)?;
+ }
+ Ok(py_list.into_any().unbind())
+}
- for i in 0..array_data.size() {
- let py_val = datum_to_python_value(py, &array_data, i,
element_type)?;
- py_list.append(py_val)?;
- }
- Ok(py_list.into_any().unbind())
+/// Convert a Fluss `MAP` to a Python list of `(key, value)` tuples — matching
+/// pyarrow's default `MapArray.to_pylist()` shape (preserves duplicate keys
and
+/// ordering, and allows non-hashable keys).
+fn map_to_pylist(py: Python, map_data: &FlussMap, map_type: &MapType) ->
PyResult<Py<PyAny>> {
+ let keys = map_data.key_array();
+ let values = map_data.value_array();
+ let py_list = pyo3::types::PyList::empty(py);
+ for i in 0..map_data.size() {
+ let py_key = array_elem_to_python(py, keys, i, map_type.key_type())?;
+ let py_val = array_elem_to_python(py, values, i,
map_type.value_type())?;
+ py_list.append(pyo3::types::PyTuple::new(py, [py_key, py_val])?)?;
+ }
+ Ok(py_list.into_any().unbind())
+}
+
+/// Convert a Fluss `ROW` to a Python dict keyed by field name — matching
+/// pyarrow's `StructArray.to_pylist()` shape.
+fn row_to_pydict(py: Python, row: &dyn InternalRow, row_type: &RowType) ->
PyResult<Py<PyAny>> {
+ let dict = PyDict::new(py);
+ for (i, field) in row_type.fields().iter().enumerate() {
+ let py_val = datum_to_python_value(py, row, i, field.data_type())?;
+ dict.set_item(field.name(), py_val)?;
+ }
+ Ok(dict.into_any().unbind())
+}
+
+/// Convert element `i` of a binary `FlussArray` to a Python value. A binary
+/// array needs the explicit nested type to decode MAP/ROW elements (the
generic
+/// `DataGetters` trait getters cover only scalars and nested arrays), so this
+/// dispatches through `FlussArray`'s inherent typed getters.
+fn array_elem_to_python(
+ py: Python,
+ arr: &FlussArray,
+ i: usize,
+ dt: &DataType,
+) -> PyResult<Py<PyAny>> {
+ if arr.is_null_at(i) {
+ return Ok(py.None());
+ }
+ match dt {
+ DataType::Array(array_type) => {
+ let nested = arr
+ .get_array(i)
+ .map_err(|e| FlussError::from_core_error(&e))?;
+ array_to_pylist(py, &nested, array_type.get_element_type())
+ }
+ DataType::Map(map_type) => {
+ let nested = arr
+ .get_map(i, map_type.key_type(), map_type.value_type())
+ .map_err(|e| FlussError::from_core_error(&e))?;
+ map_to_pylist(py, &nested, map_type)
+ }
+ DataType::Row(row_type) => {
+ let nested = arr
+ .get_row(i, row_type)
+ .map_err(|e| FlussError::from_core_error(&e))?;
+ row_to_pydict(py, &nested, row_type)
}
- _ => Err(FlussError::new_err(format!(
- "Unsupported data type for conversion to Python: {data_type}"
- ))),
+ _ => datum_to_python_value(py, arr, i, dt),
}
}
diff --git a/bindings/python/src/utils.rs b/bindings/python/src/utils.rs
index f69188f1..78f224c9 100644
--- a/bindings/python/src/utils.rs
+++ b/bindings/python/src/utils.rs
@@ -18,6 +18,7 @@
use crate::*;
use arrow_pyarrow::{FromPyArrow, ToPyArrow};
use arrow_schema::SchemaRef;
+use fcore::record::from_arrow_field;
use std::sync::Arc;
/// Utilities for schema conversion between PyArrow, Arrow, and Fluss
@@ -36,82 +37,13 @@ impl Utils {
})
}
- /// Convert an Arrow Field to a Fluss DataType, preserving nullability.
+ /// Convert an Arrow Field to a Fluss DataType. Delegates to core's
canonical
+ /// Arrow->Fluss converter, which handles scalars, list, map, and struct
+ /// recursively and preserves per-field nullability.
pub fn arrow_field_to_fluss_type(
field: &arrow::datatypes::Field,
) -> PyResult<fcore::metadata::DataType> {
- use arrow::datatypes::DataType as ArrowDataType;
- use fcore::metadata::DataTypes;
-
- let fluss_type = match field.data_type() {
- ArrowDataType::Boolean => DataTypes::boolean(),
- ArrowDataType::Int8 => DataTypes::tinyint(),
- ArrowDataType::Int16 => DataTypes::smallint(),
- ArrowDataType::Int32 => DataTypes::int(),
- ArrowDataType::Int64 => DataTypes::bigint(),
- ArrowDataType::UInt8 => DataTypes::tinyint(),
- ArrowDataType::UInt16 => DataTypes::smallint(),
- ArrowDataType::UInt32 => DataTypes::int(),
- ArrowDataType::UInt64 => DataTypes::bigint(),
- ArrowDataType::Float32 => DataTypes::float(),
- ArrowDataType::Float64 => DataTypes::double(),
- ArrowDataType::Utf8 | ArrowDataType::LargeUtf8 =>
DataTypes::string(),
- ArrowDataType::Binary | ArrowDataType::LargeBinary =>
DataTypes::bytes(),
- ArrowDataType::FixedSizeBinary(n) => DataTypes::binary(*n as
usize),
- ArrowDataType::Date32 => DataTypes::date(),
- ArrowDataType::Date64 => DataTypes::date(),
- ArrowDataType::Time32(unit) => match unit {
- arrow_schema::TimeUnit::Second =>
DataTypes::time_with_precision(0),
- arrow_schema::TimeUnit::Millisecond =>
DataTypes::time_with_precision(3),
- _ => {
- return Err(FlussError::new_err(format!(
- "Unsupported Time32 unit: {unit:?}"
- )));
- }
- },
- ArrowDataType::Time64(unit) => match unit {
- arrow_schema::TimeUnit::Microsecond =>
DataTypes::time_with_precision(6),
- arrow_schema::TimeUnit::Nanosecond =>
DataTypes::time_with_precision(9),
- _ => {
- return Err(FlussError::new_err(format!(
- "Unsupported Time64 unit: {unit:?}"
- )));
- }
- },
- ArrowDataType::Timestamp(unit, tz) => {
- let precision = match unit {
- arrow_schema::TimeUnit::Second => 0,
- arrow_schema::TimeUnit::Millisecond => 3,
- arrow_schema::TimeUnit::Microsecond => 6,
- arrow_schema::TimeUnit::Nanosecond => 9,
- };
- // Arrow Timestamp with timezone -> Fluss TimestampLtz
- // Arrow Timestamp without timezone -> Fluss Timestamp (NTZ)
- if tz.is_some() {
- DataTypes::timestamp_ltz_with_precision(precision)
- } else {
- DataTypes::timestamp_with_precision(precision)
- }
- }
- ArrowDataType::Decimal128(precision, scale) => {
- DataTypes::decimal(*precision as u32, *scale as u32)
- }
- ArrowDataType::List(element_field) => {
- let element_type =
Utils::arrow_field_to_fluss_type(element_field)?;
- DataTypes::array(element_type)
- }
- other => {
- return Err(FlussError::new_err(format!(
- "Unsupported Arrow data type: {other:?}"
- )));
- }
- };
-
- if field.is_nullable() {
- Ok(fluss_type)
- } else {
- Ok(fluss_type.as_non_nullable())
- }
+ from_arrow_field(field).map_err(|e| FlussError::from_core_error(&e))
}
/// Convert Fluss DataType to string representation, appending " NOT NULL"
diff --git a/bindings/python/test/conftest.py b/bindings/python/test/conftest.py
index 8b2bc732..22de4d43 100644
--- a/bindings/python/test/conftest.py
+++ b/bindings/python/test/conftest.py
@@ -17,12 +17,17 @@
import asyncio
import json
+import math
import os
import subprocess
import tempfile
import time
+from datetime import date, datetime, timezone
+from datetime import time as dt_time
+from decimal import Decimal
from pathlib import Path
+import pyarrow as pa
import pytest
import pytest_asyncio
from filelock import FileLock
@@ -156,6 +161,7 @@ async def wait_for_table_ready(admin):
"""
Fixture that returns a helper function to wait for a table or partition to
be ready.
"""
+
async def _wait(table_path, timeout=15, partition_name=None):
start_time = time.monotonic()
while time.monotonic() - start_time < timeout:
@@ -165,12 +171,22 @@ async def wait_for_table_ready(admin):
table_path, partition_name, [0],
fluss.OffsetSpec.earliest()
)
else:
- await admin.list_offsets(table_path, [0],
fluss.OffsetSpec.earliest())
+ await admin.list_offsets(
+ table_path, [0], fluss.OffsetSpec.earliest()
+ )
return
except (fluss.FlussError, Exception) as e:
# Catch "No leader found" or other errors that indicate the
table/partition is still initializing
err_msg = str(e)
- if any(msg in err_msg for msg in ["No leader found", "Table
not ready", "Metadata not ready", "not leader or follower"]):
+ if any(
+ msg in err_msg
+ for msg in [
+ "No leader found",
+ "Table not ready",
+ "Metadata not ready",
+ "not leader or follower",
+ ]
+ ):
await asyncio.sleep(1)
continue
raise
@@ -180,3 +196,316 @@ async def wait_for_table_ready(admin):
)
return _wait
+
+
+# Complex-type (ARRAY/MAP/ROW) helpers shared by the KV and log
+# all_complex_datatypes tests: schema sections plus the full/edge/null row
matrix.
+# Read-back shapes: ARRAY -> list, MAP -> list of (key, value) tuples, ROW ->
dict.
+
+
+def pa_row_seq_label() -> pa.DataType:
+ return pa.struct([("seq", pa.int32()), ("label", pa.string())])
+
+
+def pa_row_deep() -> pa.DataType:
+ return pa.struct([("inner", pa.struct([("n", pa.int32())]))])
+
+
+def pa_row_rich() -> pa.DataType:
+ return pa.struct(
+ [
+ ("f_bool", pa.bool_()),
+ ("f_int", pa.int32()),
+ ("f_long", pa.int64()),
+ ("f_float", pa.float32()),
+ ("f_double", pa.float64()),
+ ("f_str", pa.string()),
+ ("f_bytes", pa.binary()),
+ ("f_decimal", pa.decimal128(10, 2)),
+ ("f_date", pa.date32()),
+ ("f_time", pa.time32("ms")),
+ ("f_ts_ntz", pa.timestamp("us")),
+ ("f_ts_ltz", pa.timestamp("us", tz="UTC")),
+ ("f_binary", pa.binary(4)),
+ ("f_array_int", pa.list_(pa.int32())),
+ ]
+ )
+
+
+def array_basics_fields() -> list:
+ return [
+ pa.field("arr_int", pa.list_(pa.int32())),
+ pa.field("arr_string", pa.list_(pa.string())),
+ pa.field("arr_of_arr", pa.list_(pa.list_(pa.int32()))),
+ pa.field("arr_of_row", pa.list_(pa_row_seq_label())),
+ ]
+
+
+def row_basics_fields() -> list:
+ return [
+ pa.field("row_basic", pa_row_seq_label()),
+ pa.field("row_deep", pa_row_deep()),
+ pa.field("row_rich", pa_row_rich()),
+ ]
+
+
+def map_basics_fields() -> list:
+ return [
+ pa.field("map_string_int", pa.map_(pa.string(), pa.int32())),
+ pa.field("map_of_row", pa.map_(pa.string(), pa_row_seq_label())),
+ pa.field("map_of_map", pa.map_(pa.string(), pa.map_(pa.string(),
pa.int32()))),
+ pa.field("map_of_array", pa.map_(pa.string(), pa.list_(pa.int32()))),
+ pa.field("array_of_map", pa.list_(pa.map_(pa.string(), pa.int32()))),
+ ]
+
+
+def array_rich_fields() -> list:
+ return [
+ 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", 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_decimal_big", pa.list_(pa.decimal128(22, 5))),
+ pa.field("arr_float", pa.list_(pa.float32())),
+ pa.field("arr_double", pa.list_(pa.float64())),
+ pa.field("arr_binary", pa.list_(pa.binary(4))),
+ ]
+
+
+def map_rich_fields() -> list:
+ return [
+ pa.field("map_bytes", pa.map_(pa.string(), pa.binary())),
+ pa.field("map_decimal", pa.map_(pa.string(), pa.decimal128(10, 2))),
+ pa.field("map_date", pa.map_(pa.string(), pa.date32())),
+ pa.field("map_time", pa.map_(pa.string(), pa.time32("ms"))),
+ pa.field("map_ts", pa.map_(pa.string(), pa.timestamp("us"))),
+ pa.field("map_ts_ltz", pa.map_(pa.string(), pa.timestamp("us",
tz="UTC"))),
+ pa.field("map_float", pa.map_(pa.string(), pa.float32())),
+ pa.field("map_double", pa.map_(pa.string(), pa.float64())),
+ pa.field("map_bool", pa.map_(pa.string(), pa.bool_())),
+ pa.field("map_binary", pa.map_(pa.string(), pa.binary(4))),
+ pa.field("map_int_key", pa.map_(pa.int32(), pa.string())),
+ ]
+
+
+def complex_fields() -> list:
+ """`id` + all complex sections, in section order."""
+ return (
+ [pa.field("id", pa.int32())]
+ + array_basics_fields()
+ + array_rich_fields()
+ + row_basics_fields()
+ + map_basics_fields()
+ + map_rich_fields()
+ )
+
+
+def complex_column_names() -> list:
+ """All complex column names (everything except `id`)."""
+ return [f.name for f in complex_fields() if f.name != "id"]
+
+
+def complex_schema(primary_keys=None) -> "fluss.Schema":
+ return fluss.Schema(pa.schema(complex_fields()), primary_keys=primary_keys)
+
+
+_ROW_RICH_FULL = {
+ "f_bool": True,
+ "f_int": 100_000,
+ "f_long": 9_876_543_210,
+ "f_float": float("inf"),
+ "f_double": math.pi,
+ "f_str": "hello world",
+ "f_bytes": b"binary",
+ "f_decimal": Decimal("123.45"),
+ "f_date": date(2026, 1, 23),
+ "f_time": dt_time(10, 13, 47, 123000),
+ "f_ts_ntz": datetime(2026, 1, 23, 10, 13, 47, 123456),
+ "f_ts_ltz": datetime(2026, 1, 23, 10, 13, 47, 123456, tzinfo=timezone.utc),
+ "f_binary": b"\x01\x02\x03\x04",
+ "f_array_int": [7, None, 11],
+}
+
+
+def complex_full_row(id_: int) -> dict:
+ """Fully-populated row exercising every complex shape (incl. nesting)."""
+ return {
+ "id": id_,
+ "arr_int": [10, 20, 30],
+ "arr_string": ["hello", "world"],
+ "arr_of_arr": [[1, 2], [3, 4]],
+ "arr_of_row": [{"seq": 1, "label": "open"}, {"seq": 2, "label":
"close"}],
+ "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": [datetime(2026, 1, 23, 10, 13, 47, 123456)],
+ "arr_ts_ltz": [datetime(2026, 1, 23, 10, 13, 47, 123456,
tzinfo=timezone.utc)],
+ "arr_decimal": [Decimal("123.45"), None],
+ "arr_decimal_big": [
+ Decimal("12345678901234567.12345"),
+ Decimal("-99999999999999999.99999"),
+ ],
+ "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"],
+ "row_basic": {"seq": 42, "label": "hello"},
+ "row_deep": {"inner": {"n": 99}},
+ "row_rich": dict(_ROW_RICH_FULL),
+ "map_string_int": {"a": 1, "b": None, "c": 3},
+ "map_of_row": {
+ "e0": {"seq": 1, "label": "open"},
+ "e1": {"seq": 2, "label": "close"},
+ },
+ "map_of_map": {"g1": {"a": 1, "b": 2}, "g2": {"c": 3}},
+ "map_of_array": {"primes": [2, 3, 5], "squares": [1, 4]},
+ "array_of_map": [{"x": 1, "y": 2}, {"z": 9}],
+ "map_bytes": {"k": b"\x10\x20\x30"},
+ "map_decimal": {"p": Decimal("123.45")},
+ "map_date": {"d": date(2026, 1, 23)},
+ "map_time": {"t": dt_time(10, 13, 47, 123000)},
+ "map_ts": {"t": datetime(2026, 1, 23, 10, 13, 47, 123456)},
+ "map_ts_ltz": {
+ "t": datetime(2026, 1, 23, 10, 13, 47, 123456, tzinfo=timezone.utc)
+ },
+ "map_float": {"nan": float("nan"), "inf": float("inf"), "ninf":
float("-inf")},
+ "map_double": {"nan": float("nan"), "inf": float("inf"), "ninf":
float("-inf")},
+ "map_bool": {"t": True, "f": False},
+ "map_binary": {"k": b"\x01\x02\x03\x04"},
+ "map_int_key": {1: "one", 2: "two"},
+ }
+
+
+# Rich sections appear only in the full row; edge/null rows leave them NULL.
+_RICH_COLUMNS = [f.name for f in array_rich_fields() + map_rich_fields()]
+
+
+def complex_edge_row(id_: int) -> dict:
+ """Edge cases: empty collections, null elements, null nested rows."""
+ return {
+ "id": id_,
+ "arr_int": [],
+ "arr_string": [None],
+ "arr_of_arr": [[5], None, []],
+ "arr_of_row": [{"seq": 7, "label": "x"}, None, {"seq": 8, "label":
"y"}],
+ "row_basic": None,
+ "row_deep": None,
+ "row_rich": None,
+ "map_string_int": {},
+ "map_of_row": {},
+ "map_of_map": {},
+ "map_of_array": {},
+ "array_of_map": [],
+ **{name: None for name in _RICH_COLUMNS},
+ }
+
+
+def complex_null_row(id_: int) -> dict:
+ """Every complex column set to NULL."""
+ return {"id": id_, **{name: None for name in complex_column_names()}}
+
+
+def _map(value) -> dict:
+ """A read-back MAP is a list of (key, value) tuples; turn it into a dict
for
+ order-independent comparison (test maps use unique scalar keys)."""
+ return dict(value)
+
+
+def _assert_float_triplet(values) -> None:
+ """Assert a 3-element sequence holds [NaN, +Inf, -Inf]."""
+ assert math.isnan(values[0])
+ assert math.isinf(values[1]) and values[1] > 0
+ assert math.isinf(values[2]) and values[2] < 0
+
+
+def assert_complex_full(row: dict) -> None:
+ assert row["arr_int"] == [10, 20, 30]
+ assert row["arr_string"] == ["hello", "world"]
+ assert row["arr_of_arr"] == [[1, 2], [3, 4]]
+ assert row["arr_of_row"] == [
+ {"seq": 1, "label": "open"},
+ {"seq": 2, "label": "close"},
+ ]
+ assert row["row_basic"] == {"seq": 42, "label": "hello"}
+ assert row["row_deep"] == {"inner": {"n": 99}}
+ rr = row["row_rich"]
+ assert rr["f_bool"] is True
+ assert rr["f_int"] == 100_000
+ assert rr["f_long"] == 9_876_543_210
+ assert math.isinf(rr["f_float"]) and rr["f_float"] > 0
+ assert math.isclose(rr["f_double"], math.pi, rel_tol=1e-15)
+ assert rr["f_str"] == "hello world"
+ assert rr["f_bytes"] == b"binary"
+ assert rr["f_decimal"] == Decimal("123.45")
+ assert rr["f_date"] == date(2026, 1, 23)
+ assert rr["f_time"] == dt_time(10, 13, 47, 123000)
+ assert rr["f_ts_ntz"] == datetime(2026, 1, 23, 10, 13, 47, 123456)
+ assert rr["f_ts_ltz"] == datetime(
+ 2026, 1, 23, 10, 13, 47, 123456, tzinfo=timezone.utc
+ )
+ assert rr["f_binary"] == b"\x01\x02\x03\x04"
+ assert rr["f_array_int"] == [7, None, 11]
+ assert _map(row["map_string_int"]) == {"a": 1, "b": None, "c": 3}
+ assert _map(row["map_of_row"]) == {
+ "e0": {"seq": 1, "label": "open"},
+ "e1": {"seq": 2, "label": "close"},
+ }
+ assert {k: _map(v) for k, v in row["map_of_map"]} == {
+ "g1": {"a": 1, "b": 2},
+ "g2": {"c": 3},
+ }
+ assert _map(row["map_of_array"]) == {"primes": [2, 3, 5], "squares": [1,
4]}
+ assert [_map(m) for m in row["array_of_map"]] == [{"x": 1, "y": 2}, {"z":
9}]
+ assert row["arr_bytes"] == [b"\x10\x20\x30", None]
+ assert row["arr_date"] == [date(2026, 1, 23), None]
+ assert row["arr_time"] == [dt_time(10, 13, 47, 123000), None]
+ assert row["arr_ts"] == [datetime(2026, 1, 23, 10, 13, 47, 123456)]
+ assert row["arr_ts_ltz"] == [
+ datetime(2026, 1, 23, 10, 13, 47, 123456, tzinfo=timezone.utc)
+ ]
+ assert row["arr_decimal"] == [Decimal("123.45"), None]
+ assert row["arr_decimal_big"] == [
+ Decimal("12345678901234567.12345"),
+ Decimal("-99999999999999999.99999"),
+ ]
+ _assert_float_triplet(row["arr_float"])
+ _assert_float_triplet(row["arr_double"])
+ assert row["arr_binary"] == [b"\xde\xad\xbe\xef", b"\x00\x01\x02\x03"]
+ assert _map(row["map_bytes"]) == {"k": b"\x10\x20\x30"}
+ assert _map(row["map_decimal"]) == {"p": Decimal("123.45")}
+ assert _map(row["map_date"]) == {"d": date(2026, 1, 23)}
+ assert _map(row["map_time"]) == {"t": dt_time(10, 13, 47, 123000)}
+ assert _map(row["map_ts"]) == {"t": datetime(2026, 1, 23, 10, 13, 47,
123456)}
+ assert _map(row["map_ts_ltz"]) == {
+ "t": datetime(2026, 1, 23, 10, 13, 47, 123456, tzinfo=timezone.utc)
+ }
+ mf = _map(row["map_float"])
+ _assert_float_triplet([mf["nan"], mf["inf"], mf["ninf"]])
+ md = _map(row["map_double"])
+ _assert_float_triplet([md["nan"], md["inf"], md["ninf"]])
+ assert _map(row["map_bool"]) == {"t": True, "f": False}
+ assert _map(row["map_binary"]) == {"k": b"\x01\x02\x03\x04"}
+ assert _map(row["map_int_key"]) == {1: "one", 2: "two"}
+
+
+def assert_complex_edge(row: dict) -> None:
+ assert row["arr_int"] == []
+ assert row["arr_string"] == [None]
+ assert row["arr_of_arr"] == [[5], None, []]
+ assert row["arr_of_row"] == [
+ {"seq": 7, "label": "x"},
+ None,
+ {"seq": 8, "label": "y"},
+ ]
+ assert row["row_basic"] is None
+ assert row["row_deep"] is None
+ assert row["row_rich"] is None
+ # empty maps read back as empty lists
+ assert row["map_string_int"] == []
+ assert row["map_of_row"] == []
+ assert row["map_of_map"] == []
+ assert row["map_of_array"] == []
+ assert row["array_of_map"] == []
+ for name in _RICH_COLUMNS:
+ assert row[name] is None, f"{name} should be null in the edge row"
diff --git a/bindings/python/test/test_kv_table.py
b/bindings/python/test/test_kv_table.py
index f3cddf8c..83ceea0e 100644
--- a/bindings/python/test/test_kv_table.py
+++ b/bindings/python/test/test_kv_table.py
@@ -27,6 +27,15 @@ from decimal import Decimal
import pyarrow as pa
import pytest
+from conftest import (
+ assert_complex_edge,
+ assert_complex_full,
+ complex_column_names,
+ complex_edge_row,
+ complex_full_row,
+ complex_null_row,
+ complex_schema,
+)
import fluss
@@ -175,7 +184,9 @@ async def test_composite_primary_keys(connection, admin):
assert result is not None
assert result["score"] == 500
- prefix_lookuper = table.new_lookup().lookup_by(["region",
"user_id"]).create_lookuper()
+ prefix_lookuper = (
+ table.new_lookup().lookup_by(["region", "user_id"]).create_lookuper()
+ )
# Prefix (US, 1) should match 2 rows (event_id 1 and 2)
rows = await prefix_lookuper.lookup({"region": "US", "user_id": 1})
@@ -220,9 +231,7 @@ async def test_partial_update(connection, admin):
# Insert initial record
upsert_writer = table.new_upsert().create_writer()
- handle = upsert_writer.upsert(
- {"id": 1, "name": "Verso", "age": 32, "score": 6942}
- )
+ handle = upsert_writer.upsert({"id": 1, "name": "Verso", "age": 32,
"score": 6942})
await handle.wait()
lookuper = table.new_lookup().create_lookuper()
@@ -272,15 +281,11 @@ async def test_partial_update_by_index(connection, admin):
table = await connection.get_table(table_path)
upsert_writer = table.new_upsert().create_writer()
- handle = upsert_writer.upsert(
- {"id": 1, "name": "Verso", "age": 32, "score": 6942}
- )
+ handle = upsert_writer.upsert({"id": 1, "name": "Verso", "age": 32,
"score": 6942})
await handle.wait()
# Partial update by indices: columns 0=id (PK), 1=name
- partial_writer = (
- table.new_upsert().partial_update_by_index([0, 1]).create_writer()
- )
+ partial_writer = table.new_upsert().partial_update_by_index([0,
1]).create_writer()
handle = partial_writer.upsert([1, "Verso Renamed"])
await handle.wait()
@@ -293,6 +298,82 @@ async def test_partial_update_by_index(connection, admin):
await admin.drop_table(table_path, ignore_if_not_exists=False)
+async def test_partial_update_complex(connection, admin):
+ """Partial updates preserve non-targeted ROW/MAP/ARRAY columns and update
the
+ targeted ones (mirrors the Rust partial_update IT)."""
+ table_path = fluss.TablePath("fluss", "py_test_partial_update_complex")
+ await admin.drop_table(table_path, ignore_if_not_exists=True)
+
+ schema = fluss.Schema(
+ pa.schema(
+ [
+ pa.field("id", pa.int32()),
+ pa.field("name", pa.string()),
+ pa.field(
+ "nested", pa.struct([("seq", pa.int32()), ("label",
pa.string())])
+ ),
+ pa.field("attrs", pa.map_(pa.string(), pa.int32())),
+ pa.field("tags", pa.list_(pa.string())),
+ ]
+ ),
+ primary_keys=["id"],
+ )
+ await admin.create_table(
+ table_path, fluss.TableDescriptor(schema), ignore_if_exists=False
+ )
+ table = await connection.get_table(table_path)
+
+ await _upsert_and_wait(
+ table.new_upsert().create_writer(),
+ {
+ "id": 1,
+ "name": "Verso",
+ "nested": {"seq": 10, "label": "alpha"},
+ "attrs": {"a": 1, "b": 2},
+ "tags": ["alpha-tag", "beta-tag"],
+ },
+ )
+
+ lookuper = table.new_lookup().create_lookuper()
+
+ # Update only `name`: ROW/MAP/ARRAY columns must be preserved.
+ await _upsert_and_wait(
+ table.new_upsert().partial_update_by_name(["id",
"name"]).create_writer(),
+ {"id": 1, "name": "Renamed"},
+ )
+ r = await lookuper.lookup({"id": 1})
+ assert r["name"] == "Renamed"
+ assert r["nested"] == {"seq": 10, "label": "alpha"}
+ assert dict(r["attrs"]) == {"a": 1, "b": 2}
+ assert r["tags"] == ["alpha-tag", "beta-tag"]
+
+ # Update only `nested`: scalar + other complex columns preserved.
+ await _upsert_and_wait(
+ table.new_upsert().partial_update_by_name(["id",
"nested"]).create_writer(),
+ {"id": 1, "nested": {"seq": 99, "label": "omega"}},
+ )
+ r = await lookuper.lookup({"id": 1})
+ assert r["nested"] == {"seq": 99, "label": "omega"}
+ assert r["name"] == "Renamed"
+ assert dict(r["attrs"]) == {"a": 1, "b": 2}
+ assert r["tags"] == ["alpha-tag", "beta-tag"]
+
+ # Update `attrs` and `tags`.
+ await _upsert_and_wait(
+ table.new_upsert()
+ .partial_update_by_name(["id", "attrs", "tags"])
+ .create_writer(),
+ {"id": 1, "attrs": {"z": 99}, "tags": ["gamma"]},
+ )
+ r = await lookuper.lookup({"id": 1})
+ assert dict(r["attrs"]) == {"z": 99}
+ assert r["tags"] == ["gamma"]
+ assert r["nested"] == {"seq": 99, "label": "omega"}
+ assert r["name"] == "Renamed"
+
+ await admin.drop_table(table_path, ignore_if_not_exists=False)
+
+
async def test_partitioned_table_upsert_and_lookup(connection, admin):
"""Test upsert/lookup/delete on a partitioned KV table."""
table_path = fluss.TablePath("fluss", "py_test_partitioned_kv_table")
@@ -379,163 +460,179 @@ 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")
+async def test_omit_nullable_vs_required_fields(connection, admin):
+ """Omitting a nullable field defaults it to null; omitting a non-nullable
(or
+ PK) field is a clear client-side error -- at both top level and inside a
ROW."""
+ table_path = fluss.TablePath("fluss", "py_test_omit_fields")
await admin.drop_table(table_path, ignore_if_not_exists=True)
+ nested_type = pa.struct(
+ [
+ pa.field("a", pa.int32()), # nullable
+ pa.field("b", pa.int32(), nullable=False), # NOT NULL
+ ]
+ )
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()))),
+ pa.field("opt", pa.int32()), # nullable
+ pa.field("req", pa.int32(), nullable=False), # NOT NULL
+ pa.field("nested", nested_type),
]
),
primary_keys=["id"],
)
- table_descriptor = fluss.TableDescriptor(schema)
- await admin.create_table(table_path, table_descriptor,
ignore_if_exists=False)
-
+ await admin.create_table(
+ table_path, fluss.TableDescriptor(schema), ignore_if_exists=False
+ )
table = await connection.get_table(table_path)
- upsert_writer = table.new_upsert().create_writer()
+ writer = table.new_upsert().create_writer()
+ lookuper = table.new_lookup().create_lookuper()
- 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,
[]]},
- )
+ # Omit nullable top-level field `opt` -> Null.
+ await _upsert_and_wait(writer, {"id": 1, "req": 5, "nested": {"a": 1, "b":
2}})
+ r = await lookuper.lookup({"id": 1})
+ assert r["opt"] is None
+ assert r["req"] == 5
+ assert r["nested"] == {"a": 1, "b": 2}
- lookuper = table.new_lookup().create_lookuper()
+ # Omit nullable nested field `a` -> Null.
+ await _upsert_and_wait(writer, {"id": 2, "req": 9, "nested": {"b": 3}})
+ r = await lookuper.lookup({"id": 2})
+ assert r["nested"] == {"a": None, "b": 3}
- 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, []]
+ # Omit non-nullable top-level field `req` -> error.
+ with pytest.raises(Exception, match="non-nullable field 'req'"):
+ writer.upsert({"id": 3, "opt": 1, "nested": {"a": 1, "b": 2}})
+
+ # Omit non-nullable nested field `b` -> error.
+ with pytest.raises(Exception, match="non-nullable row field 'b'"):
+ writer.upsert({"id": 4, "req": 5, "nested": {"a": 1}})
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")
+async def test_partitioned_complex(connection, admin):
+ """ROW/MAP/ARRAY columns round-trip and update correctly across partitions
+ (mirrors the complex columns in the Rust
partitioned_table_upsert_and_lookup IT)."""
+ table_path = fluss.TablePath("fluss", "py_test_partitioned_complex")
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())),
+ pa.field("region", pa.string()),
+ pa.field("user_id", pa.int32()),
+ pa.field(
+ "nested", pa.struct([("seq", pa.int32()), ("label",
pa.string())])
+ ),
+ pa.field("attrs", pa.map_(pa.string(), pa.int32())),
+ pa.field("tags", pa.list_(pa.string())),
]
),
- primary_keys=["id"],
+ primary_keys=["region", "user_id"],
)
- table_descriptor = fluss.TableDescriptor(schema)
- await admin.create_table(table_path, table_descriptor,
ignore_if_exists=False)
+ await admin.create_table(
+ table_path,
+ fluss.TableDescriptor(schema, partition_keys=["region"]),
+ ignore_if_exists=False,
+ )
+ for region in ["US", "EU"]:
+ await admin.create_partition(
+ table_path, {"region": region}, ignore_if_exists=True
+ )
table = await connection.get_table(table_path)
- upsert_writer = table.new_upsert().create_writer()
+ writer = table.new_upsert().create_writer()
+ await _upsert_and_wait(
+ writer,
+ {
+ "region": "US",
+ "user_id": 1,
+ "nested": {"seq": 11, "label": "a"},
+ "attrs": {"x": 1},
+ "tags": ["alpha"],
+ },
+ )
+ await _upsert_and_wait(
+ writer,
+ {
+ "region": "EU",
+ "user_id": 1,
+ "nested": {"seq": 22, "label": "b"},
+ "attrs": {"y": 2},
+ "tags": ["beta"],
+ },
+ )
+ lookuper = table.new_lookup().create_lookuper()
+ r = await lookuper.lookup({"region": "US", "user_id": 1})
+ assert r["nested"] == {"seq": 11, "label": "a"}
+ assert dict(r["attrs"]) == {"x": 1}
+ assert r["tags"] == ["alpha"]
+ r = await lookuper.lookup({"region": "EU", "user_id": 1})
+ assert r["nested"] == {"seq": 22, "label": "b"}
+ assert dict(r["attrs"]) == {"y": 2}
+ assert r["tags"] == ["beta"]
+
+ # Update complex columns within one partition; sibling partition
unaffected.
await _upsert_and_wait(
- upsert_writer,
+ 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"],
+ "region": "US",
+ "user_id": 1,
+ "nested": {"seq": 99, "label": "updated"},
+ "attrs": {"z": 99},
+ "tags": ["renamed"],
},
)
+ r = await lookuper.lookup({"region": "US", "user_id": 1})
+ assert r["nested"] == {"seq": 99, "label": "updated"}
+ assert dict(r["attrs"]) == {"z": 99}
+ assert r["tags"] == ["renamed"]
+ r = await lookuper.lookup({"region": "EU", "user_id": 1})
+ assert r["nested"] == {"seq": 22, "label": "b"}
+
+ await admin.drop_table(table_path, ignore_if_not_exists=False)
+
+
+async def test_all_complex_datatypes(connection, admin):
+ """Comprehensive ARRAY/MAP/ROW coverage via upsert+lookup: full, edge,
null rows.
+
+ Mirrors the section-based ``all_supported_datatypes`` structure of the Rust
+ integration tests: the schema is composed from the shared array/row/map
+ sections, and three rows exercise fully-populated, edge-case, and all-null
+ values (see complex_types.py).
+ """
+ table_path = fluss.TablePath("fluss", "py_test_kv_all_complex")
+ await admin.drop_table(table_path, ignore_if_not_exists=True)
+
+ schema = complex_schema(primary_keys=["id"])
+ await admin.create_table(
+ table_path, fluss.TableDescriptor(schema), ignore_if_exists=False
+ )
+
+ table = await connection.get_table(table_path)
+ upsert_writer = table.new_upsert().create_writer()
+ await _upsert_and_wait(upsert_writer, complex_full_row(1))
+ await _upsert_and_wait(upsert_writer, complex_edge_row(2))
+ await _upsert_and_wait(upsert_writer, complex_null_row(3))
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"]
+ r1 = await lookuper.lookup({"id": 1})
+ assert r1 is not None
+ assert_complex_full(r1)
+
+ r2 = await lookuper.lookup({"id": 2})
+ assert r2 is not None
+ assert_complex_edge(r2)
+
+ r3 = await lookuper.lookup({"id": 3})
+ assert r3 is not None
+ for col in complex_column_names():
+ assert r3[col] is None, f"{col} should be null"
await admin.drop_table(table_path, ignore_if_not_exists=False)
@@ -677,7 +774,9 @@ async def test_prefix_lookup_validation_errors(connection,
admin):
# Partitioned table: lookup columns must include partition keys first,
# followed by bucket keys.
- partitioned_table_path = fluss.TablePath("fluss",
"py_test_prefix_lookup_validation_pt")
+ partitioned_table_path = fluss.TablePath(
+ "fluss", "py_test_prefix_lookup_validation_pt"
+ )
await admin.drop_table(partitioned_table_path, ignore_if_not_exists=True)
partitioned_schema = fluss.Schema(
@@ -708,13 +807,19 @@ async def
test_prefix_lookup_validation_errors(connection, admin):
# A non-existent partition returns empty list.
partitioned_prefix_lookuper = (
- partitioned_table.new_lookup().lookup_by(["region",
"user_id"]).create_lookuper()
+ partitioned_table.new_lookup()
+ .lookup_by(["region", "user_id"])
+ .create_lookuper()
+ )
+ rows = await partitioned_prefix_lookuper.lookup(
+ {"region": "UNKNOWN_REGION", "user_id": 1}
)
- rows = await partitioned_prefix_lookuper.lookup({"region":
"UNKNOWN_REGION", "user_id": 1})
assert rows == []
# After partition keys, remaining columns must equal bucket keys.
with pytest.raises(fluss.FlussError, match="bucket keys"):
- partitioned_table.new_lookup().lookup_by(["region",
"event_id"]).create_lookuper()
+ partitioned_table.new_lookup().lookup_by(
+ ["region", "event_id"]
+ ).create_lookuper()
await admin.drop_table(partitioned_table_path, ignore_if_not_exists=False)
diff --git a/bindings/python/test/test_log_table.py
b/bindings/python/test/test_log_table.py
index 50b9078b..b6bee545 100644
--- a/bindings/python/test/test_log_table.py
+++ b/bindings/python/test/test_log_table.py
@@ -25,6 +25,15 @@ import time
import pyarrow as pa
import pytest
+from conftest import (
+ assert_complex_edge,
+ assert_complex_full,
+ complex_column_names,
+ complex_edge_row,
+ complex_full_row,
+ complex_null_row,
+ complex_schema,
+)
import fluss
@@ -37,9 +46,7 @@ async def test_append_and_scan(connection, admin):
schema = fluss.Schema(
pa.schema([pa.field("c1", pa.int32()), pa.field("c2", pa.string())])
)
- table_descriptor = fluss.TableDescriptor(
- schema, bucket_count=3, bucket_keys=["c1"]
- )
+ table_descriptor = fluss.TableDescriptor(schema, bucket_count=3,
bucket_keys=["c1"])
await admin.create_table(table_path, table_descriptor,
ignore_if_exists=False)
table = await connection.get_table(table_path)
@@ -266,6 +273,58 @@ async def test_project(connection, admin):
await admin.drop_table(table_path, ignore_if_not_exists=False)
+async def test_project_compound_types(connection, admin):
+ """Projection selects and reorders ROW/MAP/ARRAY columns and drops others
+ (mirrors the Rust projection_with_compound_types IT)."""
+ table_path = fluss.TablePath("fluss", "py_test_project_compound")
+ await admin.drop_table(table_path, ignore_if_not_exists=True)
+
+ schema = fluss.Schema(
+ pa.schema(
+ [
+ pa.field("id", pa.int32()),
+ pa.field(
+ "nested", pa.struct([("seq", pa.int32()), ("label",
pa.string())])
+ ),
+ pa.field("attrs", pa.map_(pa.string(), pa.int32())),
+ pa.field("tags", pa.list_(pa.string())),
+ pa.field("extra", pa.string()),
+ ]
+ )
+ )
+ await admin.create_table(
+ table_path, fluss.TableDescriptor(schema), ignore_if_exists=False
+ )
+ table = await connection.get_table(table_path)
+
+ aw = table.new_append().create_writer()
+ aw.append(
+ {
+ "id": 7,
+ "nested": {"seq": 42, "label": "hello"},
+ "attrs": {"x": 1, "y": 2},
+ "tags": ["alpha", "beta"],
+ "extra": "ignore-me",
+ }
+ )
+ await aw.flush()
+
+ # Reorder and drop `extra`.
+ scan = table.new_scan().project_by_name(["nested", "attrs", "tags", "id"])
+ scanner = await scan.create_log_scanner()
+ scanner.subscribe_buckets({0: fluss.EARLIEST_OFFSET})
+ records = await _poll_records(scanner, expected_count=1)
+ assert len(records) == 1
+ row = records[0].row
+ assert row["nested"] == {"seq": 42, "label": "hello"}
+ assert dict(row["attrs"]) == {"x": 1, "y": 2}
+ assert row["tags"] == ["alpha", "beta"]
+ assert row["id"] == 7
+ assert "extra" not in row
+
+ await admin.drop_table(table_path, ignore_if_not_exists=False)
+
+
async def test_poll_batches(connection, admin, wait_for_table_ready):
"""Test batch-based scanning with poll_arrow and poll_record_batch."""
table_path = fluss.TablePath("fluss", "py_test_poll_batches")
@@ -334,9 +393,7 @@ async def test_poll_batches(connection, admin,
wait_for_table_ready):
# Projection with batch scanner
proj_scanner = (
- await table.new_scan()
- .project_by_name(["id"])
- .create_record_batch_log_scanner()
+ await
table.new_scan().project_by_name(["id"]).create_record_batch_log_scanner()
)
proj_scanner.subscribe(bucket_id=0, start_offset=0)
batches = await proj_scanner.poll_record_batch(10000)
@@ -623,7 +680,9 @@ async def test_partitioned_table_append_scan(connection,
admin, wait_for_table_r
while len(all_records) < 8 and time.monotonic() < deadline:
scan_records = await scanner.poll(5000)
for bucket, bucket_records in scan_records.items():
- assert bucket.partition_id is not None, "Partitioned table should
have partition_id"
+ assert bucket.partition_id is not None, (
+ "Partitioned table should have partition_id"
+ )
# All records in a bucket should belong to the same partition
regions = {r.row["region"] for r in bucket_records}
assert len(regions) == 1, f"Bucket has mixed regions: {regions}"
@@ -801,9 +860,13 @@ async def
test_scan_records_indexing_and_slicing(connection, admin):
writer = table.new_append().create_writer()
writer.write_arrow_batch(
pa.RecordBatch.from_arrays(
- [pa.array(list(range(1, 9)), type=pa.int32()),
- pa.array([f"v{i}" for i in range(1, 9)])],
- schema=pa.schema([pa.field("id", pa.int32()), pa.field("val",
pa.string())]),
+ [
+ pa.array(list(range(1, 9)), type=pa.int32()),
+ pa.array([f"v{i}" for i in range(1, 9)]),
+ ],
+ schema=pa.schema(
+ [pa.field("id", pa.int32()), pa.field("val", pa.string())]
+ ),
)
)
await writer.flush()
@@ -832,13 +895,13 @@ async def
test_scan_records_indexing_and_slicing(connection, admin):
# Verify slices match the same operation on the offsets reference list
test_slices = [
- slice(1, n - 1), # forward subrange
- slice(None, None, -1), # [::-1] full reverse
- slice(n - 2, 0, -1), # reverse with bounds
- slice(n - 1, 0, -2), # reverse with step
- slice(None, None, 2), # [::2]
- slice(1, None, 3), # [1::3]
- slice(2, 2), # empty
+ slice(1, n - 1), # forward subrange
+ slice(None, None, -1), # [::-1] full reverse
+ slice(n - 2, 0, -1), # reverse with bounds
+ slice(n - 1, 0, -2), # reverse with step
+ slice(None, None, 2), # [::2]
+ slice(1, None, 3), # [1::3]
+ slice(2, 2), # empty
]
for s in test_slices:
result = [r.offset for r in sr[s]]
@@ -863,13 +926,17 @@ async def test_async_iterator(connection, admin):
table = await connection.get_table(table_path)
writer = table.new_append().create_writer()
-
+
# Write 5 records
writer.write_arrow_batch(
pa.RecordBatch.from_arrays(
- [pa.array(list(range(1, 6)), type=pa.int32()),
- pa.array([f"async{i}" for i in range(1, 6)])],
- schema=pa.schema([pa.field("id", pa.int32()), pa.field("val",
pa.string())]),
+ [
+ pa.array(list(range(1, 6)), type=pa.int32()),
+ pa.array([f"async{i}" for i in range(1, 6)]),
+ ],
+ schema=pa.schema(
+ [pa.field("id", pa.int32()), pa.field("val", pa.string())]
+ ),
)
)
await writer.flush()
@@ -879,18 +946,18 @@ async def test_async_iterator(connection, admin):
scanner.subscribe_buckets({i: fluss.EARLIEST_OFFSET for i in
range(num_buckets)})
collected = []
-
+
# Here is the magical Issue #424 async iterator logic at work:
async def consume_scanner():
async for record in scanner:
collected.append(record)
if len(collected) == 5:
break
-
+
await consume_scanner()
-
+
assert len(collected) == 5, f"Expected 5 records, got {len(collected)}"
-
+
collected.sort(key=lambda r: r.row["id"])
for i, record in enumerate(collected):
assert record.row["id"] == i + 1
@@ -932,9 +999,7 @@ async def test_async_iterator_break_no_leak(connection,
admin):
scanner = await table.new_scan().create_log_scanner()
num_buckets = (await admin.get_table_info(table_path)).num_buckets
- scanner.subscribe_buckets(
- {i: fluss.EARLIEST_OFFSET for i in range(num_buckets)}
- )
+ scanner.subscribe_buckets({i: fluss.EARLIEST_OFFSET for i in
range(num_buckets)})
# Phase 1: async for with early break (collect only 3 of 10)
collected_async = []
@@ -987,12 +1052,8 @@ async def
test_async_iterator_multiple_batches(connection, admin):
schema = fluss.Schema(
pa.schema([pa.field("id", pa.int32()), pa.field("val", pa.string())])
)
- table_descriptor = fluss.TableDescriptor(
- schema, bucket_count=3, bucket_keys=["id"]
- )
- await admin.create_table(
- table_path, table_descriptor, ignore_if_exists=False
- )
+ table_descriptor = fluss.TableDescriptor(schema, bucket_count=3,
bucket_keys=["id"])
+ await admin.create_table(table_path, table_descriptor,
ignore_if_exists=False)
table = await connection.get_table(table_path)
writer = table.new_append().create_writer()
@@ -1013,9 +1074,7 @@ async def
test_async_iterator_multiple_batches(connection, admin):
scanner = await table.new_scan().create_log_scanner()
num_buckets = (await admin.get_table_info(table_path)).num_buckets
- scanner.subscribe_buckets(
- {i: fluss.EARLIEST_OFFSET for i in range(num_buckets)}
- )
+ scanner.subscribe_buckets({i: fluss.EARLIEST_OFFSET for i in
range(num_buckets)})
collected = []
@@ -1179,12 +1238,8 @@ async def
test_batch_async_iterator_multiple_batches(connection, admin):
schema = fluss.Schema(
pa.schema([pa.field("id", pa.int32()), pa.field("val", pa.string())])
)
- table_descriptor = fluss.TableDescriptor(
- schema, bucket_count=3, bucket_keys=["id"]
- )
- await admin.create_table(
- table_path, table_descriptor, ignore_if_exists=False
- )
+ table_descriptor = fluss.TableDescriptor(schema, bucket_count=3,
bucket_keys=["id"])
+ await admin.create_table(table_path, table_descriptor,
ignore_if_exists=False)
table = await connection.get_table(table_path)
writer = table.new_append().create_writer()
@@ -1342,8 +1397,6 @@ async def test_append_and_scan_with_array(connection,
admin):
]
-
-
async def test_append_rows_with_array(connection, admin):
"""Test appending rows with array data as Python lists and scanning."""
table_path = fluss.TablePath("fluss", "py_test_append_rows_with_array")
@@ -1368,7 +1421,7 @@ async def test_append_rows_with_array(connection, admin):
append_writer.append({"id": 2, "tags": ["c"], "scores": [30]})
# Append row using list with nested list (null handling)
append_writer.append([3, None, [40, None, 60]])
-
+
await append_writer.flush()
scanner = await table.new_scan().create_log_scanner()
@@ -1394,12 +1447,16 @@ async def
test_append_rows_with_nested_array(connection, admin):
table_path = fluss.TablePath("fluss",
"py_test_append_rows_with_nested_array")
await admin.drop_table(table_path, ignore_if_not_exists=True)
- pa_schema = pa.schema([
- pa.field("id", pa.int32()),
- pa.field("matrix", pa.list_(pa.list_(pa.int32()))),
- ])
+ pa_schema = pa.schema(
+ [
+ pa.field("id", pa.int32()),
+ pa.field("matrix", pa.list_(pa.list_(pa.int32()))),
+ ]
+ )
schema = fluss.Schema(pa_schema)
- await admin.create_table(table_path, fluss.TableDescriptor(schema),
ignore_if_exists=False)
+ await admin.create_table(
+ table_path, fluss.TableDescriptor(schema), ignore_if_exists=False
+ )
table = await connection.get_table(table_path)
append_writer = table.new_append().create_writer()
@@ -1410,7 +1467,7 @@ async def test_append_rows_with_nested_array(connection,
admin):
append_writer.append({"id": 3, "matrix": None})
append_writer.append({"id": 4, "matrix": [[1, None], None, []]})
append_writer.append({"id": 5, "matrix": [[None, None]]})
-
+
await append_writer.flush()
scanner = await table.new_scan().create_log_scanner()
@@ -1435,12 +1492,16 @@ async def
test_append_rows_with_invalid_array(connection, admin):
table_path = fluss.TablePath("fluss",
"py_test_append_rows_with_invalid_array")
await admin.drop_table(table_path, ignore_if_not_exists=True)
- pa_schema = pa.schema([
- pa.field("id", pa.int32()),
- pa.field("tags", pa.list_(pa.string())),
- ])
+ pa_schema = pa.schema(
+ [
+ pa.field("id", pa.int32()),
+ pa.field("tags", pa.list_(pa.string())),
+ ]
+ )
schema = fluss.Schema(pa_schema)
- await admin.create_table(table_path, fluss.TableDescriptor(schema),
ignore_if_exists=False)
+ await admin.create_table(
+ table_path, fluss.TableDescriptor(schema), ignore_if_exists=False
+ )
table = await connection.get_table(table_path)
append_writer = table.new_append().create_writer()
@@ -1448,5 +1509,119 @@ async def
test_append_rows_with_invalid_array(connection, admin):
# Appending a string instead of a list should raise an error
with pytest.raises(Exception, match="Expected sequence for Array column"):
append_writer.append({"id": 4, "tags": "not_a_list"})
-
+
+ await admin.drop_table(table_path, ignore_if_not_exists=False)
+
+
+async def test_all_complex_datatypes(connection, admin):
+ """Comprehensive ARRAY/MAP/ROW coverage on a log table.
+
+ Appends fully-populated, edge-case, and all-null rows (see
complex_types.py)
+ and verifies them through both the record (dict) scan path and the Arrow
+ scan path -- the two paths must agree. Mirrors the section-based
+ ``all_supported_datatypes`` structure of the Rust integration tests.
+ """
+ table_path = fluss.TablePath("fluss", "py_test_log_all_complex")
+ await admin.drop_table(table_path, ignore_if_not_exists=True)
+
+ schema = complex_schema()
+ await admin.create_table(
+ table_path, fluss.TableDescriptor(schema), ignore_if_exists=False
+ )
+
+ table = await connection.get_table(table_path)
+ append_writer = table.new_append().create_writer()
+ append_writer.append(complex_full_row(1))
+ append_writer.append(complex_edge_row(2))
+ append_writer.append(complex_null_row(3))
+ await append_writer.flush()
+
+ # Record (dict) scan path.
+ scanner = await table.new_scan().create_log_scanner()
+ scanner.subscribe_buckets({0: fluss.EARLIEST_OFFSET})
+ records = await _poll_records(scanner, expected_count=3)
+ assert len(records) == 3
+ poll_rows = sorted([r.row for r in records], key=lambda r: r["id"])
+ assert_complex_full(poll_rows[0])
+ assert_complex_edge(poll_rows[1])
+ for col in complex_column_names():
+ assert poll_rows[2][col] is None
+
+ # Arrow scan path: to_pylist() agrees with the dict path column-for-column,
+ # except NaN-bearing float columns (NaN != NaN), which are checked
elsewhere.
+ scanner2 = await table.new_scan().create_record_batch_log_scanner()
+ scanner2.subscribe_buckets({0: fluss.EARLIEST_OFFSET})
+ result_table = await scanner2.to_arrow()
+ assert result_table.num_rows == 3
+ arrow_rows = result_table.sort_by("id").to_pylist()
+ float_cols = {"arr_float", "arr_double", "map_float", "map_double"}
+ for i in range(3):
+ for col in complex_column_names():
+ if col in float_cols:
+ continue
+ assert arrow_rows[i][col] == poll_rows[i][col], (
+ f"scan-path mismatch at row {i}, column {col}"
+ )
+
+ await admin.drop_table(table_path, ignore_if_not_exists=False)
+
+
+async def test_append_arrow_batch_complex_types(connection, admin):
+ """Arrow write path: write MAP and ROW columns via write_arrow_batch and
+ verify through both the record and Arrow scan paths."""
+ table_path = fluss.TablePath("fluss", "py_test_arrow_batch_complex")
+ await admin.drop_table(table_path, ignore_if_not_exists=True)
+
+ row_type = pa.struct([("seq", pa.int32()), ("label", pa.string())])
+ map_type = pa.map_(pa.string(), pa.int32())
+ pa_schema = pa.schema(
+ [
+ pa.field("id", pa.int32()),
+ pa.field("attrs", map_type),
+ pa.field("nested", row_type),
+ ]
+ )
+ schema = fluss.Schema(pa_schema)
+ await admin.create_table(
+ table_path, fluss.TableDescriptor(schema), ignore_if_exists=False
+ )
+
+ table = await connection.get_table(table_path)
+ append_writer = table.new_append().create_writer()
+ batch = pa.RecordBatch.from_arrays(
+ [
+ pa.array([1, 2], type=pa.int32()),
+ pa.array([[("a", 1), ("b", 2)], []], type=map_type),
+ pa.array(
+ [{"seq": 10, "label": "open"}, {"seq": 20, "label": None}],
+ type=row_type,
+ ),
+ ],
+ schema=pa_schema,
+ )
+ append_writer.write_arrow_batch(batch)
+ await append_writer.flush()
+
+ # Record scan path.
+ scanner = await table.new_scan().create_log_scanner()
+ scanner.subscribe_buckets({0: fluss.EARLIEST_OFFSET})
+ records = await _poll_records(scanner, expected_count=2)
+ assert len(records) == 2
+ rows = sorted([r.row for r in records], key=lambda r: r["id"])
+ assert dict(rows[0]["attrs"]) == {"a": 1, "b": 2}
+ assert rows[0]["nested"] == {"seq": 10, "label": "open"}
+ assert rows[1]["attrs"] == []
+ assert rows[1]["nested"] == {"seq": 20, "label": None}
+
+ # Arrow scan path.
+ scanner2 = await table.new_scan().create_record_batch_log_scanner()
+ scanner2.subscribe_buckets({0: fluss.EARLIEST_OFFSET})
+ result_table = await scanner2.to_arrow()
+ assert result_table.column("id").to_pylist() == [1, 2]
+ assert result_table.column("attrs").to_pylist() == [[("a", 1), ("b", 2)],
[]]
+ assert result_table.column("nested").to_pylist() == [
+ {"seq": 10, "label": "open"},
+ {"seq": 20, "label": None},
+ ]
+
await admin.drop_table(table_path, ignore_if_not_exists=False)
diff --git a/bindings/python/test/test_schema.py
b/bindings/python/test/test_schema.py
index dfd9cf56..1b1f8c81 100644
--- a/bindings/python/test/test_schema.py
+++ b/bindings/python/test/test_schema.py
@@ -23,10 +23,12 @@ import fluss
def test_get_primary_keys():
- fields = pa.schema([
- pa.field("id", pa.int32()),
- pa.field("name", pa.string()),
- ])
+ fields = pa.schema(
+ [
+ pa.field("id", pa.int32()),
+ pa.field("name", pa.string()),
+ ]
+ )
schema_with_pk = fluss.Schema(fields, primary_keys=["id"])
assert schema_with_pk.get_primary_keys() == ["id"]
@@ -95,3 +97,54 @@ def test_nested_list_nullability():
assert types[2] == "array<int NOT NULL> NOT NULL"
+def test_schema_with_map():
+ # PyArrow models a map as Map(entries: struct<key, value>); Arrow map keys
+ # are always non-nullable, while the value is nullable by default.
+ fields = pa.schema(
+ [
+ pa.field("id", pa.int32()),
+ pa.field("attrs", pa.map_(pa.string(), pa.int32())),
+ ]
+ )
+ schema = fluss.Schema(fields)
+ assert schema.get_column_names() == ["id", "attrs"]
+ assert schema.get_column_types() == ["int", "map<string NOT NULL,int>"]
+
+
+def test_schema_with_row():
+ fields = pa.schema(
+ [
+ pa.field("id", pa.int32()),
+ pa.field(
+ "nested",
+ pa.struct([("seq", pa.int32()), ("label", pa.string())]),
+ ),
+ ]
+ )
+ schema = fluss.Schema(fields)
+ assert schema.get_column_names() == ["id", "nested"]
+ assert schema.get_column_types() == ["int", "row<seq: int, label: string>"]
+
+
+def test_schema_with_nested_complex_types():
+ fields = pa.schema(
+ [
+ # map<string, row<seq int, label string>>
+ pa.field(
+ "m_of_row",
+ pa.map_(
+ pa.string(),
+ pa.struct([("seq", pa.int32()), ("label", pa.string())]),
+ ),
+ ),
+ # array<map<string, int>>
+ pa.field("arr_of_map", pa.list_(pa.map_(pa.string(), pa.int32()))),
+ # row containing an array column
+ pa.field("row_with_arr", pa.struct([("ids",
pa.list_(pa.int32()))])),
+ ]
+ )
+ schema = fluss.Schema(fields)
+ types = schema.get_column_types()
+ assert types[0] == "map<string NOT NULL,row<seq: int, label: string>>"
+ assert types[1] == "array<map<string NOT NULL,int>>"
+ assert types[2] == "row<ids: array<int>>"
diff --git a/crates/fluss/src/record/arrow.rs b/crates/fluss/src/record/arrow.rs
index d2127cbc..5bd75924 100644
--- a/crates/fluss/src/record/arrow.rs
+++ b/crates/fluss/src/record/arrow.rs
@@ -1196,11 +1196,20 @@ pub fn to_arrow_type(fluss_type: &DataType) ->
Result<ArrowDataType> {
});
}
},
+ // TIMESTAMP_LTZ is an instant, so it carries the UTC zone. This keeps
+ // `to_arrow_type` symmetric with `from_arrow_type` (which treats a
+ // zoned Arrow timestamp as LTZ) so the type round-trips losslessly.
DataType::TimestampLTz(timestamp_ltz_type) => match
timestamp_ltz_type.precision() {
- 0 => ArrowDataType::Timestamp(arrow_schema::TimeUnit::Second,
None),
- 1..=3 =>
ArrowDataType::Timestamp(arrow_schema::TimeUnit::Millisecond, None),
- 4..=6 =>
ArrowDataType::Timestamp(arrow_schema::TimeUnit::Microsecond, None),
- 7..=9 =>
ArrowDataType::Timestamp(arrow_schema::TimeUnit::Nanosecond, None),
+ 0 => ArrowDataType::Timestamp(arrow_schema::TimeUnit::Second,
Some("UTC".into())),
+ 1..=3 => {
+ ArrowDataType::Timestamp(arrow_schema::TimeUnit::Millisecond,
Some("UTC".into()))
+ }
+ 4..=6 => {
+ ArrowDataType::Timestamp(arrow_schema::TimeUnit::Microsecond,
Some("UTC".into()))
+ }
+ 7..=9 => {
+ ArrowDataType::Timestamp(arrow_schema::TimeUnit::Nanosecond,
Some("UTC".into()))
+ }
invalid => {
return Err(Error::IllegalArgument {
message: format!(
@@ -1264,7 +1273,7 @@ pub fn to_arrow_type(fluss_type: &DataType) ->
Result<ArrowDataType> {
/// Like `from_arrow_type`, but also reads the Field's nullability —
/// Arrow stores it on the Field wrapper, not the leaf data type.
-pub(crate) fn from_arrow_field(field: &arrow_schema::Field) ->
Result<DataType> {
+pub fn from_arrow_field(field: &arrow_schema::Field) -> Result<DataType> {
let mut dt = from_arrow_type(field.data_type())?;
if !field.is_nullable() {
dt = dt.as_non_nullable();
@@ -1283,11 +1292,16 @@ pub(crate) fn from_arrow_type(arrow_type:
&ArrowDataType) -> Result<DataType> {
ArrowDataType::Int16 => DataTypes::smallint(),
ArrowDataType::Int32 => DataTypes::int(),
ArrowDataType::Int64 => DataTypes::bigint(),
+ // No unsigned types in Fluss; map to the signed type of the same
width.
+ ArrowDataType::UInt8 => DataTypes::tinyint(),
+ ArrowDataType::UInt16 => DataTypes::smallint(),
+ ArrowDataType::UInt32 => DataTypes::int(),
+ ArrowDataType::UInt64 => DataTypes::bigint(),
ArrowDataType::Float32 => DataTypes::float(),
ArrowDataType::Float64 => DataTypes::double(),
- ArrowDataType::Utf8 => DataTypes::string(),
- ArrowDataType::Binary => DataTypes::bytes(),
- ArrowDataType::Date32 => DataTypes::date(),
+ ArrowDataType::Utf8 | ArrowDataType::LargeUtf8 => DataTypes::string(),
+ ArrowDataType::Binary | ArrowDataType::LargeBinary =>
DataTypes::bytes(),
+ ArrowDataType::Date32 | ArrowDataType::Date64 => DataTypes::date(),
ArrowDataType::FixedSizeBinary(len) => {
if *len < 0 {
return Err(Error::IllegalArgument {
@@ -1784,19 +1798,19 @@ mod tests {
);
assert_eq!(
to_arrow_type(&DataTypes::timestamp_ltz_with_precision(0)).unwrap(),
- ArrowDataType::Timestamp(arrow_schema::TimeUnit::Second, None)
+ ArrowDataType::Timestamp(arrow_schema::TimeUnit::Second,
Some("UTC".into()))
);
assert_eq!(
to_arrow_type(&DataTypes::timestamp_ltz_with_precision(3)).unwrap(),
- ArrowDataType::Timestamp(arrow_schema::TimeUnit::Millisecond, None)
+ ArrowDataType::Timestamp(arrow_schema::TimeUnit::Millisecond,
Some("UTC".into()))
);
assert_eq!(
to_arrow_type(&DataTypes::timestamp_ltz_with_precision(6)).unwrap(),
- ArrowDataType::Timestamp(arrow_schema::TimeUnit::Microsecond, None)
+ ArrowDataType::Timestamp(arrow_schema::TimeUnit::Microsecond,
Some("UTC".into()))
);
assert_eq!(
to_arrow_type(&DataTypes::timestamp_ltz_with_precision(9)).unwrap(),
- ArrowDataType::Timestamp(arrow_schema::TimeUnit::Nanosecond, None)
+ ArrowDataType::Timestamp(arrow_schema::TimeUnit::Nanosecond,
Some("UTC".into()))
);
assert_eq!(
to_arrow_type(&DataTypes::bytes()).unwrap(),
@@ -1882,6 +1896,38 @@ mod tests {
}
}
+ #[test]
+ fn test_from_arrow_type_accepts_unsigned_large_and_date64() {
+ assert!(matches!(
+ from_arrow_type(&ArrowDataType::UInt8).unwrap(),
+ DataType::TinyInt(_)
+ ));
+ assert!(matches!(
+ from_arrow_type(&ArrowDataType::UInt16).unwrap(),
+ DataType::SmallInt(_)
+ ));
+ assert!(matches!(
+ from_arrow_type(&ArrowDataType::UInt32).unwrap(),
+ DataType::Int(_)
+ ));
+ assert!(matches!(
+ from_arrow_type(&ArrowDataType::UInt64).unwrap(),
+ DataType::BigInt(_)
+ ));
+ assert!(matches!(
+ from_arrow_type(&ArrowDataType::LargeUtf8).unwrap(),
+ DataType::String(_)
+ ));
+ assert!(matches!(
+ from_arrow_type(&ArrowDataType::LargeBinary).unwrap(),
+ DataType::Bytes(_)
+ ));
+ assert!(matches!(
+ from_arrow_type(&ArrowDataType::Date64).unwrap(),
+ DataType::Date(_)
+ ));
+ }
+
#[test]
fn test_parse_ipc_message() {
let empty_body: &[u8] = &le_bytes(&[0xFFFFFFFF, 0x00000000]);
diff --git a/crates/fluss/src/row/binary_array.rs
b/crates/fluss/src/row/binary_array.rs
index db15b082..3a43ea77 100644
--- a/crates/fluss/src/row/binary_array.rs
+++ b/crates/fluss/src/row/binary_array.rs
@@ -250,15 +250,24 @@ impl FlussArray {
let mut max_extent = fixed_part_size;
for i in 0..self.size {
- if !self.is_null_at(i) {
- let packed = self.read_i64(i, "extent calculation")? as u64;
- let mark = packed & HIGHEST_FIRST_BIT;
- if mark == 0 {
- let offset = (packed >> 32) as usize;
- let len = (packed & 0xFFFF_FFFF) as usize;
- max_extent = max_extent.max(offset + len);
- }
+ if self.is_null_at(i) {
+ continue;
}
+ let packed = self.read_i64(i, "extent calculation")? as u64;
+ let offset = (packed >> 32) as usize;
+ let var_len = match element_type {
+ // Non-compact timestamps pack `nanoOfMillisecond` in the low
word
+ // (not a byte length) and store a fixed 8-byte millisecond
value
+ // in the variable section (see `write_timestamp_ntz`).
+ DataType::Timestamp(_) | DataType::TimestampLTz(_) =>
round_to_nearest_word(8),
+ _ => {
+ if packed & HIGHEST_FIRST_BIT != 0 {
+ continue;
+ }
+ (packed & 0xFFFF_FFFF) as usize
+ }
+ };
+ max_extent = max_extent.max(offset + var_len);
}
Ok(round_to_nearest_word(max_extent))
diff --git a/crates/fluss/src/row/binary_map.rs
b/crates/fluss/src/row/binary_map.rs
index af6d93a2..9c657788 100644
--- a/crates/fluss/src/row/binary_map.rs
+++ b/crates/fluss/src/row/binary_map.rs
@@ -463,6 +463,7 @@ mod tests {
use super::*;
use crate::metadata::DataTypes;
use crate::row::binary_array::FlussArrayWriter;
+ use crate::row::datum::TimestampNtz;
#[test]
fn fluss_map_dispatches_through_internal_map_trait() {
@@ -511,6 +512,29 @@ mod tests {
assert_eq!(decoded_values.get_string(1).unwrap(), "b");
}
+ #[test]
+ fn test_round_trip_map_with_noncompact_timestamp_value() {
+ // Regression: a non-compact timestamp (precision > 3) packs
+ // nanoOfMillisecond in the low word, which `FlussArray::extent` (used
by
+ // map validation) must not treat as a byte length.
+ let key_type = DataTypes::string();
+ let value_type = DataTypes::timestamp_with_precision(6);
+ let ts = TimestampNtz::from_millis_nanos(1_769_163_227_123,
456_000).unwrap();
+
+ let mut writer = FlussMapWriter::new(2, &key_type, &value_type);
+ writer
+ .write_entry("a".into(), Datum::TimestampNtz(ts))
+ .unwrap();
+ writer.write_entry("b".into(), Datum::Null).unwrap();
+ let map = writer.complete().unwrap();
+
+ // Re-decode from the serialized bytes (the compacted / KV-lookup
path).
+ let decoded = FlussMap::from_bytes(map.as_bytes(), &key_type,
&value_type).unwrap();
+ assert_eq!(decoded.size(), 2);
+ assert_eq!(decoded.value_array().get_timestamp_ntz(0, 6).unwrap(), ts);
+ assert!(decoded.value_array().is_null_at(1));
+ }
+
#[test]
fn test_empty_map() {
let writer = FlussMapWriter::new(0, &DataTypes::int(),
&DataTypes::string());
diff --git a/crates/fluss/src/row/column_writer.rs
b/crates/fluss/src/row/column_writer.rs
index fffbc616..b47ce096 100644
--- a/crates/fluss/src/row/column_writer.rs
+++ b/crates/fluss/src/row/column_writer.rs
@@ -321,25 +321,29 @@ impl ColumnWriter {
ArrowDataType::Timestamp(arrow_schema::TimeUnit::Second,
_) => {
TypedWriter::TimestampLtzSecond {
precision,
- builder:
TimestampSecondBuilder::with_capacity(capacity),
+ builder:
TimestampSecondBuilder::with_capacity(capacity)
+ .with_timezone("UTC"),
}
}
ArrowDataType::Timestamp(arrow_schema::TimeUnit::Millisecond, _) => {
TypedWriter::TimestampLtzMillisecond {
precision,
- builder:
TimestampMillisecondBuilder::with_capacity(capacity),
+ builder:
TimestampMillisecondBuilder::with_capacity(capacity)
+ .with_timezone("UTC"),
}
}
ArrowDataType::Timestamp(arrow_schema::TimeUnit::Microsecond, _) => {
TypedWriter::TimestampLtzMicrosecond {
precision,
- builder:
TimestampMicrosecondBuilder::with_capacity(capacity),
+ builder:
TimestampMicrosecondBuilder::with_capacity(capacity)
+ .with_timezone("UTC"),
}
}
ArrowDataType::Timestamp(arrow_schema::TimeUnit::Nanosecond, _) => {
TypedWriter::TimestampLtzNanosecond {
precision,
- builder:
TimestampNanosecondBuilder::with_capacity(capacity),
+ builder:
TimestampNanosecondBuilder::with_capacity(capacity)
+ .with_timezone("UTC"),
}
}
_ => {
diff --git a/website/docs/user-guide/python/data-types.md
b/website/docs/user-guide/python/data-types.md
index 8e4371e2..bde4e867 100644
--- a/website/docs/user-guide/python/data-types.md
+++ b/website/docs/user-guide/python/data-types.md
@@ -19,6 +19,8 @@ The Python client uses PyArrow types for schema definitions:
| `pa.timestamp("us", tz="UTC")` | TimestampLTZ
| `datetime.datetime` |
| `pa.decimal128(precision, scale)` | Decimal
| `decimal.Decimal` |
| `pa.list_(type)` | Array
| `list` |
+| `pa.map_(key_type, value_type)` | Map
| `list[(key, value)]`|
+| `pa.struct([(name, type), ...])` | Row
| `dict` |
All Python native types (`date`, `time`, `datetime`, `Decimal`) work when
appending rows via dicts.
@@ -71,6 +73,11 @@ row = {
handle = writer.append(row)
```
+When a row is written as a **dict**, a nullable column may be omitted — it
+defaults to `null`. A non-nullable column (including primary keys) must be
+present, otherwise the write is rejected with a clear error. The same rule
+applies to the fields of a `ROW` value.
+
Lists and tuples must have values in column order:
```python
@@ -93,3 +100,82 @@ for record in records:
if row["nickname"] is None:
print("nickname is null")
```
+
+## Complex Types (Array, Map, Row)
+
+`ARRAY`, `MAP`, and `ROW` columns can be nested arbitrarily (for example
+`array<map<string, row<...>>>`). On read they materialize to native Python
+objects; on write they accept the shapes below:
+
+| Fluss type | Read-back value | Write input accepted
|
+|-------------|--------------------------------|---------------------------------------------|
+| `ARRAY<T>` | `list` | `list` / `tuple`
|
+| `MAP<K, V>` | `list` of `(key, value)` tuples| `dict`, or a sequence of
`(key, value)` pairs |
+| `ROW<...>` | `dict` keyed by field name | `dict` (by name) or
`list`/`tuple` (by position) |
+
+The MAP read shape matches pyarrow's `MapArray.to_pylist()` (it preserves
+duplicate keys and ordering); ROW matches `StructArray.to_pylist()`.
+
+### Arrays
+
+```python
+schema = pa.schema([
+ pa.field("id", pa.int32()),
+ pa.field("tags", pa.list_(pa.string())),
+ pa.field("matrix", pa.list_(pa.list_(pa.int32()))), # nested
+])
+writer.append({"id": 1, "tags": ["a", "b"], "matrix": [[1, 2], [3, 4]]})
+
+row = await lookuper.lookup({"id": 1})
+row["tags"] # ["a", "b"]
+row["matrix"] # [[1, 2], [3, 4]]
+```
+
+### Maps
+
+Use `pa.map_(key_type, value_type)`. Write a `dict` or a list of
+`(key, value)` pairs; reads return a list of `(key, value)` tuples (wrap with
+`dict(...)` for keyed access). Map keys must be non-null.
+
+```python
+schema = pa.schema([
+ pa.field("id", pa.int32()),
+ pa.field("attrs", pa.map_(pa.string(), pa.int32())),
+])
+writer.append({"id": 1, "attrs": {"a": 1, "b": None}}) # dict input
+# or a sequence of pairs: {"id": 2, "attrs": [("a", 1), ("b", None)]}
+
+row = await lookuper.lookup({"id": 1})
+row["attrs"] # [("a", 1), ("b", None)]
+dict(row["attrs"]) # {"a": 1, "b": None}
+```
+
+### Rows
+
+Use `pa.struct([...])`. Write a `dict` keyed by field name (or a `list`/`tuple`
+in field order); reads return a `dict`.
+
+```python
+schema = pa.schema([
+ pa.field("id", pa.int32()),
+ pa.field("profile", pa.struct([("age", pa.int32()), ("city",
pa.string())])),
+])
+writer.append({"id": 1, "profile": {"age": 30, "city": "NYC"}})
+
+row = await lookuper.lookup({"id": 1})
+row["profile"] # {"age": 30, "city": "NYC"}
+```
+
+### Constraints
+
+`ARRAY`, `MAP`, and `ROW` may be used as row values and nested inside one
+another, but not as primary-key or bucket-key columns — the server rejects
+complex key types.
+
+### Bulk (Arrow) reads
+
+The per-row paths above (`append`/`upsert` and the record-based scanner's
+`record.row` dict, point `lookup`) materialize each value into a Python object.
+For high-throughput scans, prefer the Arrow path — a record-batch scanner's
+`to_arrow()` / `poll_arrow()` returns nested columns as native pyarrow
+`ListArray` / `MapArray` / `StructArray` with no per-element conversion.