Skip to content
59 changes: 59 additions & 0 deletions datafusion/expr/src/expr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -599,6 +599,65 @@ impl From<&HashMap<String, String>> for FieldMetadata {
}
}

/// The metadata used in [`Field::metadata`].

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be good to describe the key and value, ideally with some example

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea. Thank you.
Added: baa3f8d

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As we move to working with more field metadata, maybe should move this function into its own module -- datafusion/expr/src/metadata.rs or something 🤔

As a follow on PR

///
/// This represents the metadata associated with an Arrow [`Field`]. The metadata consists of key-value pairs.
///
/// # Common Use Cases
///
/// Field metadata is commonly used to store:
/// - Default values for columns when data is missing
/// - Column descriptions or documentation
/// - Data lineage information
/// - Custom application-specific annotations
/// - Encoding hints or display formatting preferences
///
/// # Example: Storing Default Values
///
/// A practical example of using field metadata is storing default values for columns
/// that may be missing in the physical data but present in the logical schema.
/// See the [default_column_values.rs] example implementation.
///
/// [default_column_values.rs]: https://github.com/apache/datafusion/blob/main/datafusion-examples/examples/default_column_values.rs
pub type SchemaFieldMetadata = std::collections::HashMap<String, String>;

/// Intersects multiple metadata instances for UNION operations.
///
/// This function implements the intersection strategy used by UNION operations,
/// where only metadata keys that exist in ALL inputs with identical values
/// are preserved in the result.
///
/// # Union Metadata Behavior
///
/// Union operations require consistent metadata across all branches:
/// - Only metadata keys present in ALL union branches are kept
/// - For each kept key, the value must be identical across all branches
/// - If a key has different values across branches, it is excluded from the result
/// - If any input has no metadata, the result will be empty
///
/// # Arguments
///
/// * `metadatas` - An iterator of `SchemaFieldMetadata` instances to intersect
///
/// # Returns
///
/// A new `SchemaFieldMetadata` containing only the intersected metadata
pub fn intersect_metadata_for_union<'a>(
metadatas: impl IntoIterator<Item = &'a SchemaFieldMetadata>,
) -> SchemaFieldMetadata {
let mut metadatas = metadatas.into_iter();
let Some(mut intersected) = metadatas.next().cloned() else {
return Default::default();
};

for metadata in metadatas {
// Only keep keys that exist in both with the same value
intersected.retain(|k, v| metadata.get(k) == Some(v));
}

intersected
}

/// UNNEST expression.
#[derive(Clone, PartialEq, Eq, PartialOrd, Hash, Debug)]
pub struct Unnest {
Expand Down
37 changes: 14 additions & 23 deletions datafusion/expr/src/logical_plan/plan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,10 @@ use super::invariants::{
};
use super::DdlStatement;
use crate::builder::{change_redundant_column, unnest_with_options};
use crate::expr::{Placeholder, Sort as SortExpr, WindowFunction, WindowFunctionParams};
use crate::expr::{
intersect_metadata_for_union, Placeholder, Sort as SortExpr, WindowFunction,
WindowFunctionParams,
};
use crate::expr_rewriter::{
create_col_from_scalar_expr, normalize_cols, normalize_sorts, NamePreserver,
};
Expand Down Expand Up @@ -2799,15 +2802,16 @@ impl Union {

let mut field =
Field::new(name, data_type.clone(), final_is_nullable);
field.set_metadata(intersect_maps(unmerged_metadata));
field.set_metadata(intersect_metadata_for_union(unmerged_metadata));

(None, Arc::new(field))
},
)
.collect::<Vec<(Option<TableReference>, _)>>();

let union_schema_metadata =
intersect_maps(inputs.iter().map(|input| input.schema().metadata()));
let union_schema_metadata = intersect_metadata_for_union(
inputs.iter().map(|input| input.schema().metadata()),
);

// Functional Dependencies are not preserved after UNION operation
let schema = DFSchema::new_with_metadata(union_fields, union_schema_metadata)?;
Expand Down Expand Up @@ -2876,14 +2880,16 @@ impl Union {
};

