Skip to content

Commit f64100d

Browse files
fix: preserve projection metadata during optimization
1 parent 63f5b55 commit f64100d

2 files changed

Lines changed: 194 additions & 26 deletions

File tree

‎datafusion/expr/src/expr_schema.rs‎

Lines changed: 49 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -71,18 +71,31 @@ pub trait ExprSchemable {
7171
-> Result<(DataType, bool)>;
7272
}
7373

74-
/// Derives the output field for a cast expression from the source field.
74+
/// Derives the output field for a cast expression from the source and target
75+
/// fields. Type-only casts preserve source metadata, while an explicit target
76+
/// field supplies its own metadata.
77+
///
7578
/// For `TryCast`, `force_nullable` is `true` since a failed cast returns NULL.
7679
fn cast_output_field(
7780
source_field: &FieldRef,
78-
target_type: &DataType,
81+
target_field: &FieldRef,
7982
force_nullable: bool,
8083
) -> Arc<Field> {
84+
// `Cast::new` and `TryCast::new` use this field when only a target
85+
// type is known. In that case, retain the source field's metadata.
86+
let type_only_target = target_field.name().is_empty()
87+
&& target_field.is_nullable()
88+
&& target_field.metadata().is_empty();
89+
let metadata = if type_only_target {
90+
source_field.metadata().clone()
91+
} else {
92+
target_field.metadata().clone()
93+
};
8194
let mut f = source_field
8295
.as_ref()
8396
.clone()
84-
.with_data_type(target_type.clone())
85-
.with_metadata(source_field.metadata().clone());
97+
.with_data_type(target_field.data_type().clone())
98+
.with_metadata(metadata);
8699
if force_nullable {
87100
f = f.with_nullable(true);
88101
}
@@ -623,20 +636,16 @@ impl ExprSchemable for Expr {
623636
func.return_field_from_args(args)
624637
}
625638
// _ => Ok((self.get_type(schema)?, self.nullable(schema)?)),
626-
Expr::Cast(Cast { expr, field }) => {
627-
expr.to_field(schema).map(|(_table_ref, src)| {
628-
cast_output_field(&src, field.data_type(), false)
629-
})
630-
}
639+
Expr::Cast(Cast { expr, field }) => expr
640+
.to_field(schema)
641+
.map(|(_table_ref, src)| cast_output_field(&src, field, false)),
631642
Expr::Placeholder(Placeholder {
632643
id: _,
633644
field: Some(field),
634645
}) => Ok(Arc::clone(field).renamed(&schema_name)),
635-
Expr::TryCast(TryCast { expr, field }) => {
636-
expr.to_field(schema).map(|(_table_ref, src)| {
637-
cast_output_field(&src, field.data_type(), true)
638-
})
639-
}
646+
Expr::TryCast(TryCast { expr, field }) => expr
647+
.to_field(schema)
648+
.map(|(_table_ref, src)| cast_output_field(&src, field, true)),
640649
Expr::LambdaVariable(LambdaVariable {
641650
field: Some(field), ..
642651
}) => Ok(Arc::clone(field).renamed(&schema_name)),
@@ -1149,7 +1158,7 @@ mod tests {
11491158
}
11501159

11511160
#[test]
1152-
fn test_expr_metadata() {
1161+
fn test_expr_metadata() -> Result<()> {
11531162
let mut meta = HashMap::new();
11541163
meta.insert("bar".to_string(), "buzz".to_string());
11551164
let meta = FieldMetadata::from(meta);
@@ -1179,13 +1188,38 @@ mod tests {
11791188
// verify to_field method populates metadata
11801189
assert_eq!(meta, expr.metadata(&schema).unwrap());
11811190

1191+
// An explicit cast target replaces source metadata. A type-only cast
1192+
// continues to preserve it.
1193+
let target_metadata = HashMap::from([(
1194+
"ARROW:extension:name".to_string(),
1195+
"arrow.uuid".to_string(),
1196+
)]);
1197+
let target_field = Arc::new(
1198+
Field::new("", DataType::FixedSizeBinary(16), true)
1199+
.with_metadata(target_metadata.clone()),
1200+
);
1201+
let cast = Expr::Cast(Cast::new_from_field(
1202+
Box::new(expr.clone()),
1203+
Arc::clone(&target_field),
1204+
));
1205+
assert_eq!(cast.to_field(&schema)?.1.metadata(), &target_metadata);
1206+
1207+
let try_cast = Expr::TryCast(TryCast::new_from_field(
1208+
Box::new(expr.clone()),
1209+
target_field,
1210+
));
1211+
let try_cast_field = try_cast.to_field(&schema)?.1;
1212+
assert_eq!(try_cast_field.metadata(), &target_metadata);
1213+
assert!(try_cast_field.is_nullable());
1214+
11821215
// outer ref constructed by `out_ref_col_with_metadata` should be metadata-preserving
11831216
let outer_ref = out_ref_col_with_metadata(
11841217
DataType::Int32,
11851218
meta.to_hashmap(),
11861219
Column::from_name("foo"),
11871220
);
11881221
assert_eq!(meta, outer_ref.metadata(&schema).unwrap());
1222+
Ok(())
11891223
}
11901224

11911225
#[test]

‎datafusion/physical-plan/src/projection.rs‎

Lines changed: 145 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1014,6 +1014,11 @@ pub fn remove_unnecessary_projections(
10141014
plan: Arc<dyn ExecutionPlan>,
10151015
) -> Result<Transformed<Arc<dyn ExecutionPlan>>> {
10161016
let maybe_modified = if let Some(projection) = plan.downcast_ref::<ProjectionExec>() {
1017+
// Metadata is observable by physical expressions. Moving a projection that
1018+
// defines metadata can therefore change query results.
1019+
if projection_overrides_metadata(projection)? {
1020+
return Ok(Transformed::no(plan));
1021+
}
10171022
// If the projection does not cause any change on the input, we can
10181023
// safely remove it:
10191024
if is_projection_removable(projection) {
@@ -1029,10 +1034,12 @@ pub fn remove_unnecessary_projections(
10291034
Ok(maybe_modified.map_or_else(|| Transformed::no(plan), Transformed::yes))
10301035
}
10311036

1032-
/// Compare the inputs and outputs of the projection. All expressions must be
1033-
/// columns without alias, and projection does not change the order of fields.
1034-
/// For example, if the input schema is `a, b`, `SELECT a, b` is removable,
1035-
/// but `SELECT b, a` and `SELECT a+1, b` and `SELECT a AS c, b` are not.
1037+
/// Compare the input and output of a projection. A removable projection contains
1038+
/// only unaliased columns in input order and preserves the exact schema, including
1039+
/// field and schema metadata.
1040+
///
1041+
/// For example, if the input schema is `a, b`, `SELECT a, b` is removable, but
1042+
/// `SELECT b, a`, `SELECT a + 1, b`, and a metadata-only projection are not.
10361043
fn is_projection_removable(projection: &ProjectionExec) -> bool {
10371044
let exprs = projection.expr();
10381045
exprs.iter().enumerate().all(|(idx, proj_expr)| {
@@ -1041,6 +1048,7 @@ fn is_projection_removable(projection: &ProjectionExec) -> bool {
10411048
};
10421049
col.name() == proj_expr.alias && col.index() == idx
10431050
}) && exprs.len() == projection.input().schema().fields().len()
1051+
&& projection.schema() == projection.input().schema()
10441052
}
10451053

10461054
/// Given the expression set of a projection, checks if the projection causes
@@ -1073,14 +1081,20 @@ pub fn new_projections_for_columns(
10731081
.collect()
10741082
}
10751083

1076-
/// Creates a new [`ProjectionExec`] instance with the given child plan and
1077-
/// projected expressions.
1084+
/// Creates an equivalent [`ProjectionExec`] with a new child.
1085+
///
1086+
/// The original output metadata is preserved because a parent expression may
1087+
/// observe it; recomputing metadata from `child` could change query results.
10781088
pub fn make_with_child(
10791089
projection: &ProjectionExec,
10801090
child: &Arc<dyn ExecutionPlan>,
10811091
) -> Result<Arc<dyn ExecutionPlan>> {
1082-
ProjectionExec::try_new(projection.expr().to_vec(), Arc::clone(child))
1083-
.map(|e| Arc::new(e) as _)
1092+
ProjectionExec::try_new_with_schema_metadata(
1093+
projection.expr().to_vec(),
1094+
Arc::clone(child),
1095+
projection.schema().as_ref(),
1096+
)
1097+
.map(|e| Arc::new(e) as _)
10841098
}
10851099

10861100
/// Returns `true` if all the expressions in the argument are `Column`s.
@@ -1326,17 +1340,44 @@ pub fn update_join_filter(
13261340
})
13271341
}
13281342

1343+
/// Returns whether a projection defines metadata that its expressions and input
1344+
/// schema cannot reproduce.
1345+
///
1346+
/// Such a projection is an execution boundary: a parent expression such as
1347+
/// `arrow_metadata` can observe its output field metadata.
1348+
fn projection_overrides_metadata(projection: &ProjectionExec) -> Result<bool> {
1349+
let derived_schema = projection
1350+
.projector
1351+
.projection()
1352+
.project_schema(projection.input().schema().as_ref())?;
1353+
let output_schema = projection.schema();
1354+
Ok(derived_schema.metadata() != output_schema.metadata()
1355+
|| derived_schema
1356+
.fields()
1357+
.iter()
1358+
.zip(output_schema.fields())
1359+
.any(|(derived, output)| derived.metadata() != output.metadata()))
1360+
}
1361+
13291362
/// Collapse a chain of consecutive [`ProjectionExec`]s into one. Returns
13301363
/// `None` if nothing could be merged.
13311364
fn try_collapse_projection_chain(
13321365
outer: &ProjectionExec,
13331366
) -> Result<Option<Arc<dyn ExecutionPlan>>> {
1367+
if projection_overrides_metadata(outer)? {
1368+
return Ok(None);
1369+
}
1370+
13341371
let mut current_exprs: Vec<ProjectionExpr> = outer.expr().to_vec();
13351372
let mut current_input: Arc<dyn ExecutionPlan> = Arc::clone(outer.input());
13361373
let mut column_ref_map: HashMap<Column, usize> = HashMap::new();
13371374
let mut collapsed_any = false;
13381375

13391376
'outer: while let Some(inner_proj) = current_input.downcast_ref::<ProjectionExec>() {
1377+
if projection_overrides_metadata(inner_proj)? {
1378+
break;
1379+
}
1380+
13401381
// Collect the column references usage in the outer projection.
13411382
column_ref_map.clear();
13421383
for proj_expr in &current_exprs {
@@ -1385,9 +1426,14 @@ fn try_collapse_projection_chain(
13851426
return Ok(None);
13861427
}
13871428

1388-
// To unify 3 or more sequential projections:
1429+
// Expression substitution must not change the outer projection's output
1430+
// metadata contract.
13891431
let unified: Arc<dyn ExecutionPlan> =
1390-
Arc::new(ProjectionExec::try_new(current_exprs, current_input)?);
1432+
Arc::new(ProjectionExec::try_new_with_schema_metadata(
1433+
current_exprs,
1434+
current_input,
1435+
outer.schema().as_ref(),
1436+
)?);
13911437
remove_unnecessary_projections(unified).data().map(Some)
13921438
}
13931439

@@ -1517,11 +1563,14 @@ mod tests {
15171563
use crate::test;
15181564
use crate::test::exec::StatisticsExec;
15191565

1566+
use arrow::array::StringArray;
15201567
use arrow::datatypes::{DataType, Field, Schema};
15211568
use datafusion_common::ScalarValue;
15221569
use datafusion_common::stats::{ColumnStatistics, Precision, Statistics};
15231570

1524-
use datafusion_expr::Operator;
1571+
use datafusion_expr::{Operator, ScalarUDF};
1572+
use datafusion_functions::core::arrow_metadata::ArrowMetadataFunc;
1573+
use datafusion_physical_expr::ScalarFunctionExpr;
15251574
use datafusion_physical_expr::expressions::{
15261575
BinaryExpr, Column, DynamicFilterPhysicalExpr, Literal, binary, col, lit,
15271576
};
@@ -1566,6 +1615,91 @@ mod tests {
15661615
Ok(())
15671616
}
15681617

1618+
fn identity_projection_with_metadata(
1619+
input: Arc<dyn ExecutionPlan>,
1620+
) -> Result<Arc<dyn ExecutionPlan>> {
1621+
let metadata_schema =
1622+
Schema::new_with_metadata(
1623+
vec![Field::new("i", DataType::Int32, true).with_metadata(
1624+
HashMap::from([("event_field".to_string(), "true".to_string())]),
1625+
)],
1626+
HashMap::from([("schema-key".to_string(), "schema-value".to_string())]),
1627+
);
1628+
Ok(Arc::new(ProjectionExec::try_new_with_schema_metadata(
1629+
[ProjectionExpr {
1630+
expr: Arc::new(Column::new("i", 0)),
1631+
alias: "i".to_string(),
1632+
}],
1633+
input,
1634+
&metadata_schema,
1635+
)?))
1636+
}
1637+
1638+
#[test]
1639+
fn test_metadata_projection_is_not_removable() -> Result<()> {
1640+
let projection = identity_projection_with_metadata(test::scan_partitioned(1))?;
1641+
let expected_schema = projection.schema();
1642+
1643+
let optimized = remove_unnecessary_projections(projection)?.data;
1644+
1645+
assert!(optimized.downcast_ref::<ProjectionExec>().is_some());
1646+
assert_eq!(optimized.schema(), expected_schema);
1647+
Ok(())
1648+
}
1649+
1650+
#[test]
1651+
fn test_make_with_child_preserves_output_metadata() -> Result<()> {
1652+
let projection = identity_projection_with_metadata(test::scan_partitioned(1))?;
1653+
let projection = projection
1654+
.downcast_ref::<ProjectionExec>()
1655+
.expect("test plan should be a ProjectionExec");
1656+
1657+
let rebuilt = make_with_child(projection, &test::scan_partitioned(1))?;
1658+
1659+
assert_eq!(rebuilt.schema(), projection.schema());
1660+
Ok(())
1661+
}
1662+
1663+
#[tokio::test]
1664+
async fn test_metadata_observing_parent_blocks_projection_collapse() -> Result<()> {
1665+
let inner = identity_projection_with_metadata(test::scan_partitioned(1))?;
1666+
let arrow_metadata = ScalarFunctionExpr::new(
1667+
"arrow_metadata",
1668+
Arc::new(ScalarUDF::new_from_impl(ArrowMetadataFunc::new())),
1669+
vec![
1670+
Arc::new(Column::new("i", 0)),
1671+
Arc::new(Literal::new(ScalarValue::Utf8(Some(
1672+
"event_field".to_string(),
1673+
)))),
1674+
],
1675+
Arc::new(Field::new("arrow_metadata", DataType::Utf8, true)),
1676+
Arc::new(ConfigOptions::default()),
1677+
);
1678+
let outer: Arc<dyn ExecutionPlan> = Arc::new(ProjectionExec::try_new(
1679+
[ProjectionExpr {
1680+
expr: Arc::new(arrow_metadata),
1681+
alias: "metadata".to_string(),
1682+
}],
1683+
inner,
1684+
)?);
1685+
1686+
let outer_projection = outer
1687+
.downcast_ref::<ProjectionExec>()
1688+
.expect("test plan should be a ProjectionExec");
1689+
assert!(try_collapse_projection_chain(outer_projection)?.is_none());
1690+
1691+
let optimized = remove_unnecessary_projections(outer)?.data;
1692+
let batches =
1693+
collect(optimized.execute(0, Arc::new(TaskContext::default()))?).await?;
1694+
let values = batches[0]
1695+
.column(0)
1696+
.as_any()
1697+
.downcast_ref::<StringArray>()
1698+
.expect("metadata expression should return Utf8");
1699+
assert_eq!(values.value(0), "true");
1700+
Ok(())
1701+
}
1702+
15691703
#[test]
15701704
fn test_collect_column_indices() -> Result<()> {
15711705
let expr = Arc::new(BinaryExpr::new(

0 commit comments

Comments
 (0)