feat(datafusion): fill omitted INSERT columns with Iceberg write-default values - #2804
moomindani wants to merge 1 commit into
Conversation
|
Gentle ping — open for ~2.5 weeks, CI green, no review yet. Per the spec, writers must use a column's @CTTY you've reviewed most of the recent |
| /// Returns `None` for combinations that have no scalar representation; the insert | ||
| /// planner casts the resulting expression to the target arrow type, so minor | ||
| /// representation differences (e.g. timezone strings) are reconciled downstream. | ||
| fn literal_to_scalar_value(field_type: &Type, literal: &Literal) -> Option<ScalarValue> { |
There was a problem hiding this comment.
literal_to_scalar_value re-derives the (PrimitiveType, PrimitiveLiteral) → arrow-type mapping that already exists in iceberg-core in a couple of places: get_arrow_datum (crates/iceberg/src/arrow/schema.rs) and create_primitive_array_single_element (crates/iceberg/src/arrow/value.rs). This is effectively a fourth copy of that knowledge, and it can drift e.g. this match already handles Time, which get_arrow_datum currently doesn't.
One option that reuses the tested mappings and lets DataFusion do the array→scalar step:
let arrow_type = type_to_arrow_type(field_type)?; // already pub
let array = create_primitive_array_single_element(&arrow_type, &Some(lit))?;
ScalarValue::try_from_array(&array, 0).ok()Bonus: the resulting scalar's arrow type already matches the column (LargeBinary, FixedSizeBinary(len), UTC tz, …), which removes the reliance on the downstream cast for reconciliation.
Tradeoff: create_primitive_array_single_element is pub(crate), so this needs promoting it to pub in iceberg-core. If you'd rather not expand the core crate's public surface, keeping this match is reasonable, it's self-contained. Flagging mainly so the duplication is a conscious choice. @CTTY or @blackmwk might have stronger opinions.
There was a problem hiding this comment.
Thanks for flagging this. I looked into the reuse path before deciding, and the composition would regress today.
type_to_arrow_type maps Time → Time64(Microsecond), Uuid → FixedSizeBinary(16), Fixed(len) → FixedSizeBinary(len) and Binary → LargeBinary. But create_primitive_array_single_element (crates/iceberg/src/arrow/value.rs:627) only has arms for Boolean, Int32, Date32, Int64, Timestamp(us|ns, tz), Float32, Float64, Utf8, Binary, Decimal128 and Struct(None), and falls through to Err("Unsupported constant type combination"). There is no Time64 arm, no FixedSizeBinary arm and no LargeBinary arm. Since column_defaults_from_schema uses filter_map, those four types would silently lose their write-default and fall back to NULL.
So the drift runs in both directions: this match handles Time precisely because core does not, and the same holds for Uuid, Fixed and Binary.
I kept the local match and made the duplication explicit in the doc comment. What I did take from your comment is the arrow-type alignment — see the two replies below: Binary now produces LargeBinary, and Fixed(len) is sized from the declared width, so the scalars carry exactly the types type_to_arrow_type assigns and the downstream cast has nothing left to reconcile. A new unit test asserts scalar.data_type() == type_to_arrow_type(field_type) for all 16 supported combinations, so a future divergence fails the build.
Happy to do the core side as a follow-up if you would like the single source of truth: add the Time64 / FixedSizeBinary / LargeBinary arms to create_primitive_array_single_element, promote it to pub, then reduce this function to your three lines. That expands iceberg-core's public surface, so it seemed better as its own PR than folded in here — but I am happy either way, and would defer to @CTTY / @blackmwk on whether core should export it.
| (PrimitiveType::Binary, PrimitiveLiteral::Binary(v)) => { | ||
| ScalarValue::Binary(Some(v.clone())) | ||
| } | ||
| (PrimitiveType::Fixed(_), PrimitiveLiteral::Binary(v)) => { |
There was a problem hiding this comment.
Minor: this sizes the FixedSizeBinary from the default value's length (v.len()) rather than the column's declared Fixed(len). It works today because the planner casts to the target type, but a value whose length didn't match the declared width would produce a differently-typed scalar. Reusing the shared mapping (see comment above) would make this moot, otherwise a one-line note might be worth it.
There was a problem hiding this comment.
Fixed. It now sizes the scalar from the declared Fixed(len) and skips the default when the value's length contradicts the declaration:
let width = i32::try_from(*len).ok()?;
if v.len() != usize::try_from(*len).ok()? {
return None;
}
ScalarValue::FixedSizeBinary(width, Some(v.clone()))Covered by test_literal_to_scalar_value_skips_fixed_default_of_wrong_width.
| (PrimitiveType::Decimal { precision, scale }, PrimitiveLiteral::Int128(v)) => { | ||
| ScalarValue::Decimal128(Some(*v), *precision as u8, *scale as i8) | ||
| } | ||
| (PrimitiveType::Binary, PrimitiveLiteral::Binary(v)) => { |
There was a problem hiding this comment.
Minor: Binary maps to ScalarValue::Binary here, whereas iceberg-core's type_to_arrow_type maps Binary → LargeBinary (crates/iceberg/src/arrow/schema.rs). The downstream cast reconciles it, so not a correctness issue but this is another spot where reusing the shared mapping would keep things aligned.
There was a problem hiding this comment.
Fixed — it emits ScalarValue::LargeBinary now, matching type_to_arrow_type. The new test_literal_to_scalar_value_matches_column_arrow_type asserts the scalar's arrow type equals type_to_arrow_type(field_type) for every supported primitive, so this cannot drift back unnoticed.
…ult values An INSERT that omits a column left it NULL even when the Iceberg schema declared a write-default, because the DataFusion table provider exposed no default for it. The provider now surfaces the column's write-default so the planner fills the omitted column with it. Scalars are derived to match the column's arrow type, so a Fixed default whose length contradicts the declared width is skipped rather than producing a differently-typed scalar, and Binary maps to LargeBinary as type_to_arrow_type does.
1371af9 to
680d551
Compare
|
Rebased onto current @xanderbailey thanks again for the review; all three points are addressed and answered above. If the resolutions look right to you, saying so would help this find a committer. @CTTY @kevinjqliu this one has a completed review round. An INSERT that omits a column currently leaves it NULL even when the Iceberg schema declares a write-default; the table provider now surfaces the default so the planner fills it in. |
|
Sorry I still haven't got a chance to review this. We are planning to migrate datafusion integrations to https://github.com/apache/datafusion-iceberg very soon and don't want to add more code to the current integration. You are welcome to raise the PR there once the repo is up |
|
Sounds good — thanks for the heads-up, no objection to the close. I'll re-raise this in apache/datafusion-iceberg once that repo is ready to take contributions. In the meantime I'll leave #2803 open here as the record of the gap — let me know if you'd rather it be tracked in the new repo instead. |
Which issue does this PR close?
What changes are included in this PR?
Per the spec, writers must use a column's
write-defaultfor columns they do not supply. DataFusion's insert planner consultsTableProvider::get_column_defaultfor columns omitted from anINSERTand falls back toNULL;IcebergTableProviderdid not implement it, so tables withwrite-defaultvalues silently gotNULLs.IcebergTableProvidercaches the schema's top-levelwrite-defaultvalues as DataFusion expressions at construction and serves them viaget_column_default.literal_to_scalar_valueconversion covering the primitive types (boolean, int, long, float, double, string, date, time, timestamp/timestamptz in µs and ns, decimal, binary, fixed, uuid); defaults with no scalar representation are skipped. The planner casts the expression to the target arrow type, so representation differences are reconciled downstream.write-defaultas the engine column default (TypeToSparkType), and Spark materializes it at INSERT planning.Are these changes tested?
Yes — an end-to-end test (
INSERTomitting defaulted columns, then scanning to assert the defaults land and explicitly provided values win) plus unit tests for the literal conversion. The end-to-end test fails without theget_column_defaultimplementation.cargo test -p iceberg-datafusion --lib(90 tests) andcargo clippy -p iceberg-datafusion --lib --testspass locally.This pull request and its description were written by Claude Fable 5.