let mut field = Field::new(&name, data_type.clone(), nullable);
let field_metadata =
intersect_maps(fields.iter().map(|field| field.metadata()));
let field_metadata = intersect_metadata_for_union(
fields.iter().map(|field| field.metadata()),
);
field.set_metadata(field_metadata);
Ok((None, Arc::new(field)))
})
.collect::<Result<_>>()?;
let union_schema_metadata =
intersect_maps(inputs.iter().map(|input| input.schema().metadata()));
let union_schema_metadata = intersect_metadata_for_union(
inputs.iter().map(|input| input.schema().metadata()),
);

// Functional Dependencies are not preserved after UNION operation
let schema = DFSchema::new_with_metadata(union_fields, union_schema_metadata)?;
Expand All @@ -2893,21 +2899,6 @@ impl Union {
}
}

fn intersect_maps<'a>(
inputs: impl IntoIterator<Item = &'a HashMap<String, String>>,
) -> HashMap<String, String> {
let mut inputs = inputs.into_iter();
let mut merged: HashMap<String, String> = inputs.next().cloned().unwrap_or_default();
for input in inputs {
// The extra dereference below (`&*v`) is a workaround for https://github.com/rkyv/rkyv/issues/434.
// When this crate is used in a workspace that enables the `rkyv-64` feature in the `chrono` crate,
// this triggers a Rust compilation error:
// error[E0277]: can't compare `Option<&std::string::String>` with `Option<&mut std::string::String>`.
merged.retain(|k, v| input.get(k) == Some(&*v));
}
merged
}

// Manual implementation needed because of `schema` field. Comparison excludes this field.
impl PartialOrd for Union {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Expand Down
37 changes: 37 additions & 0 deletions datafusion/optimizer/src/analyzer/type_coercion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -957,6 +957,43 @@ fn coerce_case_expression(case: Case, schema: &DFSchema) -> Result<Case> {
///
/// This method presumes that the wildcard expansion is unneeded, or has already
/// been applied.
///
/// ## Schema and Field Handling in Union Coercion
///
Comment on lines +960 to +962

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that here in the analyzer, we handle union coercion differently than at construction.

/// **Processing order**: The function starts with the base schema (first input) and then
/// processes remaining inputs sequentially, with later inputs taking precedence in merging.
///
/// **Schema-level metadata merging**: Later schemas take precedence for duplicate keys.
///
/// **Field-level metadata merging**: Later fields take precedence for duplicate metadata keys.
///
/// **Type coercion precedence**: The coerced type is determined by iteratively applying
/// `comparison_coercion()` between the accumulated type and each new input's type. The
/// result depends on type coercion rules, not input order.
///
/// **Nullability merging**: Nullability is accumulated using logical OR (`||`).
/// Once any input field is nullable, the result field becomes nullable permanently.
/// Later inputs can make a field nullable but cannot make it non-nullable.
///
/// **Field precedence**: Field names come from the first (base) schema, but the field properties
/// (nullability and field-level metadata) have later schemas taking precedence.
///
/// **Example**:
/// ```sql
/// SELECT a, b FROM table1 -- a: Int32, metadata {"source": "t1"}, nullable=false
/// UNION
/// SELECT a, b FROM table2 -- a: Int64, metadata {"source": "t2"}, nullable=true
/// UNION
/// SELECT a, b FROM table3 -- a: Int32, metadata {"encoding": "utf8"}, nullable=false
/// -- Result:
/// -- a: Int64 (from type coercion), nullable=true (from table2),
/// -- metadata: {"source": "t2", "encoding": "utf8"} (later inputs take precedence)
/// ```
///
/// **Precedence Summary**:
/// - **Datatypes**: Determined by `comparison_coercion()` rules, not input order
/// - **Nullability**: Later inputs can add nullability but cannot remove it (logical OR)
/// - **Metadata**: Later inputs take precedence for same keys (HashMap::extend semantics)
pub fn coerce_union_schema(inputs: &[Arc<LogicalPlan>]) -> Result<DFSchema> {
coerce_union_schema_with_schema(&inputs[1..], inputs[0].schema())
}
Expand Down