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 860be9e4 [c++] Add nullability support for data type in c++ (#525)
860be9e4 is described below
commit 860be9e4877a4714489757904c7544315bfb2d7b
Author: Kaiqi Dong <[email protected]>
AuthorDate: Fri May 8 13:52:45 2026 +0200
[c++] Add nullability support for data type in c++ (#525)
* Add nullability support for data type in c++
* add comment to explain nullability setup
* split scalar and array
* rephrase since behavriour change fater this PR
* rephrase since behavriour change fater this PR
* format
---
bindings/cpp/include/fluss.hpp | 13 +-
bindings/cpp/src/ffi_converter.hpp | 56 +++++--
bindings/cpp/src/lib.rs | 7 +-
bindings/cpp/src/types.rs | 240 +++++++++++++++++----------
bindings/cpp/test/test_ffi_converter.cpp | 178 +++++++++++++++++++-
bindings/cpp/test/test_log_table.cpp | 63 +++++++
website/docs/user-guide/cpp/api-reference.md | 2 +
website/docs/user-guide/cpp/data-types.md | 34 ++++
8 files changed, 488 insertions(+), 105 deletions(-)
diff --git a/bindings/cpp/include/fluss.hpp b/bindings/cpp/include/fluss.hpp
index f222166d..3d39783f 100644
--- a/bindings/cpp/include/fluss.hpp
+++ b/bindings/cpp/include/fluss.hpp
@@ -283,8 +283,8 @@ enum class TypeId {
class DataType {
public:
- explicit DataType(TypeId id, int32_t p = 0, int32_t s = 0)
- : id_(id), precision_(p), scale_(s) {}
+ explicit DataType(TypeId id, int32_t p = 0, int32_t s = 0, bool nullable =
true)
+ : id_(id), precision_(p), scale_(s), nullable_(nullable) {}
static DataType Boolean() { return DataType(TypeId::Boolean); }
static DataType TinyInt() { return DataType(TypeId::TinyInt); }
@@ -321,15 +321,24 @@ class DataType {
TypeId id() const { return id_; }
int32_t precision() const { return precision_; }
int32_t scale() const { return scale_; }
+ bool nullable() const { return nullable_; }
/// Returns the element type of an ARRAY. Returns `nullptr` for non-array
/// types. The returned pointer is valid as long as this DataType (or a
/// copy holding the same shared element) is alive.
const DataType* element_type() const { return element_type_.get(); }
+ /// Returns a copy of this DataType with nullable set to false.
+ DataType NotNull() const {
+ DataType dt(id_, precision_, scale_, false);
+ dt.element_type_ = element_type_;
+ return dt;
+ }
+
private:
TypeId id_;
int32_t precision_{0};
int32_t scale_{0};
+ bool nullable_{true};
std::shared_ptr<DataType> element_type_;
};
diff --git a/bindings/cpp/src/ffi_converter.hpp
b/bindings/cpp/src/ffi_converter.hpp
index 0ef1487a..430dd199 100644
--- a/bindings/cpp/src/ffi_converter.hpp
+++ b/bindings/cpp/src/ffi_converter.hpp
@@ -33,16 +33,18 @@ namespace utils {
/// `nesting` counts the number of ARRAY wrappers stripped to reach the leaf
/// element type. `leaf_type`/`leaf_precision`/`leaf_scale` describe that leaf
/// scalar. A non-array input produces a zero-initialised value (nesting == 0).
+/// `array_nullability` has `nesting + 1` entries: one per ARRAY wrapper
+/// (outermost first) plus a trailing entry for the leaf scalar's nullability.
///
/// Using a flat representation — rather than serialising a recursive
-/// `DataType` — keeps the cxx bridge contract small (four `i32`s inside
-/// `FfiColumn`) while preserving full schema fidelity across the FFI boundary
-/// when paired with rebuild_array_type().
+/// `DataType` — keeps the cxx bridge contract small while preserving schema
+/// fidelity across the FFI boundary when paired with rebuild_array_type().
struct FlattenedArrayType {
int32_t nesting{0};
int32_t leaf_type{0};
int32_t leaf_precision{0};
int32_t leaf_scale{0};
+ std::vector<uint8_t> array_nullability;
};
/// Flattens an `ARRAY<ARRAY<...<leaf>>>` DataType into a FlattenedArrayType.
@@ -53,7 +55,8 @@ struct FlattenedArrayType {
/// - If `data_type` is an ARRAY but has a null element_type() chain (which
/// should only happen on malformed input), returns a zero-valued result to
/// signal the caller to reject the schema.
-/// - Otherwise, `nesting >= 1` and leaf_* describe the innermost scalar.
+/// - Otherwise, `nesting >= 1`, array_nullability has `nesting + 1` entries
+/// (last = leaf scalar nullability), and leaf_* describe the innermost
scalar.
inline FlattenedArrayType flatten_array_type(const DataType& data_type) {
FlattenedArrayType out;
if (data_type.id() != TypeId::Array) {
@@ -63,6 +66,7 @@ inline FlattenedArrayType flatten_array_type(const DataType&
data_type) {
const DataType* current = &data_type;
while (current && current->id() == TypeId::Array) {
out.nesting += 1;
+ out.array_nullability.push_back(current->nullable() ? 1 : 0);
current = current->element_type();
}
if (!current) {
@@ -72,16 +76,29 @@ inline FlattenedArrayType flatten_array_type(const
DataType& data_type) {
out.leaf_type = static_cast<int32_t>(current->id());
out.leaf_precision = current->precision();
out.leaf_scale = current->scale();
+ out.array_nullability.push_back(current->nullable() ? 1 : 0);
return out;
}
/// Inverse of flatten_array_type: rebuilds an `ARRAY<ARRAY<...<leaf>>>` type
/// from the compact flat form. Requires `flat.nesting >= 1`; callers handle
/// the `nesting == 0` case by using a plain scalar DataType directly.
+/// `array_nullability` must have `nesting + 1` entries (last = leaf).
inline DataType rebuild_array_type(const FlattenedArrayType& flat) {
- DataType dt(static_cast<TypeId>(flat.leaf_type), flat.leaf_precision,
flat.leaf_scale);
- for (int32_t i = 0; i < flat.nesting; ++i) {
- dt = DataType::Array(std::move(dt));
+ bool leaf_nullable = (static_cast<size_t>(flat.nesting) <
flat.array_nullability.size())
+ ?
(flat.array_nullability[static_cast<size_t>(flat.nesting)] != 0)
+ : true;
+ DataType dt(static_cast<TypeId>(flat.leaf_type), flat.leaf_precision,
flat.leaf_scale,
+ leaf_nullable);
+ for (int32_t i = flat.nesting - 1; i >= 0; --i) {
+ bool nullable = (static_cast<size_t>(i) <
flat.array_nullability.size())
+ ? (flat.array_nullability[static_cast<size_t>(i)]
!= 0)
+ : true;
+ auto arr = DataType::Array(std::move(dt));
+ if (!nullable) {
+ arr = arr.NotNull();
+ }
+ dt = std::move(arr);
}
return dt;
}
@@ -150,11 +167,15 @@ inline ffi::FfiColumn to_ffi_column(const Column& col) {
ffi::FfiColumn ffi_col;
ffi_col.name = rust::String(col.name);
ffi_col.data_type = static_cast<int32_t>(col.data_type.id());
+ ffi_col.nullable = col.data_type.nullable();
ffi_col.comment = rust::String(col.comment);
ffi_col.precision = col.data_type.precision();
ffi_col.scale = col.data_type.scale();
auto flat = flatten_array_type(col.data_type);
ffi_col.array_nesting = flat.nesting;
+ for (auto nullable : flat.array_nullability) {
+ ffi_col.array_nullability.push_back(nullable);
+ }
if (flat.nesting > 0 && flat.leaf_type != 0) {
ffi_col.element_data_type = flat.leaf_type;
ffi_col.element_precision = flat.leaf_precision;
@@ -229,7 +250,6 @@ inline ffi::FfiTableDescriptor
to_ffi_table_descriptor(const TableDescriptor& de
inline Column from_ffi_column(const ffi::FfiColumn& ffi_col) {
auto type_id = static_cast<TypeId>(ffi_col.data_type);
- DataType dt(type_id, ffi_col.precision, ffi_col.scale);
if (type_id == TypeId::Array) {
if (ffi_col.element_data_type == 0) {
throw std::runtime_error("Malformed ARRAY column '" +
std::string(ffi_col.name) +
@@ -273,13 +293,21 @@ inline Column from_ffi_column(const ffi::FfiColumn&
ffi_col) {
}
int32_t nesting = ffi_col.array_nesting > 0 ? ffi_col.array_nesting :
1;
- dt = rebuild_array_type(FlattenedArrayType{
- nesting,
- ffi_col.element_data_type,
- ffi_col.element_precision,
- ffi_col.element_scale,
- });
+ std::vector<uint8_t> array_nullability;
+ for (auto nullable : ffi_col.array_nullability) {
+ array_nullability.push_back(nullable);
+ }
+ auto dt = rebuild_array_type(
+ FlattenedArrayType{
+ nesting,
+ ffi_col.element_data_type,
+ ffi_col.element_precision,
+ ffi_col.element_scale,
+ std::move(array_nullability),
+ });
+ return Column{std::string(ffi_col.name), std::move(dt),
std::string(ffi_col.comment)};
}
+ DataType dt(type_id, ffi_col.precision, ffi_col.scale, ffi_col.nullable);
return Column{std::string(ffi_col.name), std::move(dt),
std::string(ffi_col.comment)};
}
diff --git a/bindings/cpp/src/lib.rs b/bindings/cpp/src/lib.rs
index 875373bc..52df3686 100644
--- a/bindings/cpp/src/lib.rs
+++ b/bindings/cpp/src/lib.rs
@@ -85,10 +85,12 @@ mod ffi {
struct FfiColumn {
name: String,
data_type: i32,
+ nullable: bool,
comment: String,
precision: i32,
scale: i32,
array_nesting: i32,
+ array_nullability: Vec<u8>,
element_data_type: i32,
element_precision: i32,
element_scale: i32,
@@ -3612,8 +3614,9 @@ impl ArrayWriterInner {
/// Structural type equivalence that ignores nullability flags but preserves
/// variant and precision/scale semantics. Used to compare ArrayWriter element
-/// types on the binding boundary, where C++ callers never control nullability
-/// explicitly.
+/// types on the binding boundary. Nullability is ignored in structural
comparison
+/// because the Rust-side element type is always reconstructed as nullable
+/// (encoding doesn't depend on it).
fn structurally_compatible(a: &fcore::metadata::DataType, b:
&fcore::metadata::DataType) -> bool {
use fcore::metadata::DataType;
match (a, b) {
diff --git a/bindings/cpp/src/types.rs b/bindings/cpp/src/types.rs
index c15aadc9..f61711b5 100644
--- a/bindings/cpp/src/types.rs
+++ b/bindings/cpp/src/types.rs
@@ -41,16 +41,43 @@ pub const DATA_TYPE_CHAR: i32 = 15;
pub const DATA_TYPE_BINARY: i32 = 16;
pub const DATA_TYPE_ARRAY: i32 = 17;
+/// Separates scalar and array type specs so each variant only carries
+/// the fields it actually needs — no zeroed-out placeholders.
+enum FfiDataTypeSpec {
+ Scalar {
+ data_type: i32,
+ precision: u32,
+ scale: u32,
+ nullable: bool,
+ },
+ Array {
+ element_data_type: i32,
+ element_precision: u32,
+ element_scale: u32,
+ array_nesting: u32,
+ /// `nesting` entries for each ARRAY wrapper (outermost first) plus
+ /// one trailing entry for the leaf scalar. Length = `nesting + 1`.
+ array_nullability: Vec<u8>,
+ },
+}
+
fn ffi_column_to_core_data_type(col: &ffi::FfiColumn) ->
Result<fcore::metadata::DataType> {
- ffi_data_type_to_core(
- col.data_type,
- col.precision as u32,
- col.scale as u32,
- col.element_data_type,
- col.element_precision as u32,
- col.element_scale as u32,
- col.array_nesting.max(0) as u32,
- )
+ if col.data_type == DATA_TYPE_ARRAY {
+ ffi_data_type_to_core(FfiDataTypeSpec::Array {
+ element_data_type: col.element_data_type,
+ element_precision: col.element_precision as u32,
+ element_scale: col.element_scale as u32,
+ array_nesting: col.array_nesting.max(0) as u32,
+ array_nullability: col.array_nullability.clone(),
+ })
+ } else {
+ ffi_data_type_to_core(FfiDataTypeSpec::Scalar {
+ data_type: col.data_type,
+ precision: col.precision as u32,
+ scale: col.scale as u32,
+ nullable: col.nullable,
+ })
+ }
}
fn type_precision_scale(dt: &fcore::metadata::DataType) -> (i32, i32) {
@@ -64,11 +91,23 @@ fn type_precision_scale(dt: &fcore::metadata::DataType) ->
(i32, i32) {
}
}
-fn flatten_array_leaf_type(dt: &fcore::metadata::DataType) -> Result<(i32,
i32, i32, i32)> {
+struct FlattenedLeafType {
+ nesting: i32,
+ leaf_type: i32,
+ leaf_precision: i32,
+ leaf_scale: i32,
+ /// `nesting` entries for ARRAY wrappers (outermost first) plus one
+ /// trailing entry for the leaf scalar. Length = `nesting + 1`.
+ array_nullability: Vec<u8>,
+}
+
+fn flatten_array_leaf_type(dt: &fcore::metadata::DataType) ->
Result<FlattenedLeafType> {
let mut nesting = 0_i32;
let mut leaf = dt;
+ let mut array_nullability = Vec::new();
while let fcore::metadata::DataType::Array(at) = leaf {
nesting += 1;
+ array_nullability.push(u8::from(leaf.is_nullable()));
leaf = at.get_element_type();
}
if nesting == 0 {
@@ -80,85 +119,102 @@ fn flatten_array_leaf_type(dt:
&fcore::metadata::DataType) -> Result<(i32, i32,
"Unsupported ARRAY leaf type for C++ bindings: {leaf}"
));
}
+ array_nullability.push(u8::from(leaf.is_nullable()));
let (leaf_precision, leaf_scale) = type_precision_scale(leaf);
- Ok((nesting, leaf_type, leaf_precision, leaf_scale))
+ Ok(FlattenedLeafType {
+ nesting,
+ leaf_type,
+ leaf_precision,
+ leaf_scale,
+ array_nullability,
+ })
}
fn build_array_type_from_leaf(
- leaf_dt: i32,
- leaf_precision: u32,
- leaf_scale: u32,
- nesting: u32,
+ element_data_type: i32,
+ element_precision: u32,
+ element_scale: u32,
+ array_nesting: u32,
+ array_nullability: &[u8],
) -> Result<fcore::metadata::DataType> {
- if nesting == 0 {
+ if array_nesting == 0 {
return Err(anyhow!("ARRAY nesting must be >= 1"));
}
- let mut dt = ffi_data_type_to_core(leaf_dt, leaf_precision, leaf_scale, 0,
0, 0, 0)?;
- for _ in 0..nesting {
- dt = fcore::metadata::DataTypes::array(dt);
+ let leaf_nullable = array_nullability
+ .get(array_nesting as usize)
+ .map(|v| *v != 0)
+ .unwrap_or(true);
+ let mut dt = ffi_data_type_to_core(FfiDataTypeSpec::Scalar {
+ data_type: element_data_type,
+ precision: element_precision,
+ scale: element_scale,
+ nullable: leaf_nullable,
+ })?;
+ for i in (0..array_nesting).rev() {
+ let nullable = array_nullability
+ .get(i as usize)
+ .map(|v| *v != 0)
+ .unwrap_or(true);
+ dt =
fcore::metadata::DataType::Array(fcore::metadata::ArrayType::with_nullable(
+ nullable, dt,
+ ));
}
Ok(dt)
}
-fn ffi_data_type_to_core(
- dt: i32,
- precision: u32,
- scale: u32,
- element_dt: i32,
- element_precision: u32,
- element_scale: u32,
- array_nesting: u32,
-) -> Result<fcore::metadata::DataType> {
- match dt {
- DATA_TYPE_BOOLEAN => Ok(fcore::metadata::DataTypes::boolean()),
- DATA_TYPE_TINYINT => Ok(fcore::metadata::DataTypes::tinyint()),
- DATA_TYPE_SMALLINT => Ok(fcore::metadata::DataTypes::smallint()),
- DATA_TYPE_INT => Ok(fcore::metadata::DataTypes::int()),
- DATA_TYPE_BIGINT => Ok(fcore::metadata::DataTypes::bigint()),
- DATA_TYPE_FLOAT => Ok(fcore::metadata::DataTypes::float()),
- DATA_TYPE_DOUBLE => Ok(fcore::metadata::DataTypes::double()),
- DATA_TYPE_STRING => Ok(fcore::metadata::DataTypes::string()),
- DATA_TYPE_BYTES => Ok(fcore::metadata::DataTypes::bytes()),
- DATA_TYPE_DATE => Ok(fcore::metadata::DataTypes::date()),
- DATA_TYPE_TIME => Ok(fcore::metadata::DataTypes::time()),
- DATA_TYPE_TIMESTAMP =>
Ok(fcore::metadata::DataTypes::timestamp_with_precision(
- precision,
- )),
- DATA_TYPE_TIMESTAMP_LTZ =>
Ok(fcore::metadata::DataTypes::timestamp_ltz_with_precision(
+fn ffi_data_type_to_core(spec: FfiDataTypeSpec) ->
Result<fcore::metadata::DataType> {
+ match spec {
+ FfiDataTypeSpec::Scalar {
+ data_type,
precision,
- )),
- DATA_TYPE_DECIMAL => {
- let dt = fcore::metadata::DecimalType::new(precision, scale)?;
- Ok(fcore::metadata::DataType::Decimal(dt))
- }
- DATA_TYPE_CHAR => Ok(fcore::metadata::DataTypes::char(precision)),
- DATA_TYPE_BINARY => Ok(fcore::metadata::DataTypes::binary(precision as
usize)),
- DATA_TYPE_ARRAY => {
- if array_nesting > 0 {
- build_array_type_from_leaf(
- element_dt,
- element_precision,
- element_scale,
- array_nesting,
- )
- } else {
- // Backward compatibility for older one-level metadata.
- if element_dt == 0 {
- return Err(anyhow!("ARRAY requires element type
metadata"));
+ scale,
+ nullable,
+ } => {
+ let dt = match data_type {
+ DATA_TYPE_BOOLEAN => fcore::metadata::DataTypes::boolean(),
+ DATA_TYPE_TINYINT => fcore::metadata::DataTypes::tinyint(),
+ DATA_TYPE_SMALLINT => fcore::metadata::DataTypes::smallint(),
+ DATA_TYPE_INT => fcore::metadata::DataTypes::int(),
+ DATA_TYPE_BIGINT => fcore::metadata::DataTypes::bigint(),
+ DATA_TYPE_FLOAT => fcore::metadata::DataTypes::float(),
+ DATA_TYPE_DOUBLE => fcore::metadata::DataTypes::double(),
+ DATA_TYPE_STRING => fcore::metadata::DataTypes::string(),
+ DATA_TYPE_BYTES => fcore::metadata::DataTypes::bytes(),
+ DATA_TYPE_DATE => fcore::metadata::DataTypes::date(),
+ DATA_TYPE_TIME => fcore::metadata::DataTypes::time(),
+ DATA_TYPE_TIMESTAMP => {
+
fcore::metadata::DataTypes::timestamp_with_precision(precision)
+ }
+ DATA_TYPE_TIMESTAMP_LTZ => {
+
fcore::metadata::DataTypes::timestamp_ltz_with_precision(precision)
+ }
+ DATA_TYPE_DECIMAL => {
+ let dt = fcore::metadata::DecimalType::new(precision,
scale)?;
+ fcore::metadata::DataType::Decimal(dt)
}
- let element_type = ffi_data_type_to_core(
- element_dt,
- element_precision,
- element_scale,
- 0,
- 0,
- 0,
- 0,
- )?;
- Ok(fcore::metadata::DataTypes::array(element_type))
+ DATA_TYPE_CHAR => fcore::metadata::DataTypes::char(precision),
+ DATA_TYPE_BINARY =>
fcore::metadata::DataTypes::binary(precision as usize),
+ _ => return Err(anyhow!("Unknown data type: {}", data_type)),
+ };
+ if nullable {
+ Ok(dt)
+ } else {
+ Ok(dt.as_non_nullable())
}
}
- _ => Err(anyhow!("Unknown data type: {dt}")),
+ FfiDataTypeSpec::Array {
+ element_data_type,
+ element_precision,
+ element_scale,
+ array_nesting,
+ ref array_nullability,
+ } => build_array_type_from_leaf(
+ element_data_type,
+ element_precision,
+ element_scale,
+ array_nesting,
+ array_nullability,
+ ),
}
}
@@ -188,24 +244,25 @@ pub fn core_data_type_to_ffi(dt:
&fcore::metadata::DataType) -> i32 {
fn core_column_to_ffi(col: &fcore::metadata::Column) -> ffi::FfiColumn {
let (precision, scale) = type_precision_scale(col.data_type());
- let (array_nesting, element_data_type, element_precision, element_scale) =
match col.data_type()
- {
- fcore::metadata::DataType::Array(_) => {
- flatten_array_leaf_type(col.data_type()).unwrap_or((0, 0, 0, 0))
- }
- _ => (0, 0, 0, 0),
+ let flat = match col.data_type() {
+ fcore::metadata::DataType::Array(_) =>
flatten_array_leaf_type(col.data_type()).ok(),
+ _ => None,
};
ffi::FfiColumn {
name: col.name().to_string(),
data_type: core_data_type_to_ffi(col.data_type()),
+ nullable: col.data_type().is_nullable(),
comment: col.comment().unwrap_or("").to_string(),
precision,
scale,
- array_nesting,
- element_data_type,
- element_precision,
- element_scale,
+ array_nesting: flat.as_ref().map_or(0, |f| f.nesting),
+ array_nullability: flat
+ .as_ref()
+ .map_or_else(Vec::new, |f| f.array_nullability.clone()),
+ element_data_type: flat.as_ref().map_or(0, |f| f.leaf_type),
+ element_precision: flat.as_ref().map_or(0, |f| f.leaf_precision),
+ element_scale: flat.as_ref().map_or(0, |f| f.leaf_scale),
}
}
@@ -347,6 +404,11 @@ pub fn empty_table_info() -> ffi::FfiTableInfo {
/// Convert element type tag + precision/scale to core DataType.
/// Used by ArrayWriterInner construction from C++.
+///
+/// Nullability is hardcoded to `true` (the default) because `ArrayWriter`
+/// only needs the type for encoding — the binary array format does not
+/// vary based on nullability. Nullability is a schema-level constraint
+/// enforced elsewhere (column definition, primary key normalization).
pub fn element_type_from_ffi(
leaf_dt: i32,
precision: u32,
@@ -354,9 +416,15 @@ pub fn element_type_from_ffi(
array_nesting: u32,
) -> Result<fcore::metadata::DataType> {
if array_nesting == 0 {
- ffi_data_type_to_core(leaf_dt, precision, scale, 0, 0, 0, 0)
+ ffi_data_type_to_core(FfiDataTypeSpec::Scalar {
+ data_type: leaf_dt,
+ precision,
+ scale,
+ nullable: true,
+ })
} else {
- build_array_type_from_leaf(leaf_dt, precision, scale, array_nesting)
+ let array_nullability = vec![1u8; (array_nesting + 1) as usize];
+ build_array_type_from_leaf(leaf_dt, precision, scale, array_nesting,
&array_nullability)
}
}
diff --git a/bindings/cpp/test/test_ffi_converter.cpp
b/bindings/cpp/test/test_ffi_converter.cpp
index 4bbe3ebb..2078bdab 100644
--- a/bindings/cpp/test/test_ffi_converter.cpp
+++ b/bindings/cpp/test/test_ffi_converter.cpp
@@ -24,20 +24,50 @@
namespace {
-fluss::ffi::FfiColumn MakeArrayColumn(int32_t nesting, int32_t element_type) {
+fluss::ffi::FfiColumn MakeArrayColumn(int32_t nesting, int32_t element_type,
+ bool nullable = true, bool leaf_nullable
= true,
+ std::vector<uint8_t>
per_level_nullability = {}) {
fluss::ffi::FfiColumn col;
col.name = rust::String("bad_array");
col.data_type = static_cast<int32_t>(fluss::TypeId::Array);
+ col.nullable = nullable;
col.comment = rust::String("");
col.precision = 0;
col.scale = 0;
col.array_nesting = nesting;
+ if (!per_level_nullability.empty()) {
+ for (auto v : per_level_nullability) {
+ col.array_nullability.push_back(v);
+ }
+ } else {
+ for (int32_t i = 0; i < nesting; ++i) {
+ col.array_nullability.push_back((i == 0 ? nullable : true) ? 1 :
0);
+ }
+ col.array_nullability.push_back(leaf_nullable ? 1 : 0);
+ }
col.element_data_type = element_type;
col.element_precision = 0;
col.element_scale = 0;
return col;
}
+fluss::ffi::FfiColumn MakeScalarColumn(const char* name, fluss::TypeId type_id,
+ bool nullable = true, int32_t precision
= 0,
+ int32_t scale = 0) {
+ fluss::ffi::FfiColumn col;
+ col.name = rust::String(name);
+ col.data_type = static_cast<int32_t>(type_id);
+ col.nullable = nullable;
+ col.comment = rust::String("");
+ col.precision = precision;
+ col.scale = scale;
+ col.array_nesting = 0;
+ col.element_data_type = 0;
+ col.element_precision = 0;
+ col.element_scale = 0;
+ return col;
+}
+
} // namespace
TEST(FfiConverterTest, RejectsArrayWithoutElementType) {
@@ -62,3 +92,149 @@ TEST(FfiConverterTest, SupportsLegacyOneLevelArrayMetadata)
{
ASSERT_NE(converted.data_type.element_type(), nullptr);
EXPECT_EQ(converted.data_type.element_type()->id(), fluss::TypeId::Int);
}
+
+// --- Nullability tests ---
+
+TEST(DataTypeTest, DefaultNullable) {
+ auto dt = fluss::DataType::Int();
+ EXPECT_TRUE(dt.nullable());
+}
+
+TEST(DataTypeTest, NotNullMethod) {
+ auto dt = fluss::DataType::Int().NotNull();
+ EXPECT_FALSE(dt.nullable());
+ EXPECT_EQ(dt.id(), fluss::TypeId::Int);
+}
+
+TEST(DataTypeTest, NotNullPreservesPrecisionScale) {
+ auto dt = fluss::DataType::Decimal(10, 2).NotNull();
+ EXPECT_FALSE(dt.nullable());
+ EXPECT_EQ(dt.precision(), 10);
+ EXPECT_EQ(dt.scale(), 2);
+}
+
+TEST(DataTypeTest, ArrayElementNullability) {
+ auto dt = fluss::DataType::Array(fluss::DataType::Int().NotNull());
+ EXPECT_TRUE(dt.nullable());
+ ASSERT_NE(dt.element_type(), nullptr);
+ EXPECT_FALSE(dt.element_type()->nullable());
+}
+
+TEST(DataTypeTest, NotNullArrayNullableElement) {
+ auto dt = fluss::DataType::Array(fluss::DataType::Int()).NotNull();
+ EXPECT_FALSE(dt.nullable());
+ ASSERT_NE(dt.element_type(), nullptr);
+ EXPECT_TRUE(dt.element_type()->nullable());
+}
+
+TEST(DataTypeTest, NotNullArrayNotNullElement) {
+ auto dt =
fluss::DataType::Array(fluss::DataType::Int().NotNull()).NotNull();
+ EXPECT_FALSE(dt.nullable());
+ ASSERT_NE(dt.element_type(), nullptr);
+ EXPECT_FALSE(dt.element_type()->nullable());
+}
+
+TEST(FfiConverterTest, ScalarNullableRoundTrip) {
+ fluss::Column col{"id", fluss::DataType::Int(), ""};
+ auto ffi_col = fluss::utils::to_ffi_column(col);
+ EXPECT_TRUE(ffi_col.nullable);
+ auto back = fluss::utils::from_ffi_column(ffi_col);
+ EXPECT_TRUE(back.data_type.nullable());
+}
+
+TEST(FfiConverterTest, ScalarNotNullRoundTrip) {
+ fluss::Column col{"id", fluss::DataType::Int().NotNull(), ""};
+ auto ffi_col = fluss::utils::to_ffi_column(col);
+ EXPECT_FALSE(ffi_col.nullable);
+ auto back = fluss::utils::from_ffi_column(ffi_col);
+ EXPECT_FALSE(back.data_type.nullable());
+}
+
+TEST(FfiConverterTest, ArrayNotNullElementRoundTrip) {
+ fluss::Column col{"tags",
fluss::DataType::Array(fluss::DataType::String().NotNull()), ""};
+ auto ffi_col = fluss::utils::to_ffi_column(col);
+ EXPECT_TRUE(ffi_col.nullable);
+ ASSERT_EQ(ffi_col.array_nullability.size(), 2u);
+ EXPECT_EQ(ffi_col.array_nullability[1], 0);
+ auto back = fluss::utils::from_ffi_column(ffi_col);
+ EXPECT_TRUE(back.data_type.nullable());
+ ASSERT_NE(back.data_type.element_type(), nullptr);
+ EXPECT_FALSE(back.data_type.element_type()->nullable());
+}
+
+TEST(FfiConverterTest, NotNullArrayNullableElementRoundTrip) {
+ fluss::Column col{"ids",
fluss::DataType::Array(fluss::DataType::Int()).NotNull(), ""};
+ auto ffi_col = fluss::utils::to_ffi_column(col);
+ EXPECT_FALSE(ffi_col.nullable);
+ ASSERT_EQ(ffi_col.array_nullability.size(), 2u);
+ EXPECT_EQ(ffi_col.array_nullability[1], 1);
+ auto back = fluss::utils::from_ffi_column(ffi_col);
+ EXPECT_FALSE(back.data_type.nullable());
+ ASSERT_NE(back.data_type.element_type(), nullptr);
+ EXPECT_TRUE(back.data_type.element_type()->nullable());
+}
+
+TEST(FfiConverterTest, NotNullArrayNotNullElementRoundTrip) {
+ fluss::Column col{
+ "strict_ids",
+ fluss::DataType::Array(fluss::DataType::Int().NotNull()).NotNull(),
+ "",
+ };
+ auto ffi_col = fluss::utils::to_ffi_column(col);
+ EXPECT_FALSE(ffi_col.nullable);
+ ASSERT_EQ(ffi_col.array_nullability.size(), 2u);
+ EXPECT_EQ(ffi_col.array_nullability[1], 0);
+ auto back = fluss::utils::from_ffi_column(ffi_col);
+ EXPECT_FALSE(back.data_type.nullable());
+ ASSERT_NE(back.data_type.element_type(), nullptr);
+ EXPECT_FALSE(back.data_type.element_type()->nullable());
+}
+
+TEST(FfiConverterTest, NestedArrayIntermediateNullabilityRoundTrip) {
+ fluss::Column col{
+ "nested",
+
fluss::DataType::Array(fluss::DataType::Array(fluss::DataType::Int()).NotNull()),
+ "",
+ };
+ auto ffi_col = fluss::utils::to_ffi_column(col);
+ auto back = fluss::utils::from_ffi_column(ffi_col);
+
+ EXPECT_TRUE(back.data_type.nullable());
+ ASSERT_NE(back.data_type.element_type(), nullptr);
+ EXPECT_FALSE(back.data_type.element_type()->nullable());
+ ASSERT_NE(back.data_type.element_type()->element_type(), nullptr);
+ EXPECT_TRUE(back.data_type.element_type()->element_type()->nullable());
+}
+
+TEST(FfiConverterTest, NestedArrayAllLevelsNullabilityRoundTrip) {
+ fluss::Column col{
+ "strict_nested",
+ fluss::DataType::Array(
+ fluss::DataType::Array(fluss::DataType::Int().NotNull()).NotNull())
+ .NotNull(),
+ "",
+ };
+ auto ffi_col = fluss::utils::to_ffi_column(col);
+ auto back = fluss::utils::from_ffi_column(ffi_col);
+
+ EXPECT_FALSE(back.data_type.nullable());
+ ASSERT_NE(back.data_type.element_type(), nullptr);
+ EXPECT_FALSE(back.data_type.element_type()->nullable());
+ ASSERT_NE(back.data_type.element_type()->element_type(), nullptr);
+ EXPECT_FALSE(back.data_type.element_type()->element_type()->nullable());
+}
+
+TEST(FfiConverterTest, FfiColumnNonNullableScalarReconstructed) {
+ auto col = MakeScalarColumn("id", fluss::TypeId::Int, false);
+ auto converted = fluss::utils::from_ffi_column(col);
+ EXPECT_FALSE(converted.data_type.nullable());
+ EXPECT_EQ(converted.data_type.id(), fluss::TypeId::Int);
+}
+
+TEST(FfiConverterTest, FfiColumnNonNullableArrayReconstructed) {
+ auto col = MakeArrayColumn(1, static_cast<int32_t>(fluss::TypeId::String),
false, false);
+ auto converted = fluss::utils::from_ffi_column(col);
+ EXPECT_FALSE(converted.data_type.nullable());
+ ASSERT_NE(converted.data_type.element_type(), nullptr);
+ EXPECT_FALSE(converted.data_type.element_type()->nullable());
+}
diff --git a/bindings/cpp/test/test_log_table.cpp
b/bindings/cpp/test/test_log_table.cpp
index f36c8707..5678e4bb 100644
--- a/bindings/cpp/test/test_log_table.cpp
+++ b/bindings/cpp/test/test_log_table.cpp
@@ -1458,3 +1458,66 @@ TEST_F(LogTableTest, ArrayWriterOverflowDetection) {
EXPECT_NO_THROW(smallint_arr.SetInt32(0, 32767));
}
}
+
+TEST_F(LogTableTest, NullabilityPreservedInTableInfo) {
+ auto& adm = admin();
+ auto& conn = connection();
+
+ fluss::TablePath table_path("fluss", "test_nullability_table_info_cpp");
+
+ auto schema =
+ fluss::Schema::NewBuilder()
+ .AddColumn("id", fluss::DataType::Int())
+ .AddColumn("name", fluss::DataType::String())
+ .AddColumn("tags",
fluss::DataType::Array(fluss::DataType::String().NotNull()))
+ .AddColumn("ids",
fluss::DataType::Array(fluss::DataType::Int()).NotNull())
+ .AddColumn("nested",
+ fluss::DataType::Array(
+
fluss::DataType::Array(fluss::DataType::Int()).NotNull()))
+ .SetPrimaryKeys({"id"})
+ .Build();
+
+ auto table_descriptor = fluss::TableDescriptor::NewBuilder()
+ .SetSchema(schema)
+ .SetProperty("table.replication.factor", "1")
+ .Build();
+
+ fluss_test::CreateTable(adm, table_path, table_descriptor);
+
+ fluss::Table table;
+ ASSERT_OK(conn.GetTable(table_path, table));
+ auto info = table.GetTableInfo();
+
+ ASSERT_EQ(info.schema.columns.size(), 5u);
+ EXPECT_EQ(info.primary_keys, std::vector<std::string>{"id"});
+
+ // Primary key columns are forced NOT NULL by schema normalization.
+ EXPECT_EQ(info.schema.columns[0].data_type.id(), fluss::TypeId::Int);
+ EXPECT_FALSE(info.schema.columns[0].data_type.nullable());
+
+ // "name" STRING (nullable)
+ EXPECT_EQ(info.schema.columns[1].data_type.id(), fluss::TypeId::String);
+ EXPECT_TRUE(info.schema.columns[1].data_type.nullable());
+
+ // "tags" ARRAY<STRING NOT NULL> (outer nullable)
+ EXPECT_EQ(info.schema.columns[2].data_type.id(), fluss::TypeId::Array);
+ EXPECT_TRUE(info.schema.columns[2].data_type.nullable());
+ ASSERT_NE(info.schema.columns[2].data_type.element_type(), nullptr);
+ EXPECT_FALSE(info.schema.columns[2].data_type.element_type()->nullable());
+
+ // "ids" ARRAY<INT> NOT NULL (outer not null, element nullable)
+ EXPECT_EQ(info.schema.columns[3].data_type.id(), fluss::TypeId::Array);
+ EXPECT_FALSE(info.schema.columns[3].data_type.nullable());
+ ASSERT_NE(info.schema.columns[3].data_type.element_type(), nullptr);
+ EXPECT_TRUE(info.schema.columns[3].data_type.element_type()->nullable());
+
+ // "nested" ARRAY<ARRAY<INT> NOT NULL> (outer nullable, inner array not
null)
+ EXPECT_EQ(info.schema.columns[4].data_type.id(), fluss::TypeId::Array);
+ EXPECT_TRUE(info.schema.columns[4].data_type.nullable());
+ ASSERT_NE(info.schema.columns[4].data_type.element_type(), nullptr);
+ EXPECT_FALSE(info.schema.columns[4].data_type.element_type()->nullable());
+ ASSERT_NE(info.schema.columns[4].data_type.element_type()->element_type(),
nullptr);
+
EXPECT_TRUE(info.schema.columns[4].data_type.element_type()->element_type()->nullable());
+
+ ASSERT_OK(adm.DropTable(table_path, false));
+}
diff --git a/website/docs/user-guide/cpp/api-reference.md
b/website/docs/user-guide/cpp/api-reference.md
index c50d40cd..ae4e9490 100644
--- a/website/docs/user-guide/cpp/api-reference.md
+++ b/website/docs/user-guide/cpp/api-reference.md
@@ -492,7 +492,9 @@ Same array getters as
[`RowView`](#array-getters-index-based) — `GetArraySize`
| `id() -> TypeId` | Get the type ID
|
| `precision() -> int` | Get precision (for Decimal/Timestamp
types) |
| `scale() -> int` | Get scale (for Decimal type)
|
+| `nullable() -> bool` | Returns `true` if this type is nullable
(default), `false` if `NOT NULL` |
| `element_type() -> const DataType*` | Get element type (for Array type,
nullptr otherwise) |
+| `NotNull() -> DataType` | Returns a copy of this type with
nullable set to `false` |
## `ArrayWriter`
diff --git a/website/docs/user-guide/cpp/data-types.md
b/website/docs/user-guide/cpp/data-types.md
index 400b2ecf..cce40cef 100644
--- a/website/docs/user-guide/cpp/data-types.md
+++ b/website/docs/user-guide/cpp/data-types.md
@@ -23,6 +23,40 @@ sidebar_position: 3
| `DataType::Decimal(p, s)` | Decimal with precision and scale
|
| `DataType::Array(element)` | Array of the given element type (supports
nesting) |
+## Nullability
+
+All DataTypes are nullable by default. Use `.NotNull()` to create a `NOT NULL`
type:
+
+```cpp
+auto schema = fluss::Schema::NewBuilder()
+ .AddColumn("id", fluss::DataType::Int().NotNull())
+ .AddColumn("name", fluss::DataType::String()) // nullable by
default
+ .Build();
+```
+
+Primary key columns are automatically forced `NOT NULL` regardless of the
`DataType` setting.
+
+For nested types, nullability is preserved at each array level and at the leaf
element:
+
+```cpp
+auto schema = fluss::Schema::NewBuilder()
+ .AddColumn("tags",
fluss::DataType::Array(fluss::DataType::String().NotNull()))
+ .AddColumn("ids", fluss::DataType::Array(fluss::DataType::Int()).NotNull())
+ .AddColumn("nested", fluss::DataType::Array(
+ fluss::DataType::Array(fluss::DataType::Int()).NotNull()))
+ .Build();
+// "tags": ARRAY<STRING NOT NULL> (outer nullable, elements NOT NULL)
+// "ids": ARRAY<INT> NOT NULL (outer NOT NULL, elements nullable)
+// "nested": ARRAY<ARRAY<INT> NOT NULL> (outer nullable, inner array NOT
NULL)
+```
+
+You can query nullability at runtime:
+
+```cpp
+auto info = table.GetTableInfo();
+bool is_nullable = info.schema.columns[0].data_type.nullable();
+```
+
## GenericRow Setters
`SetInt32` is used for `TinyInt`, `SmallInt`, and `Int` columns. For `TinyInt`
and `SmallInt`, the value is validated at write time — an error is returned if
it overflows the column's range (e.g., \[-128, 127\] for `TinyInt`, \[-32768,
32767\] for `SmallInt`).