From 0973cfad449676a1bf0c4b714eca653e36df955b Mon Sep 17 00:00:00 2001 From: Singleton <135771543+SingletonYe@users.noreply.github.com> Date: Wed, 21 Oct 2026 09:52:10 -0400 Subject: [PATCH] fix: keep a projection's field names when decorrelating a predicate subquery A set comparison in a projection without an alias failed with Internal error: Assertion failed: compatible: Failed due to a difference in schemas: original schema: ... Field { name: "Int64(2) > ALL", data_type: Boolean, nullable: true } new schema: ... Field { name: "CASE WHEN EXISTS THEN Boolean(false) ...", ... } This rule rebuilds a projection from its expressions, and it preserved the output field names from `Expr::schema_name()` of the expression it was given. That is not the name the rebuilt projection uses, for two reasons: `schema_name()` keeps a column's qualifier (`t.k`) while the field is named after the bare column, and earlier rules rewrite expressions with `map_expressions`, which keeps the schema but can leave an expression whose display name no longer matches its field name. `RewriteSetComparison` does exactly that -- it replaces `x > ALL (..)` with a `CASE WHEN EXISTS ..` -- so this rule rebuilt the projection under the stale name and the optimizer's "the schema is invariant under optimization" check failed. `datafusion.optimizer.skip_failed_rules` defaults to false, so an ordinary query returned an internal error asking the user to file a bug report. Take the field names from the projection's own schema, and compare them against the name the rebuilt projection would give the expression -- the bare column name for a column, the alias name for an alias, the display name otherwise -- so an alias is only added when the rebuilt name would really differ. Comparing against the schema name alone would alias a qualified column and lose its qualifier, which breaks the same check from the other side. Tested with three cases in datafusion/sqllogictest/test_files/subquery.slt, and with subquery.slt, null_aware_mark_join.slt, prepare.slt and cargo test -p datafusion-optimizer. --- .../src/decorrelate_predicate_subquery.rs | 34 ++++++++++++++++--- .../sqllogictest/test_files/subquery.slt | 20 +++++++++++ 2 files changed, 50 insertions(+), 4 deletions(-) diff --git a/datafusion/optimizer/src/decorrelate_predicate_subquery.rs b/datafusion/optimizer/src/decorrelate_predicate_subquery.rs index 99b57a5494aa8..038d0bbc1e720 100644 --- a/datafusion/optimizer/src/decorrelate_predicate_subquery.rs +++ b/datafusion/optimizer/src/decorrelate_predicate_subquery.rs @@ -56,6 +56,19 @@ impl DecorrelatePredicateSubquery { } } +/// The name a projection rebuilt from `expr` gives that expression's field. +/// +/// `Expr::schema_name` is not that name for a column reference: it keeps the +/// qualifier (`t.k`), while the field is named after the bare column (`k`) and +/// stores the qualifier separately. +fn projected_field_name(expr: &Expr) -> String { + match expr { + Expr::Alias(alias) => alias.name.clone(), + Expr::Column(column) => column.name.clone(), + other => other.schema_name().to_string(), + } +} + impl OptimizerRule for DecorrelatePredicateSubquery { fn rewrite( &self, @@ -74,10 +87,23 @@ impl OptimizerRule for DecorrelatePredicateSubquery { } let original_projection = projection.clone(); + // The projection's own schema is the authority for its output field names. + // Earlier rules rewrite expressions with `map_expressions`, which keeps the + // schema but can leave an expression whose display name no longer matches its + // field name (`RewriteSetComparison` turns `x > ALL (…)` into a `CASE` and does + // exactly that). Taking the name from the expression instead would rebuild the + // projection under a different name and fail the "schema is invariant under + // optimization" check. + let field_names = projection + .schema + .fields() + .iter() + .map(|field| field.name().to_string()) + .collect::>(); let mut cur_input = Arc::unwrap_or_clone(projection.input); let mut rewritten_exprs = Vec::with_capacity(projection.expr.len()); - for expr in projection.expr { - let original_name = expr.schema_name().to_string(); + for (index, expr) in projection.expr.into_iter().enumerate() { + let original_name = &field_names[index]; let (new_input, mut rewritten_expr) = rewrite_inner_subqueries(cur_input, expr, config, true, true)?; if has_subquery(&rewritten_expr) { @@ -87,8 +113,8 @@ impl OptimizerRule for DecorrelatePredicateSubquery { } cur_input = new_input; - if rewritten_expr.schema_name().to_string() != original_name { - rewritten_expr = rewritten_expr.alias(original_name); + if projected_field_name(&rewritten_expr) != *original_name { + rewritten_expr = rewritten_expr.alias(original_name.as_str()); } rewritten_exprs.push(rewritten_expr); } diff --git a/datafusion/sqllogictest/test_files/subquery.slt b/datafusion/sqllogictest/test_files/subquery.slt index 09f648df27702..d92a667c2447b 100644 --- a/datafusion/sqllogictest/test_files/subquery.slt +++ b/datafusion/sqllogictest/test_files/subquery.slt @@ -3575,3 +3575,23 @@ DROP TABLE unnest_outer; statement ok DROP TABLE unnest_inner; + +# A set comparison in a projection without an alias must not change the projection's +# output field name. `RewriteSetComparison` replaces `x > ALL (…)` with a `CASE` whose +# display name differs from the field name, and rebuilding the projection from the +# expression names failed the optimizer's "schema is invariant" check with an internal +# error instead of returning the answer. +query B +select 2 > all (select v from (values (1), (0)) t(v)); +---- +true + +query BI +select 2 > all (select v from (values (1), (0)) t(v)), 9 as k; +---- +true 9 + +query B +select 2 > all (select v from (values (1), (0)) t(v)) as aliased; +---- +true