From 9ca43f369be1a0d1b989b66f3bd043e4b9e4478e Mon Sep 17 00:00:00 2001 From: osipovartem Date: Sat, 10 Oct 2026 02:59:07 +0300 Subject: [PATCH 1/2] Keep annotated untyped NULL neutral during CASE coercion (cherry picked from commit 3b33f8c409bc7c2e0178eec865307cfcd6199366) --- datafusion/expr/src/expr_schema.rs | 17 ++++++- .../optimizer/src/analyzer/type_coercion.rs | 49 +++++++++++++++++++ 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/datafusion/expr/src/expr_schema.rs b/datafusion/expr/src/expr_schema.rs index 863d4347d618..2444f9241769 100644 --- a/datafusion/expr/src/expr_schema.rs +++ b/datafusion/expr/src/expr_schema.rs @@ -234,8 +234,23 @@ fn case_field_metadata(case: &Case, schema: &dyn ExprSchema) -> Result let Some(source) = fields.pop() else { return internal_err!("Missing CASE cast input field"); }; + let untyped_null = + source.certainly_null && source.field.data_type().is_null(); + let mut field = cast_output_field(&source.field, target, force_nullable); + // A type-only coercion must not make an untyped NULL constrain CASE metadata. + if untyped_null + && target.metadata().is_empty() + && !field.metadata().is_empty() + { + field = Arc::new( + field + .as_ref() + .clone() + .with_metadata(arrow_schema::Metadata::default()), + ); + } fields.push(BranchField { - field: cast_output_field(&source.field, target, force_nullable), + field, certainly_null: source.certainly_null, }); } diff --git a/datafusion/optimizer/src/analyzer/type_coercion.rs b/datafusion/optimizer/src/analyzer/type_coercion.rs index 8a73892e7f53..96b12a454271 100644 --- a/datafusion/optimizer/src/analyzer/type_coercion.rs +++ b/datafusion/optimizer/src/analyzer/type_coercion.rs @@ -2761,6 +2761,55 @@ mod test { Ok(()) } + #[test] + fn test_case_coercion_preserves_metadata_with_annotated_null() -> Result<()> { + let metadata = std::collections::HashMap::from([ + ( + "ARROW:extension:name".to_string(), + "example.case".to_string(), + ), + ("structured_type".to_string(), "ARRAY(NUMBER)".to_string()), + ]); + let schema = DFSchema::from_unqualified_fields( + vec![ + Field::new("structured", DataType::Int32, false) + .with_metadata(metadata.clone()), + ] + .into(), + std::collections::HashMap::new(), + )?; + let conflicting = std::collections::HashMap::from([ + ( + "ARROW:extension:name".to_string(), + "example.other".to_string(), + ), + ("structured_type".to_string(), "OBJECT".to_string()), + ]); + for null_metadata in [metadata.clone(), conflicting] { + let annotated_null = Expr::Literal( + ScalarValue::Null, + Some(expr::FieldMetadata::from(null_metadata)), + ); + let case = Case { + expr: None, + when_then_expr: vec![(Box::new(lit(true)), Box::new(col("structured")))], + else_expr: Some(Box::new(annotated_null)), + }; + assert_eq!( + Expr::Case(case.clone()).to_field(&schema)?.1.metadata(), + &metadata + ); + + let coerced = coerce_case_expression(case, &schema, None)?; + assert!(matches!(coerced.else_expr.as_deref(), Some(Expr::Cast(_)))); + assert_eq!( + Expr::Case(coerced).to_field(&schema)?.1.metadata(), + &metadata + ); + } + Ok(()) + } + #[test] fn test_case_expression_coercion() -> Result<()> { let schema = Arc::new(DFSchema::from_unqualified_fields( From 54667f1d3d7b6eebacad67540150a1197a4bb9b4 Mon Sep 17 00:00:00 2001 From: osipovartem Date: Sat, 10 Oct 2026 03:02:05 +0300 Subject: [PATCH 2/2] Adapt CASE metadata regression to DF55 and Arrow 59 --- datafusion/expr/src/expr_schema.rs | 9 +++------ datafusion/optimizer/src/analyzer/type_coercion.rs | 2 +- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/datafusion/expr/src/expr_schema.rs b/datafusion/expr/src/expr_schema.rs index 2444f9241769..e63ca5ff4e82 100644 --- a/datafusion/expr/src/expr_schema.rs +++ b/datafusion/expr/src/expr_schema.rs @@ -242,12 +242,9 @@ fn case_field_metadata(case: &Case, schema: &dyn ExprSchema) -> Result && target.metadata().is_empty() && !field.metadata().is_empty() { - field = Arc::new( - field - .as_ref() - .clone() - .with_metadata(arrow_schema::Metadata::default()), - ); + field = Arc::new(field.as_ref().clone().with_metadata( + std::collections::HashMap::::new(), + )); } fields.push(BranchField { field, diff --git a/datafusion/optimizer/src/analyzer/type_coercion.rs b/datafusion/optimizer/src/analyzer/type_coercion.rs index 96b12a454271..a11ccc6fe161 100644 --- a/datafusion/optimizer/src/analyzer/type_coercion.rs +++ b/datafusion/optimizer/src/analyzer/type_coercion.rs @@ -2800,7 +2800,7 @@ mod test { &metadata ); - let coerced = coerce_case_expression(case, &schema, None)?; + let coerced = coerce_case_expression(case, &schema)?; assert!(matches!(coerced.else_expr.as_deref(), Some(Expr::Cast(_)))); assert_eq!( Expr::Case(coerced).to_field(&schema)?.1.metadata(),