Skip to main content

datafusion_optimizer/
extract_leaf_expressions.rs

1// Licensed to the Apache Software Foundation (ASF) under one
2// or more contributor license agreements.  See the NOTICE file
3// distributed with this work for additional information
4// regarding copyright ownership.  The ASF licenses this file
5// to you under the Apache License, Version 2.0 (the
6// "License"); you may not use this file except in compliance
7// with the License.  You may obtain a copy of the License at
8//
9//   http://www.apache.org/licenses/LICENSE-2.0
10//
11// Unless required by applicable law or agreed to in writing,
12// software distributed under the License is distributed on an
13// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14// KIND, either express or implied.  See the License for the
15// specific language governing permissions and limitations
16// under the License.
17
18//! Two-pass optimizer pipeline that pushes cheap expressions (like struct field
19//! access `user['status']`) closer to data sources, enabling early data reduction
20//! and source-level optimizations (e.g., Parquet column pruning). See
21//! [`ExtractLeafExpressions`] (pass 1) and [`PushDownLeafProjections`] (pass 2).
22
23use indexmap::{IndexMap, IndexSet};
24use std::collections::{BTreeSet, HashMap};
25use std::sync::Arc;
26
27use datafusion_common::alias::AliasGenerator;
28use datafusion_common::tree_node::{Transformed, TreeNode, TreeNodeRecursion};
29use datafusion_common::{Column, DFSchema, Result, qualified_name};
30use datafusion_expr::logical_plan::LogicalPlan;
31use datafusion_expr::{Expr, ExpressionPlacement, Projection};
32
33use crate::optimizer::ApplyOrder;
34use crate::push_down_filter::replace_cols_by_name;
35use crate::utils::{ColumnReference, has_all_column_refs, schema_columns};
36use crate::{OptimizerConfig, OptimizerRule};
37
38/// Prefix for aliases generated by the extraction optimizer passes.
39///
40/// This prefix is **reserved for internal optimizer use**. User-defined aliases
41/// starting with this prefix may be misidentified as optimizer-generated
42/// extraction aliases, leading to unexpected behavior. Do not use this prefix
43/// in user queries.
44const EXTRACTED_EXPR_PREFIX: &str = "__datafusion_extracted";
45
46/// Returns `true` if any sub-expression in `exprs` has
47/// [`ExpressionPlacement::MoveTowardsLeafNodes`] placement.
48///
49/// This is a lightweight pre-check that short-circuits as soon as one
50/// extractable expression is found, avoiding the expensive allocations
51/// (column HashSets, extractors, expression rewrites) that the full
52/// extraction pipeline requires.
53fn has_extractable_expr(exprs: &[Expr]) -> bool {
54    exprs.iter().any(|expr| {
55        expr.exists(|e| Ok(e.placement() == ExpressionPlacement::MoveTowardsLeafNodes))
56            .unwrap_or(false)
57    })
58}
59
60/// Extracts `MoveTowardsLeafNodes` sub-expressions from non-projection nodes
61/// into **extraction projections** (pass 1 of 2).
62///
63/// This handles Filter, Sort, Limit, Aggregate, and Join nodes. For Projection
64/// nodes, extraction and pushdown are handled by [`PushDownLeafProjections`].
65///
66/// # Key Concepts
67///
68/// **Extraction projection**: a projection inserted *below* a node that
69/// pre-computes a cheap expression and exposes it under an alias
70/// (`__datafusion_extracted_N`). The parent node then references the alias
71/// instead of the original expression.
72///
73/// **Recovery projection**: a projection inserted *above* a node to restore
74/// the original output schema when extraction changes it.
75/// Schema-preserving nodes (Filter, Sort, Limit) gain extra columns from
76/// the extraction projection that bubble up; the recovery projection selects
77/// only the original columns to hide the extras.
78///
79/// # Example
80///
81/// Given a filter with a struct field access:
82///
83/// ```text
84/// Filter: user['status'] = 'active'
85///   TableScan: t [id, user]
86/// ```
87///
88/// This rule:
89/// 1. Inserts an **extraction projection** below the filter:
90/// 2. Adds a **recovery projection** above to hide the extra column:
91///
92/// ```text
93/// Projection: id, user                                                        <-- recovery projection
94///   Filter: __datafusion_extracted_1 = 'active'
95///     Projection: user['status'] AS __datafusion_extracted_1, id, user         <-- extraction projection
96///       TableScan: t [id, user]
97/// ```
98///
99/// **Important:** The `PushDownFilter` rule is aware of projections created by this rule
100/// and will not push filters through them. It uses `ExpressionPlacement` to detect
101/// `MoveTowardsLeafNodes` expressions and skip filter pushdown past them.
102#[derive(Default, Debug)]
103pub struct ExtractLeafExpressions {}
104
105impl ExtractLeafExpressions {
106    /// Create a new [`ExtractLeafExpressions`]
107    pub fn new() -> Self {
108        Self {}
109    }
110}
111
112impl OptimizerRule for ExtractLeafExpressions {
113    fn name(&self) -> &str {
114        "extract_leaf_expressions"
115    }
116
117    fn rewrite(
118        &self,
119        plan: LogicalPlan,
120        config: &dyn OptimizerConfig,
121    ) -> Result<Transformed<LogicalPlan>> {
122        if !config.options().optimizer.enable_leaf_expression_pushdown {
123            return Ok(Transformed::no(plan));
124        }
125        let alias_generator = config.alias_generator();
126
127        // Advance the alias generator past any user-provided __datafusion_extracted_N
128        // aliases to prevent collisions when generating new extraction aliases.
129        advance_generator_past_existing(&plan, alias_generator)?;
130
131        plan.transform_down_with_subqueries(|plan| {
132            extract_from_plan(plan, alias_generator)
133        })
134    }
135}
136
137/// Scans the current plan node's expressions for pre-existing
138/// `__datafusion_extracted_N` aliases and advances the generator
139/// counter past them to avoid collisions with user-provided aliases.
140fn advance_generator_past_existing(
141    plan: &LogicalPlan,
142    alias_generator: &AliasGenerator,
143) -> Result<()> {
144    plan.apply(|plan| {
145        plan.expressions().iter().try_for_each(|expr| {
146            expr.apply(|e| {
147                if let Expr::Alias(alias) = e
148                    && let Some(id) = alias
149                        .name
150                        .strip_prefix(EXTRACTED_EXPR_PREFIX)
151                        .and_then(|s| s.strip_prefix('_'))
152                        .and_then(|s| s.parse().ok())
153                {
154                    alias_generator.update_min_id(id);
155                }
156                Ok(TreeNodeRecursion::Continue)
157            })?;
158            Ok::<(), datafusion_common::error::DataFusionError>(())
159        })?;
160        Ok(TreeNodeRecursion::Continue)
161    })
162    .map(|_| ())
163}
164
165/// Extracts `MoveTowardsLeafNodes` sub-expressions from a plan node.
166///
167/// Works for any number of inputs (0, 1, 2, …N). For multi-input nodes
168/// like Join, each extracted sub-expression is routed to the correct input
169/// by checking which input's schema contains all of the expression's column
170/// references.
171fn extract_from_plan(
172    plan: LogicalPlan,
173    alias_generator: &Arc<AliasGenerator>,
174) -> Result<Transformed<LogicalPlan>> {
175    // Only extract from plan types whose output schema is predictable after
176    // expression rewriting.  Nodes like Window derive column names from
177    // their expressions, so rewriting `get_field` inside a window function
178    // changes the output schema and breaks the recovery projection.
179    if !matches!(
180        &plan,
181        LogicalPlan::Aggregate(_)
182            | LogicalPlan::Filter(_)
183            | LogicalPlan::Sort(_)
184            | LogicalPlan::Limit(_)
185            | LogicalPlan::Join(_)
186    ) {
187        return Ok(Transformed::no(plan));
188    }
189
190    let inputs = plan.inputs();
191    if inputs.is_empty() {
192        return Ok(Transformed::no(plan));
193    }
194
195    // Fast pre-check: skip all allocations if no extractable expressions exist
196    if !has_extractable_expr(&plan.expressions()) {
197        return Ok(Transformed::no(plan));
198    }
199
200    // Save original output schema before any transformation
201    let original_schema = Arc::clone(plan.schema());
202
203    // Build per-input schemas from borrowed inputs (before plan is consumed
204    // by map_expressions). We only need schemas and column sets for routing;
205    // the actual inputs are cloned later only if extraction succeeds.
206    let input_schemas: Vec<Arc<DFSchema>> =
207        inputs.iter().map(|i| Arc::clone(i.schema())).collect();
208
209    // Build per-input extractors
210    let mut extractors: Vec<LeafExpressionExtractor> = input_schemas
211        .iter()
212        .map(|schema| LeafExpressionExtractor::new(schema.as_ref(), alias_generator))
213        .collect();
214
215    // Build per-input column sets for routing expressions to the correct input
216    let input_column_sets: Vec<std::collections::HashSet<ColumnReference>> =
217        input_schemas
218            .iter()
219            .map(|schema| schema_columns(schema.as_ref()))
220            .collect();
221
222    // Transform expressions via map_expressions with routing
223    let transformed = plan.map_expressions(|expr| {
224        routing_extract(expr, &mut extractors, &input_column_sets)
225    })?;
226
227    // If no expressions were rewritten, nothing was extracted
228    if !transformed.transformed {
229        return Ok(transformed);
230    }
231
232    // Clone inputs now that we know extraction succeeded. Wrap in Arc
233    // upfront since build_extraction_projection expects &Arc<LogicalPlan>.
234    let owned_inputs: Vec<Arc<LogicalPlan>> = transformed
235        .data
236        .inputs()
237        .into_iter()
238        .map(|i| Arc::new(i.clone()))
239        .collect();
240
241    // Build per-input extraction projections (None means no extractions for that input)
242    let new_inputs: Vec<LogicalPlan> = owned_inputs
243        .into_iter()
244        .zip(extractors.iter())
245        .map(|(input_arc, extractor)| {
246            match extractor.build_extraction_projection(&input_arc)? {
247                Some(plan) => Ok(plan),
248                // No extractions for this input — recover the LogicalPlan
249                // without cloning (refcount is 1 since build returned None).
250                None => Ok(Arc::unwrap_or_clone(input_arc)),
251            }
252        })
253        .collect::<Result<Vec<_>>>()?;
254
255    // Rebuild the plan keeping its rewritten expressions but replacing
256    // inputs with the new extraction projections.
257    let new_plan = transformed
258        .data
259        .with_new_exprs(transformed.data.expressions(), new_inputs)?;
260
261    // Add recovery projection if the output schema changed
262    let recovered = build_recovery_projection(original_schema.as_ref(), new_plan)?;
263
264    Ok(Transformed::yes(recovered))
265}
266
267/// Given an expression, returns the index of the input whose columns fully
268/// cover the expression's column references.
269/// Returns `None` if the expression references columns from multiple inputs
270/// or if multiple inputs match (ambiguous, e.g. unqualified columns present
271/// in both sides of a join).
272fn find_owning_input(
273    expr: &Expr,
274    input_column_sets: &[std::collections::HashSet<ColumnReference>],
275) -> Option<usize> {
276    let mut found = None;
277    for (idx, cols) in input_column_sets.iter().enumerate() {
278        if has_all_column_refs(expr, cols) {
279            if found.is_some() {
280                // Ambiguous — multiple inputs match
281                return None;
282            }
283            found = Some(idx);
284        }
285    }
286    found
287}
288
289/// Walks an expression tree top-down, extracting `MoveTowardsLeafNodes`
290/// sub-expressions and routing each to the correct per-input extractor.
291fn routing_extract(
292    expr: Expr,
293    extractors: &mut [LeafExpressionExtractor],
294    input_column_sets: &[std::collections::HashSet<ColumnReference>],
295) -> Result<Transformed<Expr>> {
296    expr.transform_down(|e| {
297        // Skip expressions already aliased with extracted expression pattern
298        if let Expr::Alias(alias) = &e
299            && alias.name.starts_with(EXTRACTED_EXPR_PREFIX)
300        {
301            return Ok(Transformed {
302                data: e,
303                transformed: false,
304                tnr: TreeNodeRecursion::Jump,
305            });
306        }
307
308        // Don't extract Alias nodes directly — preserve the alias and let
309        // transform_down recurse into the inner expression
310        if matches!(&e, Expr::Alias(_)) {
311            return Ok(Transformed::no(e));
312        }
313
314        match e.placement() {
315            ExpressionPlacement::MoveTowardsLeafNodes => {
316                if let Some(idx) = find_owning_input(&e, input_column_sets) {
317                    let col_ref = extractors[idx].add_extracted(e)?;
318                    Ok(Transformed::yes(col_ref))
319                } else {
320                    // References columns from multiple inputs — cannot extract
321                    Ok(Transformed::no(e))
322                }
323            }
324            ExpressionPlacement::Column => {
325                // Track columns that the parent node references so the
326                // extraction projection includes them as pass-through.
327                // Without this, the extraction projection would only
328                // contain __datafusion_extracted_N aliases, and the parent couldn't
329                // resolve its other column references.
330                if let Expr::Column(col) = &e
331                    && let Some(idx) = find_owning_input(&e, input_column_sets)
332                {
333                    extractors[idx].columns_needed.insert(col.clone());
334                }
335                Ok(Transformed::no(e))
336            }
337            _ => Ok(Transformed::no(e)),
338        }
339    })
340}
341
342/// Rewrites extraction pairs and column references from one qualifier
343/// space to another.
344///
345/// Builds a replacement map by zipping `from_schema` (whose qualifiers
346/// currently appear in `pairs` / `columns`) with `to_schema` (the
347/// qualifiers we want), then applies `replace_cols_by_name`.
348///
349/// Used for SubqueryAlias (alias-space -> input-space) and Union
350/// (union output-space -> per-branch input-space).
351fn remap_pairs_and_columns(
352    pairs: &[(Expr, String)],
353    columns: &IndexSet<Column>,
354    from_schema: &DFSchema,
355    to_schema: &DFSchema,
356) -> Result<ExtractionTarget> {
357    let mut replace_map = HashMap::new();
358    for ((from_q, from_f), (to_q, to_f)) in from_schema.iter().zip(to_schema.iter()) {
359        replace_map.insert(
360            qualified_name(from_q, from_f.name()),
361            Expr::Column(Column::new(to_q.cloned(), to_f.name())),
362        );
363    }
364    let remapped_pairs: Vec<(Expr, String)> = pairs
365        .iter()
366        .map(|(expr, alias)| {
367            Ok((
368                replace_cols_by_name(expr.clone(), &replace_map)?,
369                alias.clone(),
370            ))
371        })
372        .collect::<Result<_>>()?;
373    let remapped_columns: IndexSet<Column> = columns
374        .iter()
375        .filter_map(|col| {
376            let rewritten =
377                replace_cols_by_name(Expr::Column(col.clone()), &replace_map).ok()?;
378            if let Expr::Column(c) = rewritten {
379                Some(c)
380            } else {
381                Some(col.clone())
382            }
383        })
384        .collect();
385    Ok(ExtractionTarget {
386        pairs: remapped_pairs,
387        columns: remapped_columns,
388    })
389}
390
391// =============================================================================
392// Helper Types & Functions for Extraction Targeting
393// =============================================================================
394
395/// A bundle of extraction pairs (expression + alias) and standalone columns
396/// that need to be pushed through a plan node.
397struct ExtractionTarget {
398    /// Extracted expressions paired with their generated aliases.
399    pairs: Vec<(Expr, String)>,
400    /// Standalone column references needed by the parent node.
401    columns: IndexSet<Column>,
402}
403
404/// Build a replacement map from a projection: output_column_name -> underlying_expr.
405///
406/// This is used to resolve column references through a renaming projection.
407/// For example, if a projection has `user AS x`, this maps `x` -> `col("user")`.
408fn build_projection_replace_map(projection: &Projection) -> HashMap<String, Expr> {
409    projection
410        .schema
411        .iter()
412        .zip(projection.expr.iter())
413        .map(|((qualifier, field), expr)| {
414            let key = Column::from((qualifier, field)).flat_name();
415            (key, expr.clone().unalias())
416        })
417        .collect()
418}
419
420/// Build a recovery projection to restore the original output schema.
421///
422/// After extraction, a node's output schema may differ from the original:
423///
424/// - **Schema-preserving nodes** (Filter/Sort/Limit): the extraction projection
425///   below adds extra `__datafusion_extracted_N` columns that bubble up through
426///   the node. Recovery selects only the original columns to hide the extras.
427///   ```text
428///   Original schema: [id, user]
429///   After extraction: [__datafusion_extracted_1, id, user]   ← extra column leaked through
430///   Recovery: SELECT id, user FROM ...                       ← hides __datafusion_extracted_1
431///   ```
432///
433/// - **Schema-defining nodes** (Aggregate): same number of columns but names
434///   may differ because extracted aliases replaced the original expressions.
435///   Recovery maps positionally, aliasing where names changed.
436///   ```text
437///   Original: [SUM(user['balance'])]
438///   After:    [SUM(__datafusion_extracted_1)]                ← name changed
439///   Recovery: SUM(__datafusion_extracted_1) AS "SUM(user['balance'])"
440///   ```
441///
442/// - **Schemas identical** → no recovery projection needed.
443fn build_recovery_projection(
444    original_schema: &DFSchema,
445    input: LogicalPlan,
446) -> Result<LogicalPlan> {
447    let new_schema = input.schema();
448    let orig_len = original_schema.fields().len();
449    let new_len = new_schema.fields().len();
450
451    if orig_len == new_len {
452        // Same number of fields — check if schemas are identical
453        let schemas_match = original_schema.iter().zip(new_schema.iter()).all(
454            |((orig_q, orig_f), (new_q, new_f))| {
455                orig_f.name() == new_f.name() && orig_q == new_q
456            },
457        );
458        if schemas_match {
459            return Ok(input);
460        }
461
462        // Schema-defining nodes (Aggregate, Join): names may differ at some
463        // positions because extracted aliases replaced the original expressions.
464        // Map positionally, aliasing where the name changed.
465        //
466        // Invariant: `with_new_exprs` on all supported node types (Aggregate,
467        // Filter, Sort, Limit, Join) preserves column order, so positional
468        // mapping is safe here.
469        debug_assert!(
470            orig_len == new_len,
471            "build_recovery_projection: positional mapping requires same field count, \
472             got original={orig_len} vs new={new_len}"
473        );
474        let mut proj_exprs = Vec::with_capacity(orig_len);
475        for (i, (orig_qualifier, orig_field)) in original_schema.iter().enumerate() {
476            let (new_qualifier, new_field) = new_schema.qualified_field(i);
477            if orig_field.name() == new_field.name() && orig_qualifier == new_qualifier {
478                proj_exprs.push(Expr::from((orig_qualifier, orig_field)));
479            } else {
480                let new_col = Expr::Column(Column::from((new_qualifier, new_field)));
481                proj_exprs.push(
482                    new_col.alias_qualified(orig_qualifier.cloned(), orig_field.name()),
483                );
484            }
485        }
486        let projection = Projection::try_new(proj_exprs, Arc::new(input))?;
487        Ok(LogicalPlan::Projection(projection))
488    } else {
489        // Schema-preserving nodes: new schema has extra extraction columns.
490        // Original columns still exist by name; select them to hide extras.
491        let col_exprs: Vec<Expr> = original_schema.iter().map(Expr::from).collect();
492        let projection = Projection::try_new(col_exprs, Arc::new(input))?;
493        Ok(LogicalPlan::Projection(projection))
494    }
495}
496
497/// Collects `MoveTowardsLeafNodes` sub-expressions found during expression
498/// tree traversal and can build an extraction projection from them.
499///
500/// # Example
501///
502/// Given `Filter: user['status'] = 'active' AND user['name'] IS NOT NULL`:
503/// - `add_extracted(user['status'])` → stores it, returns `col("__datafusion_extracted_1")`
504/// - `add_extracted(user['name'])`   → stores it, returns `col("__datafusion_extracted_2")`
505/// - `build_extraction_projection()` produces:
506///   `Projection: user['status'] AS __datafusion_extracted_1, user['name'] AS __datafusion_extracted_2, <all input columns>`
507struct LeafExpressionExtractor<'a> {
508    /// Extracted expressions: maps expression -> alias
509    extracted: IndexMap<Expr, String>,
510    /// Columns referenced by extracted expressions or the parent node,
511    /// included as pass-through in the extraction projection.
512    columns_needed: IndexSet<Column>,
513    /// Input schema
514    input_schema: &'a DFSchema,
515    /// Alias generator
516    alias_generator: &'a Arc<AliasGenerator>,
517}
518
519impl<'a> LeafExpressionExtractor<'a> {
520    fn new(input_schema: &'a DFSchema, alias_generator: &'a Arc<AliasGenerator>) -> Self {
521        Self {
522            extracted: IndexMap::new(),
523            columns_needed: IndexSet::new(),
524            input_schema,
525            alias_generator,
526        }
527    }
528
529    /// Adds an expression to extracted set, returns column reference.
530    fn add_extracted(&mut self, expr: Expr) -> Result<Expr> {
531        // Deduplication: reuse existing alias if same expression
532        if let Some(alias) = self.extracted.get(&expr) {
533            return Ok(Expr::Column(Column::new_unqualified(alias)));
534        }
535
536        // Track columns referenced by this expression
537        for col in expr.column_refs() {
538            self.columns_needed.insert(col.clone());
539        }
540
541        // Generate unique alias
542        let alias = self.alias_generator.next(EXTRACTED_EXPR_PREFIX);
543        self.extracted.insert(expr, alias.clone());
544
545        Ok(Expr::Column(Column::new_unqualified(&alias)))
546    }
547
548    /// Builds an extraction projection above the given input, or merges into
549    /// it if the input is already a projection. Delegates to
550    /// [`build_extraction_projection_impl`].
551    ///
552    /// Returns `None` if there are no extractions.
553    fn build_extraction_projection(
554        &self,
555        input: &Arc<LogicalPlan>,
556    ) -> Result<Option<LogicalPlan>> {
557        if self.extracted.is_empty() {
558            return Ok(None);
559        }
560        let pairs: Vec<(Expr, String)> = self
561            .extracted
562            .iter()
563            .map(|(e, a)| (e.clone(), a.clone()))
564            .collect();
565        let proj = build_extraction_projection_impl(
566            &pairs,
567            &self.columns_needed,
568            input,
569            self.input_schema,
570        )?;
571        Ok(Some(LogicalPlan::Projection(proj)))
572    }
573}
574
575/// Build an extraction projection above the target node (shared by both passes).
576///
577/// If the target is an existing projection, merges into it. This requires
578/// resolving column references through the projection's rename mapping:
579/// if the projection has `user AS u`, and an extracted expression references
580/// `u['name']`, we must rewrite it to `user['name']` since the merged
581/// projection reads from the same input as the original.
582///
583/// Deduplicates by resolved expression equality and adds pass-through
584/// columns as needed. Otherwise builds a fresh projection with extracted
585/// expressions + ALL input schema columns.
586fn build_extraction_projection_impl(
587    extracted_exprs: &[(Expr, String)],
588    columns_needed: &IndexSet<Column>,
589    target: &Arc<LogicalPlan>,
590    target_schema: &DFSchema,
591) -> Result<Projection> {
592    if let LogicalPlan::Projection(existing) = target.as_ref() {
593        // Merge into existing projection
594        let mut proj_exprs = existing.expr.clone();
595
596        // Build a map of existing expressions (by Expr equality) to their aliases
597        let existing_extractions: IndexMap<Expr, String> = existing
598            .expr
599            .iter()
600            .filter_map(|e| {
601                if let Expr::Alias(alias) = e
602                    && alias.name.starts_with(EXTRACTED_EXPR_PREFIX)
603                {
604                    return Some((*alias.expr.clone(), alias.name.clone()));
605                }
606                None
607            })
608            .collect();
609
610        // Resolve column references through the projection's rename mapping
611        let replace_map = build_projection_replace_map(existing);
612
613        // Add new extracted expressions, resolving column refs through the projection
614        for (expr, alias) in extracted_exprs {
615            let resolved = replace_cols_by_name(expr.clone().alias(alias), &replace_map)?;
616            let resolved_inner = if let Expr::Alias(a) = &resolved {
617                a.expr.as_ref()
618            } else {
619                &resolved
620            };
621            if let Some(existing_alias) = existing_extractions.get(resolved_inner) {
622                // Same expression already extracted under a different alias —
623                // add the expression with the new alias so both names are
624                // available in the output. We can't reference the existing alias
625                // as a column within the same projection, so we duplicate the
626                // computation.
627                if existing_alias != alias {
628                    proj_exprs.push(resolved);
629                }
630            } else {
631                proj_exprs.push(resolved);
632            }
633        }
634
635        // Add any new pass-through columns that aren't already in the projection.
636        // We check against existing.input.schema() (the projection's source) rather
637        // than target_schema (the projection's output) because columns produced
638        // by alias expressions (e.g., CSE's __common_expr_N) exist in the output but
639        // not the input, and cannot be added as pass-through Column references.
640        let existing_cols: IndexSet<Column> = existing
641            .expr
642            .iter()
643            .filter_map(|e| {
644                if let Expr::Column(c) = e {
645                    Some(c.clone())
646                } else {
647                    None
648                }
649            })
650            .collect();
651
652        let input_schema = existing.input.schema();
653        for col in columns_needed {
654            let col_expr = Expr::Column(col.clone());
655            let resolved = replace_cols_by_name(col_expr, &replace_map)?;
656            if let Expr::Column(resolved_col) = &resolved
657                && !existing_cols.contains(resolved_col)
658                && input_schema.has_column(resolved_col)
659            {
660                proj_exprs.push(Expr::Column(resolved_col.clone()));
661            }
662            // If resolved to non-column expr, it's already computed by existing projection
663        }
664
665        Projection::try_new(proj_exprs, Arc::clone(&existing.input))
666    } else {
667        // Build new projection with extracted expressions + all input columns
668        let mut proj_exprs = Vec::new();
669        for (expr, alias) in extracted_exprs {
670            proj_exprs.push(expr.clone().alias(alias));
671        }
672        for (qualifier, field) in target_schema.iter() {
673            proj_exprs.push(Expr::from((qualifier, field)));
674        }
675        Projection::try_new(proj_exprs, Arc::clone(target))
676    }
677}
678
679// =============================================================================
680// Pass 2: PushDownLeafProjections
681// =============================================================================
682
683/// Pushes extraction projections down through schema-preserving nodes towards
684/// leaf nodes (pass 2 of 2, after [`ExtractLeafExpressions`]).
685///
686/// Handles two types of projections:
687/// - **Pure extraction projections** (all `__datafusion_extracted` aliases + columns):
688///   pushes through Filter/Sort/Limit, merges into existing projections, or routes
689///   into multi-input node inputs (Join, SubqueryAlias, etc.)
690/// - **Mixed projections** (user projections containing `MoveTowardsLeafNodes`
691///   sub-expressions): splits into a recovery projection + extraction projection,
692///   then pushes the extraction projection down.
693///
694/// # Example: Pushing through a Filter
695///
696/// After pass 1, the extraction projection sits directly below the filter:
697/// ```text
698/// Projection: id, user                                                              <-- recovery
699///   Filter: __datafusion_extracted_1 = 'active'
700///     Projection: user['status'] AS __datafusion_extracted_1, id, user               <-- extraction
701///       TableScan: t [id, user]
702/// ```
703///
704/// Pass 2 pushes the extraction projection through the recovery and filter,
705/// and a subsequent `OptimizeProjections` pass removes the (now-redundant)
706/// recovery projection:
707/// ```text
708/// Filter: __datafusion_extracted_1 = 'active'
709///   Projection: user['status'] AS __datafusion_extracted_1, id, user                 <-- extraction (pushed down)
710///     TableScan: t [id, user]
711/// ```
712#[derive(Default, Debug)]
713pub struct PushDownLeafProjections {}
714
715impl PushDownLeafProjections {
716    pub fn new() -> Self {
717        Self {}
718    }
719}
720
721impl OptimizerRule for PushDownLeafProjections {
722    fn name(&self) -> &str {
723        "push_down_leaf_projections"
724    }
725
726    fn apply_order(&self) -> Option<ApplyOrder> {
727        Some(ApplyOrder::TopDown)
728    }
729
730    fn rewrite(
731        &self,
732        plan: LogicalPlan,
733        config: &dyn OptimizerConfig,
734    ) -> Result<Transformed<LogicalPlan>> {
735        if !config.options().optimizer.enable_leaf_expression_pushdown {
736            return Ok(Transformed::no(plan));
737        }
738        let alias_generator = config.alias_generator();
739        match try_push_input(&plan, alias_generator)? {
740            Some(new_plan) => Ok(Transformed::yes(new_plan)),
741            None => Ok(Transformed::no(plan)),
742        }
743    }
744}
745
746/// Attempts to push a projection's extractable expressions further down.
747///
748/// Returns `Some(new_subtree)` if the projection was pushed down or merged,
749/// `None` if there is nothing to push or the projection sits above a barrier.
750fn try_push_input(
751    input: &LogicalPlan,
752    alias_generator: &Arc<AliasGenerator>,
753) -> Result<Option<LogicalPlan>> {
754    let LogicalPlan::Projection(proj) = input else {
755        return Ok(None);
756    };
757    split_and_push_projection(proj, alias_generator)
758}
759
760/// Splits a projection into extractable pieces, pushes them towards leaf
761/// nodes, and adds a recovery projection if needed.
762///
763/// Handles both:
764/// - **Pure extraction projections** (all `__datafusion_extracted` aliases + columns)
765/// - **Mixed projections** (containing `MoveTowardsLeafNodes` sub-expressions)
766///
767/// Returns `Some(new_subtree)` if extractions were pushed down,
768/// `None` if there is nothing to extract or push.
769///
770/// # Example: Mixed Projection
771///
772/// ```text
773/// Input plan:
774///   Projection: user['name'] IS NOT NULL AS has_name, id
775///     Filter: ...
776///       TableScan
777///
778/// Phase 1 (Split):
779///   extraction_pairs: [(user['name'], "__datafusion_extracted_1")]
780///   recovery_exprs:   [__datafusion_extracted_1 IS NOT NULL AS has_name, id]
781///
782/// Phase 2 (Push):
783///   Push extraction projection through Filter toward TableScan
784///
785/// Phase 3 (Recovery):
786///   Projection: __datafusion_extracted_1 IS NOT NULL AS has_name, id       <-- recovery
787///     Filter: ...
788///       Projection: user['name'] AS __datafusion_extracted_1, id           <-- extraction (pushed)
789///         TableScan
790/// ```
791fn split_and_push_projection(
792    proj: &Projection,
793    alias_generator: &Arc<AliasGenerator>,
794) -> Result<Option<LogicalPlan>> {
795    // Fast pre-check: skip if there are no pre-existing extracted aliases
796    // and no new extractable expressions.
797    let has_existing_extracted = proj.expr.iter().any(|e| {
798        matches!(e, Expr::Alias(alias) if alias.name.starts_with(EXTRACTED_EXPR_PREFIX))
799    });
800    if !has_existing_extracted && !has_extractable_expr(&proj.expr) {
801        return Ok(None);
802    }
803
804    let input = &proj.input;
805    let input_schema = input.schema();
806
807    // ── Phase 1: Split ──────────────────────────────────────────────────
808    // For each projection expression, collect extraction pairs and build
809    // recovery expressions.
810    //
811    // Pre-existing `__datafusion_extracted` aliases are inserted into the
812    // extractor's `IndexMap` with the **full** `Expr::Alias(…)` as the key,
813    // so the alias name participates in equality. This prevents collisions
814    // when CSE rewrites produce the same inner expression under different
815    // alias names (e.g. `__common_expr_4 AS __datafusion_extracted_1` and
816    // `__common_expr_4 AS __datafusion_extracted_3`). New extractions from
817    // `routing_extract` use bare (non-Alias) keys and get normal dedup.
818    //
819    // When building the final `extraction_pairs`, the Alias wrapper is
820    // stripped so consumers see the usual `(inner_expr, alias_name)` tuples.
821
822    let mut extractors = vec![LeafExpressionExtractor::new(
823        input_schema.as_ref(),
824        alias_generator,
825    )];
826    let input_column_sets = vec![schema_columns(input_schema.as_ref())];
827
828    let original_schema = proj.schema.as_ref();
829    let mut recovery_exprs: Vec<Expr> = Vec::with_capacity(proj.expr.len());
830    let mut has_new_extractions = false;
831    let mut proj_exprs_captured: usize = 0;
832
833    for (expr, (qualifier, field)) in proj.expr.iter().zip(original_schema.iter()) {
834        if let Expr::Alias(alias) = expr
835            && alias.name.starts_with(EXTRACTED_EXPR_PREFIX)
836        {
837            // Insert the full Alias expression as the key so that
838            // distinct alias names don't collide in the IndexMap.
839            let alias_name = alias.name.clone();
840
841            for col_ref in alias.expr.column_refs() {
842                extractors[0].columns_needed.insert(col_ref.clone());
843            }
844
845            extractors[0]
846                .extracted
847                .insert(expr.clone(), alias_name.clone());
848            recovery_exprs.push(Expr::Column(Column::new_unqualified(&alias_name)));
849            proj_exprs_captured += 1;
850        } else if let Expr::Column(col) = expr {
851            // Plain column pass-through — track it in the extractor
852            extractors[0].columns_needed.insert(col.clone());
853            recovery_exprs.push(expr.clone());
854            proj_exprs_captured += 1;
855        } else {
856            // Everything else: run through routing_extract
857            let transformed =
858                routing_extract(expr.clone(), &mut extractors, &input_column_sets)?;
859            if transformed.transformed {
860                has_new_extractions = true;
861            }
862            let transformed_expr = transformed.data;
863
864            // Build recovery expression, aliasing back to original name if needed
865            let original_name = field.name();
866            let needs_alias = if let Expr::Column(col) = &transformed_expr {
867                col.name.as_str() != original_name
868            } else {
869                let expr_name = transformed_expr.schema_name().to_string();
870                original_name != &expr_name
871            };
872            let recovery_expr = if needs_alias {
873                transformed_expr
874                    .clone()
875                    .alias_qualified(qualifier.cloned(), original_name)
876            } else {
877                transformed_expr.clone()
878            };
879
880            recovery_exprs.push(recovery_expr);
881        }
882    }
883
884    // Build extraction_pairs, stripping the Alias wrapper from pre-existing
885    // entries (they used the full Alias as the map key to avoid dedup).
886    let extractor = &extractors[0];
887    let extraction_pairs: Vec<(Expr, String)> = extractor
888        .extracted
889        .iter()
890        .map(|(e, a)| match e {
891            Expr::Alias(alias) => (*alias.expr.clone(), a.clone()),
892            _ => (e.clone(), a.clone()),
893        })
894        .collect();
895    let columns_needed = &extractor.columns_needed;
896
897    // If no extractions found, nothing to do
898    if extraction_pairs.is_empty() {
899        return Ok(None);
900    }
901
902    // ── Phase 2: Push down ──────────────────────────────────────────────
903    let proj_input = Arc::clone(&proj.input);
904    let pushed = push_extraction_pairs(
905        &extraction_pairs,
906        columns_needed,
907        proj,
908        &proj_input,
909        alias_generator,
910        proj_exprs_captured,
911    )?;
912
913    // ── Phase 3: Recovery ───────────────────────────────────────────────
914    // Determine the base plan: either the pushed result or an in-place extraction.
915    let base_plan = match pushed {
916        Some(plan) => plan,
917        None => {
918            if !has_new_extractions {
919                // Only pre-existing __datafusion_extracted aliases and columns, no new
920                // extractions from routing_extract. The original projection is
921                // already an extraction projection that couldn't be pushed
922                // further. Return None.
923                return Ok(None);
924            }
925            // Build extraction projection in-place (couldn't push down)
926            let input_arc = Arc::clone(input);
927            let extraction = build_extraction_projection_impl(
928                &extraction_pairs,
929                columns_needed,
930                &input_arc,
931                input_schema.as_ref(),
932            )?;
933            LogicalPlan::Projection(extraction)
934        }
935    };
936
937    // The recovery projection restores the original projection's output. We need
938    // it whenever `base_plan` no longer exposes the same set of output column
939    // names, which happens two ways:
940    //   * a column is *renamed* — a transformed expression now surfaces as its
941    //     internal `__datafusion_extracted_*` alias instead of the original name;
942    //   * a column is *leaked* — pushing the projection down widens `base_plan`
943    //     with an inner extraction projection's *other* extracted aliases bubbling
944    //     up through a Filter. A schema-caching parent like SubqueryAlias then
945    //     keeps a stale schema (see `map_children` in `logical_plan/tree_node.rs`)
946    //     and the later `optimize_projections` pass fails to resolve columns.
947    //
948    // Both are captured by comparing the *set of unqualified field names*. We
949    // compare by unqualified name rather than the full qualified schema on
950    // purpose: extracted aliases are globally unique, so name-only comparison is
951    // unambiguous for them, while it ignores the benign column reordering and the
952    // `SubqueryAlias` re-qualification (`sub.__datafusion_extracted_1` vs
953    // `__datafusion_extracted_1`) that a qualified/ordered comparison would
954    // spuriously treat as drift, stacking redundant recovery projections.
955    let base_names: BTreeSet<&str> = base_plan
956        .schema()
957        .fields()
958        .iter()
959        .map(|f| f.name().as_str())
960        .collect();
961    let original_names: BTreeSet<&str> = original_schema
962        .fields()
963        .iter()
964        .map(|f| f.name().as_str())
965        .collect();
966    let needs_recovery = base_names != original_names;
967
968    // Wrap with recovery projection if the output schema changed
969    if needs_recovery {
970        let recovery = LogicalPlan::Projection(Projection::try_new(
971            recovery_exprs,
972            Arc::new(base_plan),
973        )?);
974        Ok(Some(recovery))
975    } else {
976        Ok(Some(base_plan))
977    }
978}
979
980/// Returns true if the plan is a Projection where ALL expressions are either
981/// `Alias(EXTRACTED_EXPR_PREFIX, ...)` or `Column`, with at least one extraction.
982/// Such projections can safely be pushed further without re-extraction.
983fn is_pure_extraction_projection(plan: &LogicalPlan) -> bool {
984    let LogicalPlan::Projection(proj) = plan else {
985        return false;
986    };
987    let mut has_extraction = false;
988    for expr in &proj.expr {
989        match expr {
990            Expr::Alias(alias) if alias.name.starts_with(EXTRACTED_EXPR_PREFIX) => {
991                has_extraction = true;
992            }
993            Expr::Column(_) => {}
994            _ => return false,
995        }
996    }
997    has_extraction
998}
999
1000/// Pushes extraction pairs down through the projection's input node,
1001/// dispatching to the appropriate handler based on the input node type.
1002fn push_extraction_pairs(
1003    pairs: &[(Expr, String)],
1004    columns_needed: &IndexSet<Column>,
1005    proj: &Projection,
1006    proj_input: &Arc<LogicalPlan>,
1007    alias_generator: &Arc<AliasGenerator>,
1008    proj_exprs_captured: usize,
1009) -> Result<Option<LogicalPlan>> {
1010    match proj_input.as_ref() {
1011        // Merge into existing projection, then try to push the result further down.
1012        // Only merge when every expression in the outer projection is fully
1013        // captured as either an extraction pair (Case A: __datafusion_extracted
1014        // alias) or a plain column (Case B). Uncaptured expressions (e.g.
1015        // `col AS __common_expr_1` from CSE, or complex expressions with
1016        // extracted sub-parts) would be lost during the merge.
1017        LogicalPlan::Projection(_) if proj_exprs_captured == proj.expr.len() => {
1018            let target_schema = Arc::clone(proj_input.schema());
1019            let merged = build_extraction_projection_impl(
1020                pairs,
1021                columns_needed,
1022                proj_input,
1023                target_schema.as_ref(),
1024            )?;
1025            let merged_plan = LogicalPlan::Projection(merged);
1026
1027            // After merging, try to push the result further down, but ONLY
1028            // if the merged result is still a pure extraction projection
1029            // (all __datafusion_extracted aliases + columns). If the merge inherited
1030            // bare MoveTowardsLeafNodes expressions from the inner projection,
1031            // pushing would re-extract them into new aliases and fail when
1032            // the (None, true) fallback can't find the original aliases.
1033            // This handles: Extraction → Recovery(cols) → Filter → ... → TableScan
1034            // by pushing through the recovery projection AND the filter in one pass.
1035            if is_pure_extraction_projection(&merged_plan)
1036                && let Some(pushed) = try_push_input(&merged_plan, alias_generator)?
1037            {
1038                return Ok(Some(pushed));
1039            }
1040            Ok(Some(merged_plan))
1041        }
1042        // Generic: handles Filter/Sort/Limit (via recursion),
1043        // SubqueryAlias (with qualifier remap in try_push_into_inputs),
1044        // Join, and anything else.
1045        // Safely bails out for nodes that don't pass through extracted
1046        // columns (Aggregate, Window) via the output schema check.
1047        _ => try_push_into_inputs(
1048            pairs,
1049            columns_needed,
1050            proj_input.as_ref(),
1051            alias_generator,
1052        ),
1053    }
1054}
1055
1056/// Routes extraction pairs and columns to the appropriate inputs.
1057///
1058/// - **Union**: broadcasts to every input via [`remap_pairs_and_columns`].
1059/// - **Other nodes**: routes each expression to the one input that owns
1060///   all of its column references (via [`find_owning_input`]).
1061///
1062/// Returns `None` if any expression can't be routed or no input has pairs.
1063fn route_to_inputs(
1064    pairs: &[(Expr, String)],
1065    columns: &IndexSet<Column>,
1066    node: &LogicalPlan,
1067    input_column_sets: &[std::collections::HashSet<ColumnReference>],
1068    input_schemas: &[Arc<DFSchema>],
1069) -> Result<Option<Vec<ExtractionTarget>>> {
1070    let num_inputs = input_schemas.len();
1071    let mut per_input: Vec<ExtractionTarget> = (0..num_inputs)
1072        .map(|_| ExtractionTarget {
1073            pairs: vec![],
1074            columns: IndexSet::new(),
1075        })
1076        .collect();
1077
1078    if matches!(node, LogicalPlan::Union(_)) {
1079        // Union output schema and each input schema have the same fields by
1080        // index but may differ in qualifiers (e.g. output `s` vs input
1081        // `simple_struct.s`). Remap pairs/columns to each input's space.
1082        let union_schema = node.schema();
1083        for (idx, input_schema) in input_schemas.iter().enumerate() {
1084            per_input[idx] =
1085                remap_pairs_and_columns(pairs, columns, union_schema, input_schema)?;
1086        }
1087    } else {
1088        for (expr, alias) in pairs {
1089            match find_owning_input(expr, input_column_sets) {
1090                Some(idx) => per_input[idx].pairs.push((expr.clone(), alias.clone())),
1091                None => return Ok(None), // Cross-input expression — bail out
1092            }
1093        }
1094        for col in columns {
1095            let col_expr = Expr::Column(col.clone());
1096            match find_owning_input(&col_expr, input_column_sets) {
1097                Some(idx) => {
1098                    per_input[idx].columns.insert(col.clone());
1099                }
1100                None => return Ok(None), // Ambiguous column — bail out
1101            }
1102        }
1103    }
1104
1105    // Check at least one input has extractions to push
1106    if per_input.iter().all(|t| t.pairs.is_empty()) {
1107        return Ok(None);
1108    }
1109
1110    Ok(Some(per_input))
1111}
1112
1113/// Pushes extraction expressions into a node's inputs by routing each
1114/// expression to the input that owns all of its column references.
1115///
1116/// Works for any number of inputs (1, 2, …N). For single-input nodes,
1117/// all expressions trivially route to that input. For multi-input nodes
1118/// (Join, etc.), each expression is routed to the side that owns its columns.
1119///
1120/// Returns `Some(new_node)` if all expressions could be routed AND the
1121/// rebuilt node's output schema contains all extracted aliases.
1122/// Returns `None` if any expression references columns from multiple inputs
1123/// or the node doesn't pass through the extracted columns.
1124///
1125/// # Example: Join with expressions from both sides
1126///
1127/// ```text
1128/// Extraction projection above a Join:
1129///   Projection: left.user['name'] AS __datafusion_extracted_1, right.order['total'] AS __datafusion_extracted_2, ...
1130///     Join: left.id = right.user_id
1131///       TableScan: left [id, user]
1132///       TableScan: right [user_id, order]
1133///
1134/// After routing each expression to its owning input:
1135///   Join: left.id = right.user_id
1136///     Projection: user['name'] AS __datafusion_extracted_1, id, user              <-- left-side extraction
1137///       TableScan: left [id, user]
1138///     Projection: order['total'] AS __datafusion_extracted_2, user_id, order      <-- right-side extraction
1139///       TableScan: right [user_id, order]
1140/// ```
1141fn try_push_into_inputs(
1142    pairs: &[(Expr, String)],
1143    columns_needed: &IndexSet<Column>,
1144    node: &LogicalPlan,
1145    alias_generator: &Arc<AliasGenerator>,
1146) -> Result<Option<LogicalPlan>> {
1147    let inputs = node.inputs();
1148    if inputs.is_empty() {
1149        return Ok(None);
1150    }
1151
1152    // Unnest may output a column with the same name but different value/type
1153    // than its input column. Name-based routing cannot distinguish those.
1154    if matches!(node, LogicalPlan::Unnest(_)) {
1155        return Ok(None);
1156    }
1157
1158    // SubqueryAlias remaps qualifiers between input and output.
1159    // Rewrite pairs/columns from alias-space to input-space before routing.
1160    let remapped = if let LogicalPlan::SubqueryAlias(sa) = node {
1161        remap_pairs_and_columns(pairs, columns_needed, &sa.schema, sa.input.schema())?
1162    } else {
1163        ExtractionTarget {
1164            pairs: pairs.to_vec(),
1165            columns: columns_needed.clone(),
1166        }
1167    };
1168    let pairs = &remapped.pairs[..];
1169    let columns_needed = &remapped.columns;
1170
1171    // Build per-input schemas and column sets for routing
1172    let input_schemas: Vec<Arc<DFSchema>> =
1173        inputs.iter().map(|i| Arc::clone(i.schema())).collect();
1174    let input_column_sets: Vec<std::collections::HashSet<ColumnReference>> =
1175        input_schemas.iter().map(|s| schema_columns(s)).collect();
1176
1177    // Route pairs and columns to the appropriate inputs
1178    let per_input = match route_to_inputs(
1179        pairs,
1180        columns_needed,
1181        node,
1182        &input_column_sets,
1183        &input_schemas,
1184    )? {
1185        Some(routed) => routed,
1186        None => return Ok(None),
1187    };
1188
1189    let num_inputs = inputs.len();
1190
1191    // Build per-input extraction projections and push them as far as possible
1192    // immediately. This is critical because map_children preserves cached schemas,
1193    // so if the TopDown pass later pushes a child further (changing its output
1194    // schema), the parent node's schema becomes stale.
1195    let mut new_inputs: Vec<LogicalPlan> = Vec::with_capacity(num_inputs);
1196    for (idx, input) in inputs.into_iter().enumerate() {
1197        if per_input[idx].pairs.is_empty() {
1198            new_inputs.push(input.clone());
1199        } else {
1200            let input_arc = Arc::new(input.clone());
1201            let target_schema = Arc::clone(input.schema());
1202            let proj = build_extraction_projection_impl(
1203                &per_input[idx].pairs,
1204                &per_input[idx].columns,
1205                &input_arc,
1206                target_schema.as_ref(),
1207            )?;
1208            // Verify all requested aliases appear in the projection's output.
1209            // A merge may deduplicate if the same expression already exists
1210            // under a different alias, leaving the requested alias missing.
1211            let proj_schema = proj.schema.as_ref();
1212            for (_expr, alias) in &per_input[idx].pairs {
1213                if !proj_schema.fields().iter().any(|f| f.name() == alias) {
1214                    return Ok(None);
1215                }
1216            }
1217            let proj_plan = LogicalPlan::Projection(proj);
1218            // Try to push the extraction projection further down within
1219            // this input (e.g., through Filter → existing extraction projection).
1220            // This ensures the input's output schema is stable and won't change
1221            // when the TopDown pass later visits children.
1222            match try_push_input(&proj_plan, alias_generator)? {
1223                Some(pushed) => new_inputs.push(pushed),
1224                None => new_inputs.push(proj_plan),
1225            }
1226        }
1227    }
1228
1229    // Rebuild the node with new inputs
1230    let new_node = node.with_new_exprs(node.expressions(), new_inputs)?;
1231
1232    // Safety check: verify all extracted aliases appear in the rebuilt
1233    // node's output schema. Nodes like Aggregate define their own output
1234    // and won't pass through extracted columns — bail out for those.
1235    let output_schema = new_node.schema();
1236    for (_expr, alias) in pairs {
1237        if !output_schema.fields().iter().any(|f| f.name() == alias) {
1238            return Ok(None);
1239        }
1240    }
1241
1242    Ok(Some(new_node))
1243}
1244
1245#[cfg(test)]
1246mod tests {
1247
1248    use super::*;
1249    use crate::optimize_projections::OptimizeProjections;
1250    use crate::test::udfs::PlacementTestUDF;
1251    use crate::test::*;
1252    use crate::{Optimizer, OptimizerContext};
1253    use datafusion_expr::expr::ScalarFunction;
1254    use datafusion_expr::{
1255        ScalarUDF, col, lit, logical_plan::builder::LogicalPlanBuilder,
1256    };
1257
1258    fn leaf_udf(expr: Expr, name: &str) -> Expr {
1259        Expr::ScalarFunction(ScalarFunction::new_udf(
1260            Arc::new(ScalarUDF::new_from_impl(
1261                PlacementTestUDF::new()
1262                    .with_placement(ExpressionPlacement::MoveTowardsLeafNodes),
1263            )),
1264            vec![expr, lit(name)],
1265        ))
1266    }
1267
1268    // =========================================================================
1269    // Combined optimization stage formatter
1270    // =========================================================================
1271
1272    /// Runs all 4 optimization stages and returns a single formatted string.
1273    /// Stages that produce the same plan as the previous stage show
1274    /// "(same as <previous>)" to reduce noise.
1275    ///
1276    /// Stages:
1277    /// 1. **Original** - OptimizeProjections only (baseline)
1278    /// 2. **After Extraction** - + ExtractLeafExpressions
1279    /// 3. **After Pushdown** - + PushDownLeafProjections
1280    /// 4. **Optimized** - + final OptimizeProjections
1281    fn format_optimization_stages(plan: &LogicalPlan) -> Result<String> {
1282        let run = |rules: Vec<Arc<dyn OptimizerRule + Send + Sync>>| -> Result<String> {
1283            let ctx = OptimizerContext::new().with_max_passes(1);
1284            let optimizer = Optimizer::with_rules(rules);
1285            let optimized = optimizer.optimize(plan.clone(), &ctx, |_, _| {})?;
1286            Ok(format!("{optimized}"))
1287        };
1288
1289        let original = run(vec![Arc::new(OptimizeProjections::new())])?;
1290
1291        let after_extract = run(vec![
1292            Arc::new(OptimizeProjections::new()),
1293            Arc::new(ExtractLeafExpressions::new()),
1294        ])?;
1295
1296        let after_pushdown = run(vec![
1297            Arc::new(OptimizeProjections::new()),
1298            Arc::new(ExtractLeafExpressions::new()),
1299            Arc::new(PushDownLeafProjections::new()),
1300        ])?;
1301
1302        let optimized = run(vec![
1303            Arc::new(OptimizeProjections::new()),
1304            Arc::new(ExtractLeafExpressions::new()),
1305            Arc::new(PushDownLeafProjections::new()),
1306            Arc::new(OptimizeProjections::new()),
1307        ])?;
1308
1309        let mut out = format!("## Original Plan\n{original}");
1310
1311        out.push_str("\n\n## After Extraction\n");
1312        if after_extract == original {
1313            out.push_str("(same as original)");
1314        } else {
1315            out.push_str(&after_extract);
1316        }
1317
1318        out.push_str("\n\n## After Pushdown\n");
1319        if after_pushdown == after_extract {
1320            out.push_str("(same as after extraction)");
1321        } else {
1322            out.push_str(&after_pushdown);
1323        }
1324
1325        out.push_str("\n\n## Optimized\n");
1326        if optimized == after_pushdown {
1327            out.push_str("(same as after pushdown)");
1328        } else {
1329            out.push_str(&optimized);
1330        }
1331
1332        Ok(out)
1333    }
1334
1335    /// Assert all optimization stages for a plan in a single insta snapshot.
1336    macro_rules! assert_stages {
1337        ($plan:expr, @ $expected:literal $(,)?) => {{
1338            let result = format_optimization_stages(&$plan)?;
1339            insta::assert_snapshot!(result, @ $expected);
1340            Ok::<(), datafusion_common::DataFusionError>(())
1341        }};
1342    }
1343
1344    #[test]
1345    fn test_extract_from_filter() -> Result<()> {
1346        let table_scan = test_table_scan_with_struct()?;
1347        let plan = LogicalPlanBuilder::from(table_scan.clone())
1348            .filter(leaf_udf(col("user"), "status").eq(lit("active")))?
1349            .select(vec![
1350                table_scan
1351                    .schema()
1352                    .index_of_column_by_name(None, "id")
1353                    .unwrap(),
1354            ])?
1355            .build()?;
1356
1357        assert_stages!(plan, @r#"
1358        ## Original Plan
1359        Projection: test.id
1360          Filter: leaf_udf(test.user, Utf8("status")) = Utf8("active")
1361            TableScan: test projection=[id, user]
1362
1363        ## After Extraction
1364        Projection: test.id
1365          Projection: test.id, test.user
1366            Filter: __datafusion_extracted_1 = Utf8("active")
1367              Projection: leaf_udf(test.user, Utf8("status")) AS __datafusion_extracted_1, test.id, test.user
1368                TableScan: test projection=[id, user]
1369
1370        ## After Pushdown
1371        (same as after extraction)
1372
1373        ## Optimized
1374        Projection: test.id
1375          Filter: __datafusion_extracted_1 = Utf8("active")
1376            Projection: leaf_udf(test.user, Utf8("status")) AS __datafusion_extracted_1, test.id
1377              TableScan: test projection=[id, user]
1378        "#)
1379    }
1380
1381    #[test]
1382    fn test_no_extraction_for_column() -> Result<()> {
1383        let table_scan = test_table_scan()?;
1384        let plan = LogicalPlanBuilder::from(table_scan)
1385            .filter(col("a").eq(lit(1)))?
1386            .build()?;
1387
1388        assert_stages!(plan, @"
1389        ## Original Plan
1390        Filter: test.a = Int32(1)
1391          TableScan: test projection=[a, b, c]
1392
1393        ## After Extraction
1394        (same as original)
1395
1396        ## After Pushdown
1397        (same as after extraction)
1398
1399        ## Optimized
1400        (same as after pushdown)
1401        ")
1402    }
1403
1404    #[test]
1405    fn test_extract_from_projection() -> Result<()> {
1406        let table_scan = test_table_scan_with_struct()?;
1407        let plan = LogicalPlanBuilder::from(table_scan)
1408            .project(vec![leaf_udf(col("user"), "name")])?
1409            .build()?;
1410
1411        assert_stages!(plan, @r#"
1412        ## Original Plan
1413        Projection: leaf_udf(test.user, Utf8("name"))
1414          TableScan: test projection=[user]
1415
1416        ## After Extraction
1417        (same as original)
1418
1419        ## After Pushdown
1420        Projection: __datafusion_extracted_1 AS leaf_udf(test.user,Utf8("name"))
1421          Projection: leaf_udf(test.user, Utf8("name")) AS __datafusion_extracted_1, test.user
1422            TableScan: test projection=[user]
1423
1424        ## Optimized
1425        Projection: leaf_udf(test.user, Utf8("name"))
1426          TableScan: test projection=[user]
1427        "#)
1428    }
1429
1430    #[test]
1431    fn test_extract_from_projection_with_subexpression() -> Result<()> {
1432        let table_scan = test_table_scan_with_struct()?;
1433        let plan = LogicalPlanBuilder::from(table_scan)
1434            .project(vec![
1435                leaf_udf(col("user"), "name")
1436                    .is_not_null()
1437                    .alias("has_name"),
1438            ])?
1439            .build()?;
1440
1441        assert_stages!(plan, @r#"
1442        ## Original Plan
1443        Projection: leaf_udf(test.user, Utf8("name")) IS NOT NULL AS has_name
1444          TableScan: test projection=[user]
1445
1446        ## After Extraction
1447        (same as original)
1448
1449        ## After Pushdown
1450        Projection: __datafusion_extracted_1 IS NOT NULL AS has_name
1451          Projection: leaf_udf(test.user, Utf8("name")) AS __datafusion_extracted_1, test.user
1452            TableScan: test projection=[user]
1453
1454        ## Optimized
1455        Projection: leaf_udf(test.user, Utf8("name")) IS NOT NULL AS has_name
1456          TableScan: test projection=[user]
1457        "#)
1458    }
1459
1460    #[test]
1461    fn test_projection_no_extraction_for_column() -> Result<()> {
1462        let table_scan = test_table_scan()?;
1463        let plan = LogicalPlanBuilder::from(table_scan)
1464            .project(vec![col("a"), col("b")])?
1465            .build()?;
1466
1467        assert_stages!(plan, @"
1468        ## Original Plan
1469        TableScan: test projection=[a, b]
1470
1471        ## After Extraction
1472        (same as original)
1473
1474        ## After Pushdown
1475        (same as after extraction)
1476
1477        ## Optimized
1478        (same as after pushdown)
1479        ")
1480    }
1481
1482    #[test]
1483    fn test_filter_with_deduplication() -> Result<()> {
1484        let table_scan = test_table_scan_with_struct()?;
1485        let field_access = leaf_udf(col("user"), "name");
1486        // Filter with the same expression used twice
1487        let plan = LogicalPlanBuilder::from(table_scan)
1488            .filter(
1489                field_access
1490                    .clone()
1491                    .is_not_null()
1492                    .and(field_access.is_null()),
1493            )?
1494            .build()?;
1495
1496        assert_stages!(plan, @r#"
1497        ## Original Plan
1498        Filter: leaf_udf(test.user, Utf8("name")) IS NOT NULL AND leaf_udf(test.user, Utf8("name")) IS NULL
1499          TableScan: test projection=[id, user]
1500
1501        ## After Extraction
1502        Projection: test.id, test.user
1503          Filter: __datafusion_extracted_1 IS NOT NULL AND __datafusion_extracted_1 IS NULL
1504            Projection: leaf_udf(test.user, Utf8("name")) AS __datafusion_extracted_1, test.id, test.user
1505              TableScan: test projection=[id, user]
1506
1507        ## After Pushdown
1508        (same as after extraction)
1509
1510        ## Optimized
1511        (same as after pushdown)
1512        "#)
1513    }
1514
1515    #[test]
1516    fn test_already_leaf_expression_in_filter() -> Result<()> {
1517        let table_scan = test_table_scan_with_struct()?;
1518        let plan = LogicalPlanBuilder::from(table_scan)
1519            .filter(leaf_udf(col("user"), "name").eq(lit("test")))?
1520            .build()?;
1521
1522        assert_stages!(plan, @r#"
1523        ## Original Plan
1524        Filter: leaf_udf(test.user, Utf8("name")) = Utf8("test")
1525          TableScan: test projection=[id, user]
1526
1527        ## After Extraction
1528        Projection: test.id, test.user
1529          Filter: __datafusion_extracted_1 = Utf8("test")
1530            Projection: leaf_udf(test.user, Utf8("name")) AS __datafusion_extracted_1, test.id, test.user
1531              TableScan: test projection=[id, user]
1532
1533        ## After Pushdown
1534        (same as after extraction)
1535
1536        ## Optimized
1537        (same as after pushdown)
1538        "#)
1539    }
1540
1541    #[test]
1542    fn test_extract_from_aggregate_group_by() -> Result<()> {
1543        use datafusion_expr::test::function_stub::count;
1544
1545        let table_scan = test_table_scan_with_struct()?;
1546        let plan = LogicalPlanBuilder::from(table_scan)
1547            .aggregate(vec![leaf_udf(col("user"), "status")], vec![count(lit(1))])?
1548            .build()?;
1549
1550        assert_stages!(plan, @r#"
1551        ## Original Plan
1552        Aggregate: groupBy=[[leaf_udf(test.user, Utf8("status"))]], aggr=[[COUNT(Int32(1))]]
1553          TableScan: test projection=[user]
1554
1555        ## After Extraction
1556        Projection: __datafusion_extracted_1 AS leaf_udf(test.user,Utf8("status")), COUNT(Int32(1))
1557          Aggregate: groupBy=[[__datafusion_extracted_1]], aggr=[[COUNT(Int32(1))]]
1558            Projection: leaf_udf(test.user, Utf8("status")) AS __datafusion_extracted_1, test.user
1559              TableScan: test projection=[user]
1560
1561        ## After Pushdown
1562        (same as after extraction)
1563
1564        ## Optimized
1565        Projection: __datafusion_extracted_1 AS leaf_udf(test.user,Utf8("status")), COUNT(Int32(1))
1566          Aggregate: groupBy=[[__datafusion_extracted_1]], aggr=[[COUNT(Int32(1))]]
1567            Projection: leaf_udf(test.user, Utf8("status")) AS __datafusion_extracted_1
1568              TableScan: test projection=[user]
1569        "#)
1570    }
1571
1572    #[test]
1573    fn test_extract_from_aggregate_args() -> Result<()> {
1574        use datafusion_expr::test::function_stub::count;
1575
1576        let table_scan = test_table_scan_with_struct()?;
1577        let plan = LogicalPlanBuilder::from(table_scan)
1578            .aggregate(
1579                vec![col("user")],
1580                vec![count(leaf_udf(col("user"), "value"))],
1581            )?
1582            .build()?;
1583
1584        assert_stages!(plan, @r#"
1585        ## Original Plan
1586        Aggregate: groupBy=[[test.user]], aggr=[[COUNT(leaf_udf(test.user, Utf8("value")))]]
1587          TableScan: test projection=[user]
1588
1589        ## After Extraction
1590        Projection: test.user, COUNT(__datafusion_extracted_1) AS COUNT(leaf_udf(test.user,Utf8("value")))
1591          Aggregate: groupBy=[[test.user]], aggr=[[COUNT(__datafusion_extracted_1)]]
1592            Projection: leaf_udf(test.user, Utf8("value")) AS __datafusion_extracted_1, test.user
1593              TableScan: test projection=[user]
1594
1595        ## After Pushdown
1596        (same as after extraction)
1597
1598        ## Optimized
1599        (same as after pushdown)
1600        "#)
1601    }
1602
1603    #[test]
1604    fn test_projection_with_filter_combined() -> Result<()> {
1605        let table_scan = test_table_scan_with_struct()?;
1606        let plan = LogicalPlanBuilder::from(table_scan)
1607            .filter(leaf_udf(col("user"), "status").eq(lit("active")))?
1608            .project(vec![leaf_udf(col("user"), "name")])?
1609            .build()?;
1610
1611        assert_stages!(plan, @r#"
1612        ## Original Plan
1613        Projection: leaf_udf(test.user, Utf8("name"))
1614          Filter: leaf_udf(test.user, Utf8("status")) = Utf8("active")
1615            TableScan: test projection=[user]
1616
1617        ## After Extraction
1618        Projection: leaf_udf(test.user, Utf8("name"))
1619          Projection: test.user
1620            Filter: __datafusion_extracted_1 = Utf8("active")
1621              Projection: leaf_udf(test.user, Utf8("status")) AS __datafusion_extracted_1, test.user
1622                TableScan: test projection=[user]
1623
1624        ## After Pushdown
1625        Projection: __datafusion_extracted_2 AS leaf_udf(test.user,Utf8("name"))
1626          Projection: test.user, __datafusion_extracted_2
1627            Filter: __datafusion_extracted_1 = Utf8("active")
1628              Projection: leaf_udf(test.user, Utf8("status")) AS __datafusion_extracted_1, test.user, leaf_udf(test.user, Utf8("name")) AS __datafusion_extracted_2
1629                TableScan: test projection=[user]
1630
1631        ## Optimized
1632        Projection: __datafusion_extracted_2 AS leaf_udf(test.user,Utf8("name"))
1633          Filter: __datafusion_extracted_1 = Utf8("active")
1634            Projection: leaf_udf(test.user, Utf8("status")) AS __datafusion_extracted_1, leaf_udf(test.user, Utf8("name")) AS __datafusion_extracted_2
1635              TableScan: test projection=[user]
1636        "#)
1637    }
1638
1639    #[test]
1640    fn test_projection_preserves_alias() -> Result<()> {
1641        let table_scan = test_table_scan_with_struct()?;
1642        let plan = LogicalPlanBuilder::from(table_scan)
1643            .project(vec![leaf_udf(col("user"), "name").alias("username")])?
1644            .build()?;
1645
1646        assert_stages!(plan, @r#"
1647        ## Original Plan
1648        Projection: leaf_udf(test.user, Utf8("name")) AS username
1649          TableScan: test projection=[user]
1650
1651        ## After Extraction
1652        (same as original)
1653
1654        ## After Pushdown
1655        Projection: __datafusion_extracted_1 AS username
1656          Projection: leaf_udf(test.user, Utf8("name")) AS __datafusion_extracted_1, test.user
1657            TableScan: test projection=[user]
1658
1659        ## Optimized
1660        Projection: leaf_udf(test.user, Utf8("name")) AS username
1661          TableScan: test projection=[user]
1662        "#)
1663    }
1664
1665    /// Test: Projection with different field than Filter
1666    /// SELECT id, s['label'] FROM t WHERE s['value'] > 150
1667    /// Both s['label'] and s['value'] should be in a single extraction projection.
1668    #[test]
1669    fn test_projection_different_field_from_filter() -> Result<()> {
1670        let table_scan = test_table_scan_with_struct()?;
1671        let plan = LogicalPlanBuilder::from(table_scan)
1672            .filter(leaf_udf(col("user"), "value").gt(lit(150)))?
1673            .project(vec![col("user"), leaf_udf(col("user"), "label")])?
1674            .build()?;
1675
1676        assert_stages!(plan, @r#"
1677        ## Original Plan
1678        Projection: test.user, leaf_udf(test.user, Utf8("label"))
1679          Filter: leaf_udf(test.user, Utf8("value")) > Int32(150)
1680            TableScan: test projection=[user]
1681
1682        ## After Extraction
1683        Projection: test.user, leaf_udf(test.user, Utf8("label"))
1684          Projection: test.user
1685            Filter: __datafusion_extracted_1 > Int32(150)
1686              Projection: leaf_udf(test.user, Utf8("value")) AS __datafusion_extracted_1, test.user
1687                TableScan: test projection=[user]
1688
1689        ## After Pushdown
1690        Projection: test.user, __datafusion_extracted_2 AS leaf_udf(test.user,Utf8("label"))
1691          Projection: test.user, __datafusion_extracted_2
1692            Filter: __datafusion_extracted_1 > Int32(150)
1693              Projection: leaf_udf(test.user, Utf8("value")) AS __datafusion_extracted_1, test.user, leaf_udf(test.user, Utf8("label")) AS __datafusion_extracted_2
1694                TableScan: test projection=[user]
1695
1696        ## Optimized
1697        Projection: test.user, __datafusion_extracted_2 AS leaf_udf(test.user,Utf8("label"))
1698          Filter: __datafusion_extracted_1 > Int32(150)
1699            Projection: leaf_udf(test.user, Utf8("value")) AS __datafusion_extracted_1, test.user, leaf_udf(test.user, Utf8("label")) AS __datafusion_extracted_2
1700              TableScan: test projection=[user]
1701        "#)
1702    }
1703
1704    #[test]
1705    fn test_projection_deduplication() -> Result<()> {
1706        let table_scan = test_table_scan_with_struct()?;
1707        let field = leaf_udf(col("user"), "name");
1708        let plan = LogicalPlanBuilder::from(table_scan)
1709            .project(vec![field.clone(), field.clone().alias("name2")])?
1710            .build()?;
1711
1712        assert_stages!(plan, @r#"
1713        ## Original Plan
1714        Projection: leaf_udf(test.user, Utf8("name")), leaf_udf(test.user, Utf8("name")) AS name2
1715          TableScan: test projection=[user]
1716
1717        ## After Extraction
1718        (same as original)
1719
1720        ## After Pushdown
1721        Projection: __datafusion_extracted_1 AS leaf_udf(test.user,Utf8("name")), __datafusion_extracted_1 AS name2
1722          Projection: leaf_udf(test.user, Utf8("name")) AS __datafusion_extracted_1, test.user
1723            TableScan: test projection=[user]
1724
1725        ## Optimized
1726        Projection: leaf_udf(test.user, Utf8("name")), leaf_udf(test.user, Utf8("name")) AS name2
1727          TableScan: test projection=[user]
1728        "#)
1729    }
1730
1731    // =========================================================================
1732    // Additional tests for code coverage
1733    // =========================================================================
1734
1735    /// Extractions push through Sort nodes to reach the TableScan.
1736    #[test]
1737    fn test_extract_through_sort() -> Result<()> {
1738        let table_scan = test_table_scan_with_struct()?;
1739        let plan = LogicalPlanBuilder::from(table_scan)
1740            .sort(vec![col("user").sort(true, true)])?
1741            .project(vec![leaf_udf(col("user"), "name")])?
1742            .build()?;
1743
1744        assert_stages!(plan, @r#"
1745        ## Original Plan
1746        Projection: leaf_udf(test.user, Utf8("name"))
1747          Sort: test.user ASC NULLS FIRST
1748            TableScan: test projection=[user]
1749
1750        ## After Extraction
1751        (same as original)
1752
1753        ## After Pushdown
1754        Projection: __datafusion_extracted_1 AS leaf_udf(test.user,Utf8("name"))
1755          Sort: test.user ASC NULLS FIRST
1756            Projection: leaf_udf(test.user, Utf8("name")) AS __datafusion_extracted_1, test.user
1757              TableScan: test projection=[user]
1758
1759        ## Optimized
1760        (same as after pushdown)
1761        "#)
1762    }
1763
1764    /// Extractions push through Limit nodes to reach the TableScan.
1765    #[test]
1766    fn test_extract_through_limit() -> Result<()> {
1767        let table_scan = test_table_scan_with_struct()?;
1768        let plan = LogicalPlanBuilder::from(table_scan)
1769            .limit(0, Some(10))?
1770            .project(vec![leaf_udf(col("user"), "name")])?
1771            .build()?;
1772
1773        assert_stages!(plan, @r#"
1774        ## Original Plan
1775        Projection: leaf_udf(test.user, Utf8("name"))
1776          Limit: skip=0, fetch=10
1777            TableScan: test projection=[user]
1778
1779        ## After Extraction
1780        (same as original)
1781
1782        ## After Pushdown
1783        Projection: __datafusion_extracted_1 AS leaf_udf(test.user,Utf8("name"))
1784          Limit: skip=0, fetch=10
1785            Projection: leaf_udf(test.user, Utf8("name")) AS __datafusion_extracted_1, test.user
1786              TableScan: test projection=[user]
1787
1788        ## Optimized
1789        Projection: __datafusion_extracted_1 AS leaf_udf(test.user,Utf8("name"))
1790          Limit: skip=0, fetch=10
1791            Projection: leaf_udf(test.user, Utf8("name")) AS __datafusion_extracted_1
1792              TableScan: test projection=[user]
1793        "#)
1794    }
1795
1796    /// Aliased aggregate functions like count(...).alias("cnt") are handled.
1797    #[test]
1798    fn test_extract_from_aliased_aggregate() -> Result<()> {
1799        use datafusion_expr::test::function_stub::count;
1800
1801        let table_scan = test_table_scan_with_struct()?;
1802        let plan = LogicalPlanBuilder::from(table_scan)
1803            .aggregate(
1804                vec![col("user")],
1805                vec![count(leaf_udf(col("user"), "value")).alias("cnt")],
1806            )?
1807            .build()?;
1808
1809        assert_stages!(plan, @r#"
1810        ## Original Plan
1811        Aggregate: groupBy=[[test.user]], aggr=[[COUNT(leaf_udf(test.user, Utf8("value"))) AS cnt]]
1812          TableScan: test projection=[user]
1813
1814        ## After Extraction
1815        Aggregate: groupBy=[[test.user]], aggr=[[COUNT(__datafusion_extracted_1) AS cnt]]
1816          Projection: leaf_udf(test.user, Utf8("value")) AS __datafusion_extracted_1, test.user
1817            TableScan: test projection=[user]
1818
1819        ## After Pushdown
1820        (same as after extraction)
1821
1822        ## Optimized
1823        (same as after pushdown)
1824        "#)
1825    }
1826
1827    /// Aggregates with no MoveTowardsLeafNodes expressions return unchanged.
1828    #[test]
1829    fn test_aggregate_no_extraction() -> Result<()> {
1830        use datafusion_expr::test::function_stub::count;
1831
1832        let table_scan = test_table_scan()?;
1833        let plan = LogicalPlanBuilder::from(table_scan)
1834            .aggregate(vec![col("a")], vec![count(col("b"))])?
1835            .build()?;
1836
1837        assert_stages!(plan, @"
1838        ## Original Plan
1839        Aggregate: groupBy=[[test.a]], aggr=[[COUNT(test.b)]]
1840          TableScan: test projection=[a, b]
1841
1842        ## After Extraction
1843        (same as original)
1844
1845        ## After Pushdown
1846        (same as after extraction)
1847
1848        ## Optimized
1849        (same as after pushdown)
1850        ")
1851    }
1852
1853    /// Projections containing extracted expression aliases are skipped (already extracted).
1854    #[test]
1855    fn test_skip_extracted_projection() -> Result<()> {
1856        let table_scan = test_table_scan_with_struct()?;
1857        let plan = LogicalPlanBuilder::from(table_scan)
1858            .project(vec![
1859                leaf_udf(col("user"), "name").alias("__datafusion_extracted_manual"),
1860                col("user"),
1861            ])?
1862            .build()?;
1863
1864        assert_stages!(plan, @r#"
1865        ## Original Plan
1866        Projection: leaf_udf(test.user, Utf8("name")) AS __datafusion_extracted_manual, test.user
1867          TableScan: test projection=[user]
1868
1869        ## After Extraction
1870        (same as original)
1871
1872        ## After Pushdown
1873        (same as after extraction)
1874
1875        ## Optimized
1876        (same as after pushdown)
1877        "#)
1878    }
1879
1880    /// Multiple extractions merge into a single extracted expression projection.
1881    #[test]
1882    fn test_merge_into_existing_extracted_projection() -> Result<()> {
1883        let table_scan = test_table_scan_with_struct()?;
1884        let plan = LogicalPlanBuilder::from(table_scan)
1885            .filter(leaf_udf(col("user"), "status").eq(lit("active")))?
1886            .filter(leaf_udf(col("user"), "name").is_not_null())?
1887            .build()?;
1888
1889        assert_stages!(plan, @r#"
1890        ## Original Plan
1891        Filter: leaf_udf(test.user, Utf8("name")) IS NOT NULL
1892          Filter: leaf_udf(test.user, Utf8("status")) = Utf8("active")
1893            TableScan: test projection=[id, user]
1894
1895        ## After Extraction
1896        Projection: test.id, test.user
1897          Filter: __datafusion_extracted_1 IS NOT NULL
1898            Projection: leaf_udf(test.user, Utf8("name")) AS __datafusion_extracted_1, test.id, test.user
1899              Projection: test.id, test.user
1900                Filter: __datafusion_extracted_2 = Utf8("active")
1901                  Projection: leaf_udf(test.user, Utf8("status")) AS __datafusion_extracted_2, test.id, test.user
1902                    TableScan: test projection=[id, user]
1903
1904        ## After Pushdown
1905        Projection: test.id, test.user
1906          Filter: __datafusion_extracted_1 IS NOT NULL
1907            Projection: test.id, test.user, __datafusion_extracted_1
1908              Filter: __datafusion_extracted_2 = Utf8("active")
1909                Projection: leaf_udf(test.user, Utf8("status")) AS __datafusion_extracted_2, test.id, test.user, leaf_udf(test.user, Utf8("name")) AS __datafusion_extracted_1
1910                  TableScan: test projection=[id, user]
1911
1912        ## Optimized
1913        (same as after pushdown)
1914        "#)
1915    }
1916
1917    /// Extractions push through passthrough projections (columns only).
1918    #[test]
1919    fn test_extract_through_passthrough_projection() -> Result<()> {
1920        let table_scan = test_table_scan_with_struct()?;
1921        let plan = LogicalPlanBuilder::from(table_scan)
1922            .project(vec![col("user")])?
1923            .project(vec![leaf_udf(col("user"), "name")])?
1924            .build()?;
1925
1926        assert_stages!(plan, @r#"
1927        ## Original Plan
1928        Projection: leaf_udf(test.user, Utf8("name"))
1929          TableScan: test projection=[user]
1930
1931        ## After Extraction
1932        (same as original)
1933
1934        ## After Pushdown
1935        Projection: __datafusion_extracted_1 AS leaf_udf(test.user,Utf8("name"))
1936          Projection: leaf_udf(test.user, Utf8("name")) AS __datafusion_extracted_1, test.user
1937            TableScan: test projection=[user]
1938
1939        ## Optimized
1940        Projection: leaf_udf(test.user, Utf8("name"))
1941          TableScan: test projection=[user]
1942        "#)
1943    }
1944
1945    /// Projections with aliased columns (nothing to extract) return unchanged.
1946    #[test]
1947    fn test_projection_early_return_no_extraction() -> Result<()> {
1948        let table_scan = test_table_scan()?;
1949        let plan = LogicalPlanBuilder::from(table_scan)
1950            .project(vec![col("a").alias("x"), col("b")])?
1951            .build()?;
1952
1953        assert_stages!(plan, @"
1954        ## Original Plan
1955        Projection: test.a AS x, test.b
1956          TableScan: test projection=[a, b]
1957
1958        ## After Extraction
1959        (same as original)
1960
1961        ## After Pushdown
1962        (same as after extraction)
1963
1964        ## Optimized
1965        (same as after pushdown)
1966        ")
1967    }
1968
1969    /// Projections with arithmetic expressions but no MoveTowardsLeafNodes return unchanged.
1970    #[test]
1971    fn test_projection_with_arithmetic_no_extraction() -> Result<()> {
1972        let table_scan = test_table_scan()?;
1973        let plan = LogicalPlanBuilder::from(table_scan)
1974            .project(vec![(col("a") + col("b")).alias("sum")])?
1975            .build()?;
1976
1977        assert_stages!(plan, @"
1978        ## Original Plan
1979        Projection: test.a + test.b AS sum
1980          TableScan: test projection=[a, b]
1981
1982        ## After Extraction
1983        (same as original)
1984
1985        ## After Pushdown
1986        (same as after extraction)
1987
1988        ## Optimized
1989        (same as after pushdown)
1990        ")
1991    }
1992
1993    /// Aggregate extractions merge into existing extracted projection created by Filter.
1994    #[test]
1995    fn test_aggregate_merge_into_extracted_projection() -> Result<()> {
1996        use datafusion_expr::test::function_stub::count;
1997
1998        let table_scan = test_table_scan_with_struct()?;
1999        let plan = LogicalPlanBuilder::from(table_scan)
2000            .filter(leaf_udf(col("user"), "status").eq(lit("active")))?
2001            .aggregate(vec![leaf_udf(col("user"), "name")], vec![count(lit(1))])?
2002            .build()?;
2003
2004        assert_stages!(plan, @r#"
2005        ## Original Plan
2006        Aggregate: groupBy=[[leaf_udf(test.user, Utf8("name"))]], aggr=[[COUNT(Int32(1))]]
2007          Filter: leaf_udf(test.user, Utf8("status")) = Utf8("active")
2008            TableScan: test projection=[user]
2009
2010        ## After Extraction
2011        Projection: __datafusion_extracted_1 AS leaf_udf(test.user,Utf8("name")), COUNT(Int32(1))
2012          Aggregate: groupBy=[[__datafusion_extracted_1]], aggr=[[COUNT(Int32(1))]]
2013            Projection: leaf_udf(test.user, Utf8("name")) AS __datafusion_extracted_1, test.user
2014              Projection: test.user
2015                Filter: __datafusion_extracted_2 = Utf8("active")
2016                  Projection: leaf_udf(test.user, Utf8("status")) AS __datafusion_extracted_2, test.user
2017                    TableScan: test projection=[user]
2018
2019        ## After Pushdown
2020        Projection: __datafusion_extracted_1 AS leaf_udf(test.user,Utf8("name")), COUNT(Int32(1))
2021          Aggregate: groupBy=[[__datafusion_extracted_1]], aggr=[[COUNT(Int32(1))]]
2022            Projection: test.user, __datafusion_extracted_1
2023              Filter: __datafusion_extracted_2 = Utf8("active")
2024                Projection: leaf_udf(test.user, Utf8("status")) AS __datafusion_extracted_2, test.user, leaf_udf(test.user, Utf8("name")) AS __datafusion_extracted_1
2025                  TableScan: test projection=[user]
2026
2027        ## Optimized
2028        Projection: __datafusion_extracted_1 AS leaf_udf(test.user,Utf8("name")), COUNT(Int32(1))
2029          Aggregate: groupBy=[[__datafusion_extracted_1]], aggr=[[COUNT(Int32(1))]]
2030            Projection: __datafusion_extracted_1
2031              Filter: __datafusion_extracted_2 = Utf8("active")
2032                Projection: leaf_udf(test.user, Utf8("status")) AS __datafusion_extracted_2, leaf_udf(test.user, Utf8("name")) AS __datafusion_extracted_1
2033                  TableScan: test projection=[user]
2034        "#)
2035    }
2036
2037    /// Projection containing a MoveTowardsLeafNodes sub-expression above an
2038    /// Aggregate. Aggregate blocks pushdown, so the (None, true) recovery
2039    /// fallback path fires: in-place extraction + recovery projection.
2040    #[test]
2041    fn test_projection_with_leaf_expr_above_aggregate() -> Result<()> {
2042        use datafusion_expr::test::function_stub::count;
2043
2044        let table_scan = test_table_scan_with_struct()?;
2045        let plan = LogicalPlanBuilder::from(table_scan)
2046            .aggregate(vec![col("user")], vec![count(lit(1))])?
2047            .project(vec![
2048                leaf_udf(col("user"), "name")
2049                    .is_not_null()
2050                    .alias("has_name"),
2051                col("COUNT(Int32(1))"),
2052            ])?
2053            .build()?;
2054
2055        assert_stages!(plan, @r#"
2056        ## Original Plan
2057        Projection: leaf_udf(test.user, Utf8("name")) IS NOT NULL AS has_name, COUNT(Int32(1))
2058          Aggregate: groupBy=[[test.user]], aggr=[[COUNT(Int32(1))]]
2059            TableScan: test projection=[user]
2060
2061        ## After Extraction
2062        (same as original)
2063
2064        ## After Pushdown
2065        Projection: __datafusion_extracted_1 IS NOT NULL AS has_name, COUNT(Int32(1))
2066          Projection: leaf_udf(test.user, Utf8("name")) AS __datafusion_extracted_1, test.user, COUNT(Int32(1))
2067            Aggregate: groupBy=[[test.user]], aggr=[[COUNT(Int32(1))]]
2068              TableScan: test projection=[user]
2069
2070        ## Optimized
2071        Projection: leaf_udf(test.user, Utf8("name")) IS NOT NULL AS has_name, COUNT(Int32(1))
2072          Aggregate: groupBy=[[test.user]], aggr=[[COUNT(Int32(1))]]
2073            TableScan: test projection=[user]
2074        "#)
2075    }
2076
2077    /// Merging adds new pass-through columns not in the existing extracted projection.
2078    #[test]
2079    fn test_merge_with_new_columns() -> Result<()> {
2080        let table_scan = test_table_scan()?;
2081        let plan = LogicalPlanBuilder::from(table_scan)
2082            .filter(leaf_udf(col("a"), "x").eq(lit(1)))?
2083            .filter(leaf_udf(col("b"), "y").eq(lit(2)))?
2084            .build()?;
2085
2086        assert_stages!(plan, @r#"
2087        ## Original Plan
2088        Filter: leaf_udf(test.b, Utf8("y")) = Int32(2)
2089          Filter: leaf_udf(test.a, Utf8("x")) = Int32(1)
2090            TableScan: test projection=[a, b, c]
2091
2092        ## After Extraction
2093        Projection: test.a, test.b, test.c
2094          Filter: __datafusion_extracted_1 = Int32(2)
2095            Projection: leaf_udf(test.b, Utf8("y")) AS __datafusion_extracted_1, test.a, test.b, test.c
2096              Projection: test.a, test.b, test.c
2097                Filter: __datafusion_extracted_2 = Int32(1)
2098                  Projection: leaf_udf(test.a, Utf8("x")) AS __datafusion_extracted_2, test.a, test.b, test.c
2099                    TableScan: test projection=[a, b, c]
2100
2101        ## After Pushdown
2102        Projection: test.a, test.b, test.c
2103          Filter: __datafusion_extracted_1 = Int32(2)
2104            Projection: test.a, test.b, test.c, __datafusion_extracted_1
2105              Filter: __datafusion_extracted_2 = Int32(1)
2106                Projection: leaf_udf(test.a, Utf8("x")) AS __datafusion_extracted_2, test.a, test.b, test.c, leaf_udf(test.b, Utf8("y")) AS __datafusion_extracted_1
2107                  TableScan: test projection=[a, b, c]
2108
2109        ## Optimized
2110        (same as after pushdown)
2111        "#)
2112    }
2113
2114    // =========================================================================
2115    // Join extraction tests
2116    // =========================================================================
2117
2118    /// Create a second table scan with struct field for join tests
2119    fn test_table_scan_with_struct_named(name: &str) -> Result<LogicalPlan> {
2120        use arrow::datatypes::Schema;
2121        let schema = Schema::new(test_table_scan_with_struct_fields());
2122        datafusion_expr::logical_plan::table_scan(Some(name), &schema, None)?.build()
2123    }
2124
2125    /// Extraction from equijoin keys (`on` expressions).
2126    #[test]
2127    fn test_extract_from_join_on() -> Result<()> {
2128        use datafusion_expr::JoinType;
2129
2130        let left = test_table_scan_with_struct()?;
2131        let right = test_table_scan_with_struct_named("right")?;
2132
2133        let plan = LogicalPlanBuilder::from(left)
2134            .join_with_expr_keys(
2135                right,
2136                JoinType::Inner,
2137                (
2138                    vec![leaf_udf(col("user"), "id")],
2139                    vec![leaf_udf(col("user"), "id")],
2140                ),
2141                None,
2142            )?
2143            .build()?;
2144
2145        assert_stages!(plan, @r#"
2146        ## Original Plan
2147        Inner Join: leaf_udf(test.user, Utf8("id")) = leaf_udf(right.user, Utf8("id"))
2148          TableScan: test projection=[id, user]
2149          TableScan: right projection=[id, user]
2150
2151        ## After Extraction
2152        Projection: test.id, test.user, right.id, right.user
2153          Inner Join: __datafusion_extracted_1 = __datafusion_extracted_2
2154            Projection: leaf_udf(test.user, Utf8("id")) AS __datafusion_extracted_1, test.id, test.user
2155              TableScan: test projection=[id, user]
2156            Projection: leaf_udf(right.user, Utf8("id")) AS __datafusion_extracted_2, right.id, right.user
2157              TableScan: right projection=[id, user]
2158
2159        ## After Pushdown
2160        (same as after extraction)
2161
2162        ## Optimized
2163        (same as after pushdown)
2164        "#)
2165    }
2166
2167    /// Extraction from non-equi join filter.
2168    #[test]
2169    fn test_extract_from_join_filter() -> Result<()> {
2170        use datafusion_expr::JoinType;
2171
2172        let left = test_table_scan_with_struct()?;
2173        let right = test_table_scan_with_struct_named("right")?;
2174
2175        let plan = LogicalPlanBuilder::from(left)
2176            .join_on(
2177                right,
2178                JoinType::Inner,
2179                vec![
2180                    col("test.user").eq(col("right.user")),
2181                    leaf_udf(col("test.user"), "status").eq(lit("active")),
2182                ],
2183            )?
2184            .build()?;
2185
2186        assert_stages!(plan, @r#"
2187        ## Original Plan
2188        Inner Join:  Filter: test.user = right.user AND leaf_udf(test.user, Utf8("status")) = Utf8("active")
2189          TableScan: test projection=[id, user]
2190          TableScan: right projection=[id, user]
2191
2192        ## After Extraction
2193        Projection: test.id, test.user, right.id, right.user
2194          Inner Join:  Filter: test.user = right.user AND __datafusion_extracted_1 = Utf8("active")
2195            Projection: leaf_udf(test.user, Utf8("status")) AS __datafusion_extracted_1, test.id, test.user
2196              TableScan: test projection=[id, user]
2197            TableScan: right projection=[id, user]
2198
2199        ## After Pushdown
2200        (same as after extraction)
2201
2202        ## Optimized
2203        (same as after pushdown)
2204        "#)
2205    }
2206
2207    /// Extraction from both left and right sides of a join.
2208    #[test]
2209    fn test_extract_from_join_both_sides() -> Result<()> {
2210        use datafusion_expr::JoinType;
2211
2212        let left = test_table_scan_with_struct()?;
2213        let right = test_table_scan_with_struct_named("right")?;
2214
2215        let plan = LogicalPlanBuilder::from(left)
2216            .join_on(
2217                right,
2218                JoinType::Inner,
2219                vec![
2220                    col("test.user").eq(col("right.user")),
2221                    leaf_udf(col("test.user"), "status").eq(lit("active")),
2222                    leaf_udf(col("right.user"), "role").eq(lit("admin")),
2223                ],
2224            )?
2225            .build()?;
2226
2227        assert_stages!(plan, @r#"
2228        ## Original Plan
2229        Inner Join:  Filter: test.user = right.user AND leaf_udf(test.user, Utf8("status")) = Utf8("active") AND leaf_udf(right.user, Utf8("role")) = Utf8("admin")
2230          TableScan: test projection=[id, user]
2231          TableScan: right projection=[id, user]
2232
2233        ## After Extraction
2234        Projection: test.id, test.user, right.id, right.user
2235          Inner Join:  Filter: test.user = right.user AND __datafusion_extracted_1 = Utf8("active") AND __datafusion_extracted_2 = Utf8("admin")
2236            Projection: leaf_udf(test.user, Utf8("status")) AS __datafusion_extracted_1, test.id, test.user
2237              TableScan: test projection=[id, user]
2238            Projection: leaf_udf(right.user, Utf8("role")) AS __datafusion_extracted_2, right.id, right.user
2239              TableScan: right projection=[id, user]
2240
2241        ## After Pushdown
2242        (same as after extraction)
2243
2244        ## Optimized
2245        (same as after pushdown)
2246        "#)
2247    }
2248
2249    /// Join with no MoveTowardsLeafNodes expressions returns unchanged.
2250    #[test]
2251    fn test_extract_from_join_no_extraction() -> Result<()> {
2252        use datafusion_expr::JoinType;
2253
2254        let left = test_table_scan()?;
2255        let right = test_table_scan_with_name("right")?;
2256
2257        let plan = LogicalPlanBuilder::from(left)
2258            .join(right, JoinType::Inner, (vec!["a"], vec!["a"]), None)?
2259            .build()?;
2260
2261        assert_stages!(plan, @"
2262        ## Original Plan
2263        Inner Join: test.a = right.a
2264          TableScan: test projection=[a, b, c]
2265          TableScan: right projection=[a, b, c]
2266
2267        ## After Extraction
2268        (same as original)
2269
2270        ## After Pushdown
2271        (same as after extraction)
2272
2273        ## Optimized
2274        (same as after pushdown)
2275        ")
2276    }
2277
2278    /// Join followed by filter with extraction.
2279    #[test]
2280    fn test_extract_from_filter_above_join() -> Result<()> {
2281        use datafusion_expr::JoinType;
2282
2283        let left = test_table_scan_with_struct()?;
2284        let right = test_table_scan_with_struct_named("right")?;
2285
2286        let plan = LogicalPlanBuilder::from(left)
2287            .join_with_expr_keys(
2288                right,
2289                JoinType::Inner,
2290                (
2291                    vec![leaf_udf(col("user"), "id")],
2292                    vec![leaf_udf(col("user"), "id")],
2293                ),
2294                None,
2295            )?
2296            .filter(leaf_udf(col("test.user"), "status").eq(lit("active")))?
2297            .build()?;
2298
2299        assert_stages!(plan, @r#"
2300        ## Original Plan
2301        Filter: leaf_udf(test.user, Utf8("status")) = Utf8("active")
2302          Inner Join: leaf_udf(test.user, Utf8("id")) = leaf_udf(right.user, Utf8("id"))
2303            TableScan: test projection=[id, user]
2304            TableScan: right projection=[id, user]
2305
2306        ## After Extraction
2307        Projection: test.id, test.user, right.id, right.user
2308          Filter: __datafusion_extracted_1 = Utf8("active")
2309            Projection: leaf_udf(test.user, Utf8("status")) AS __datafusion_extracted_1, test.id, test.user, right.id, right.user
2310              Projection: test.id, test.user, right.id, right.user
2311                Inner Join: __datafusion_extracted_2 = __datafusion_extracted_3
2312                  Projection: leaf_udf(test.user, Utf8("id")) AS __datafusion_extracted_2, test.id, test.user
2313                    TableScan: test projection=[id, user]
2314                  Projection: leaf_udf(right.user, Utf8("id")) AS __datafusion_extracted_3, right.id, right.user
2315                    TableScan: right projection=[id, user]
2316
2317        ## After Pushdown
2318        Projection: test.id, test.user, right.id, right.user
2319          Filter: __datafusion_extracted_1 = Utf8("active")
2320            Projection: test.id, test.user, right.id, right.user, __datafusion_extracted_1
2321              Inner Join: __datafusion_extracted_2 = __datafusion_extracted_3
2322                Projection: leaf_udf(test.user, Utf8("id")) AS __datafusion_extracted_2, test.id, test.user, leaf_udf(test.user, Utf8("status")) AS __datafusion_extracted_1
2323                  TableScan: test projection=[id, user]
2324                Projection: leaf_udf(right.user, Utf8("id")) AS __datafusion_extracted_3, right.id, right.user
2325                  TableScan: right projection=[id, user]
2326
2327        ## Optimized
2328        (same as after pushdown)
2329        "#)
2330    }
2331
2332    /// Extraction projection (get_field in SELECT) above a Join pushes into
2333    /// the correct input side.
2334    #[test]
2335    fn test_extract_projection_above_join() -> Result<()> {
2336        use datafusion_expr::JoinType;
2337
2338        let left = test_table_scan_with_struct()?;
2339        let right = test_table_scan_with_struct_named("right")?;
2340
2341        let plan = LogicalPlanBuilder::from(left)
2342            .join(right, JoinType::Inner, (vec!["id"], vec!["id"]), None)?
2343            .project(vec![
2344                leaf_udf(col("test.user"), "status"),
2345                leaf_udf(col("right.user"), "role"),
2346            ])?
2347            .build()?;
2348
2349        assert_stages!(plan, @r#"
2350        ## Original Plan
2351        Projection: leaf_udf(test.user, Utf8("status")), leaf_udf(right.user, Utf8("role"))
2352          Inner Join: test.id = right.id
2353            TableScan: test projection=[id, user]
2354            TableScan: right projection=[id, user]
2355
2356        ## After Extraction
2357        (same as original)
2358
2359        ## After Pushdown
2360        Projection: __datafusion_extracted_1 AS leaf_udf(test.user,Utf8("status")), __datafusion_extracted_2 AS leaf_udf(right.user,Utf8("role"))
2361          Inner Join: test.id = right.id
2362            Projection: leaf_udf(test.user, Utf8("status")) AS __datafusion_extracted_1, test.id, test.user
2363              TableScan: test projection=[id, user]
2364            Projection: leaf_udf(right.user, Utf8("role")) AS __datafusion_extracted_2, right.id, right.user
2365              TableScan: right projection=[id, user]
2366
2367        ## Optimized
2368        Projection: __datafusion_extracted_1 AS leaf_udf(test.user,Utf8("status")), __datafusion_extracted_2 AS leaf_udf(right.user,Utf8("role"))
2369          Inner Join: test.id = right.id
2370            Projection: leaf_udf(test.user, Utf8("status")) AS __datafusion_extracted_1, test.id
2371              TableScan: test projection=[id, user]
2372            Projection: leaf_udf(right.user, Utf8("role")) AS __datafusion_extracted_2, right.id
2373              TableScan: right projection=[id, user]
2374        "#)
2375    }
2376
2377    /// Join where both sides have same-named columns: a qualified reference
2378    /// to the right side must be routed to the right input, not the left.
2379    #[test]
2380    fn test_extract_from_join_qualified_right_side() -> Result<()> {
2381        use datafusion_expr::JoinType;
2382
2383        let left = test_table_scan_with_struct()?;
2384        let right = test_table_scan_with_struct_named("right")?;
2385
2386        // Filter references right.user explicitly — must route to right side
2387        let plan = LogicalPlanBuilder::from(left)
2388            .join_on(
2389                right,
2390                JoinType::Inner,
2391                vec![
2392                    col("test.id").eq(col("right.id")),
2393                    leaf_udf(col("right.user"), "status").eq(lit("active")),
2394                ],
2395            )?
2396            .build()?;
2397
2398        assert_stages!(plan, @r#"
2399        ## Original Plan
2400        Inner Join:  Filter: test.id = right.id AND leaf_udf(right.user, Utf8("status")) = Utf8("active")
2401          TableScan: test projection=[id, user]
2402          TableScan: right projection=[id, user]
2403
2404        ## After Extraction
2405        Projection: test.id, test.user, right.id, right.user
2406          Inner Join:  Filter: test.id = right.id AND __datafusion_extracted_1 = Utf8("active")
2407            TableScan: test projection=[id, user]
2408            Projection: leaf_udf(right.user, Utf8("status")) AS __datafusion_extracted_1, right.id, right.user
2409              TableScan: right projection=[id, user]
2410
2411        ## After Pushdown
2412        (same as after extraction)
2413
2414        ## Optimized
2415        (same as after pushdown)
2416        "#)
2417    }
2418
2419    /// When both inputs contain the same unqualified column, an unqualified
2420    /// column reference is ambiguous and `find_owning_input` must return
2421    /// `None` rather than always returning 0 (the left side).
2422    #[test]
2423    fn test_find_owning_input_ambiguous_unqualified_column() {
2424        use std::collections::HashSet;
2425
2426        // Simulate schema_columns output for two sides of a join where both
2427        // have a "user" column — each set contains the qualified and
2428        // unqualified form.
2429        let relation = "test".into();
2430        let left_cols: HashSet<ColumnReference> = [
2431            ColumnReference::new(Some(&relation), "user"),
2432            ColumnReference::new_unqualified("user"),
2433        ]
2434        .into_iter()
2435        .collect();
2436
2437        let relation = "right".into();
2438        let right_cols: HashSet<ColumnReference> = [
2439            ColumnReference::new(Some(&relation), "user"),
2440            ColumnReference::new_unqualified("user"),
2441        ]
2442        .into_iter()
2443        .collect();
2444
2445        let input_column_sets = vec![left_cols, right_cols];
2446
2447        // Unqualified "user" matches both sets — must return None (ambiguous)
2448        let unqualified = Expr::Column(Column::new_unqualified("user"));
2449        assert_eq!(find_owning_input(&unqualified, &input_column_sets), None);
2450
2451        // Qualified "right.user" matches only the right set — must return Some(1)
2452        let qualified_right = Expr::Column(Column::new(Some("right"), "user"));
2453        assert_eq!(
2454            find_owning_input(&qualified_right, &input_column_sets),
2455            Some(1)
2456        );
2457
2458        // Qualified "test.user" matches only the left set — must return Some(0)
2459        let qualified_left = Expr::Column(Column::new(Some("test"), "user"));
2460        assert_eq!(
2461            find_owning_input(&qualified_left, &input_column_sets),
2462            Some(0)
2463        );
2464    }
2465
2466    /// Two leaf_udf expressions from different sides of a Join in a Filter.
2467    /// Each is routed to its respective input side independently.
2468    #[test]
2469    fn test_extract_from_join_cross_input_expression() -> Result<()> {
2470        let left = test_table_scan_with_struct()?;
2471        let right = test_table_scan_with_struct_named("right")?;
2472
2473        let plan = LogicalPlanBuilder::from(left)
2474            .join_on(
2475                right,
2476                datafusion_expr::JoinType::Inner,
2477                vec![col("test.id").eq(col("right.id"))],
2478            )?
2479            .filter(
2480                leaf_udf(col("test.user"), "status")
2481                    .eq(leaf_udf(col("right.user"), "status")),
2482            )?
2483            .build()?;
2484
2485        assert_stages!(plan, @r#"
2486        ## Original Plan
2487        Filter: leaf_udf(test.user, Utf8("status")) = leaf_udf(right.user, Utf8("status"))
2488          Inner Join:  Filter: test.id = right.id
2489            TableScan: test projection=[id, user]
2490            TableScan: right projection=[id, user]
2491
2492        ## After Extraction
2493        Projection: test.id, test.user, right.id, right.user
2494          Filter: __datafusion_extracted_1 = __datafusion_extracted_2
2495            Projection: leaf_udf(test.user, Utf8("status")) AS __datafusion_extracted_1, leaf_udf(right.user, Utf8("status")) AS __datafusion_extracted_2, test.id, test.user, right.id, right.user
2496              Inner Join:  Filter: test.id = right.id
2497                TableScan: test projection=[id, user]
2498                TableScan: right projection=[id, user]
2499
2500        ## After Pushdown
2501        Projection: test.id, test.user, right.id, right.user
2502          Filter: __datafusion_extracted_1 = __datafusion_extracted_2
2503            Inner Join:  Filter: test.id = right.id
2504              Projection: leaf_udf(test.user, Utf8("status")) AS __datafusion_extracted_1, test.id, test.user
2505                TableScan: test projection=[id, user]
2506              Projection: leaf_udf(right.user, Utf8("status")) AS __datafusion_extracted_2, right.id, right.user
2507                TableScan: right projection=[id, user]
2508
2509        ## Optimized
2510        (same as after pushdown)
2511        "#)
2512    }
2513
2514    // =========================================================================
2515    // Column-rename through intermediate node tests
2516    // =========================================================================
2517
2518    /// Projection with leaf expr above Filter above renaming Projection.
2519    #[test]
2520    fn test_extract_through_filter_with_column_rename() -> Result<()> {
2521        let table_scan = test_table_scan_with_struct()?;
2522        let plan = LogicalPlanBuilder::from(table_scan)
2523            .project(vec![col("user").alias("x")])?
2524            .filter(col("x").is_not_null())?
2525            .project(vec![leaf_udf(col("x"), "a")])?
2526            .build()?;
2527
2528        assert_stages!(plan, @r#"
2529        ## Original Plan
2530        Projection: leaf_udf(x, Utf8("a"))
2531          Filter: x IS NOT NULL
2532            Projection: test.user AS x
2533              TableScan: test projection=[user]
2534
2535        ## After Extraction
2536        (same as original)
2537
2538        ## After Pushdown
2539        Projection: __datafusion_extracted_1 AS leaf_udf(x,Utf8("a"))
2540          Filter: x IS NOT NULL
2541            Projection: test.user AS x, leaf_udf(test.user, Utf8("a")) AS __datafusion_extracted_1, test.user
2542              TableScan: test projection=[user]
2543
2544        ## Optimized
2545        Projection: __datafusion_extracted_1 AS leaf_udf(x,Utf8("a"))
2546          Filter: x IS NOT NULL
2547            Projection: test.user AS x, leaf_udf(test.user, Utf8("a")) AS __datafusion_extracted_1
2548              TableScan: test projection=[user]
2549        "#)
2550    }
2551
2552    /// Same as above but with a partial extraction (leaf + arithmetic).
2553    #[test]
2554    fn test_extract_partial_through_filter_with_column_rename() -> Result<()> {
2555        let table_scan = test_table_scan_with_struct()?;
2556        let plan = LogicalPlanBuilder::from(table_scan)
2557            .project(vec![col("user").alias("x")])?
2558            .filter(col("x").is_not_null())?
2559            .project(vec![leaf_udf(col("x"), "a").is_not_null()])?
2560            .build()?;
2561
2562        assert_stages!(plan, @r#"
2563        ## Original Plan
2564        Projection: leaf_udf(x, Utf8("a")) IS NOT NULL
2565          Filter: x IS NOT NULL
2566            Projection: test.user AS x
2567              TableScan: test projection=[user]
2568
2569        ## After Extraction
2570        (same as original)
2571
2572        ## After Pushdown
2573        Projection: __datafusion_extracted_1 IS NOT NULL AS leaf_udf(x,Utf8("a")) IS NOT NULL
2574          Filter: x IS NOT NULL
2575            Projection: test.user AS x, leaf_udf(test.user, Utf8("a")) AS __datafusion_extracted_1, test.user
2576              TableScan: test projection=[user]
2577
2578        ## Optimized
2579        Projection: __datafusion_extracted_1 IS NOT NULL AS leaf_udf(x,Utf8("a")) IS NOT NULL
2580          Filter: x IS NOT NULL
2581            Projection: test.user AS x, leaf_udf(test.user, Utf8("a")) AS __datafusion_extracted_1
2582              TableScan: test projection=[user]
2583        "#)
2584    }
2585
2586    /// Tests merge_into_extracted_projection path through a renaming projection.
2587    #[test]
2588    fn test_extract_from_filter_above_renaming_projection() -> Result<()> {
2589        let table_scan = test_table_scan_with_struct()?;
2590        let plan = LogicalPlanBuilder::from(table_scan)
2591            .project(vec![col("user").alias("x")])?
2592            .filter(leaf_udf(col("x"), "a").eq(lit("active")))?
2593            .build()?;
2594
2595        assert_stages!(plan, @r#"
2596        ## Original Plan
2597        Filter: leaf_udf(x, Utf8("a")) = Utf8("active")
2598          Projection: test.user AS x
2599            TableScan: test projection=[user]
2600
2601        ## After Extraction
2602        Projection: x
2603          Filter: __datafusion_extracted_1 = Utf8("active")
2604            Projection: test.user AS x, leaf_udf(test.user, Utf8("a")) AS __datafusion_extracted_1, test.user
2605              TableScan: test projection=[user]
2606
2607        ## After Pushdown
2608        (same as after extraction)
2609
2610        ## Optimized
2611        Projection: x
2612          Filter: __datafusion_extracted_1 = Utf8("active")
2613            Projection: test.user AS x, leaf_udf(test.user, Utf8("a")) AS __datafusion_extracted_1
2614              TableScan: test projection=[user]
2615        "#)
2616    }
2617
2618    // =========================================================================
2619    // SubqueryAlias extraction tests
2620    // =========================================================================
2621
2622    /// Extraction projection pushes through SubqueryAlias.
2623    #[test]
2624    fn test_extract_through_subquery_alias() -> Result<()> {
2625        let table_scan = test_table_scan_with_struct()?;
2626        let plan = LogicalPlanBuilder::from(table_scan)
2627            .alias("sub")?
2628            .project(vec![leaf_udf(col("sub.user"), "name")])?
2629            .build()?;
2630
2631        assert_stages!(plan, @r#"
2632        ## Original Plan
2633        Projection: leaf_udf(sub.user, Utf8("name"))
2634          SubqueryAlias: sub
2635            TableScan: test projection=[user]
2636
2637        ## After Extraction
2638        (same as original)
2639
2640        ## After Pushdown
2641        Projection: __datafusion_extracted_1 AS leaf_udf(sub.user,Utf8("name"))
2642          SubqueryAlias: sub
2643            Projection: leaf_udf(test.user, Utf8("name")) AS __datafusion_extracted_1, test.user
2644              TableScan: test projection=[user]
2645
2646        ## Optimized
2647        Projection: __datafusion_extracted_1 AS leaf_udf(sub.user,Utf8("name"))
2648          SubqueryAlias: sub
2649            Projection: leaf_udf(test.user, Utf8("name")) AS __datafusion_extracted_1
2650              TableScan: test projection=[user]
2651        "#)
2652    }
2653
2654    /// Extraction projection pushes through SubqueryAlias + Filter.
2655    #[test]
2656    fn test_extract_through_subquery_alias_with_filter() -> Result<()> {
2657        let table_scan = test_table_scan_with_struct()?;
2658        let plan = LogicalPlanBuilder::from(table_scan)
2659            .alias("sub")?
2660            .filter(leaf_udf(col("sub.user"), "status").eq(lit("active")))?
2661            .project(vec![leaf_udf(col("sub.user"), "name")])?
2662            .build()?;
2663
2664        assert_stages!(plan, @r#"
2665        ## Original Plan
2666        Projection: leaf_udf(sub.user, Utf8("name"))
2667          Filter: leaf_udf(sub.user, Utf8("status")) = Utf8("active")
2668            SubqueryAlias: sub
2669              TableScan: test projection=[user]
2670
2671        ## After Extraction
2672        Projection: leaf_udf(sub.user, Utf8("name"))
2673          Projection: sub.user
2674            Filter: __datafusion_extracted_1 = Utf8("active")
2675              Projection: leaf_udf(sub.user, Utf8("status")) AS __datafusion_extracted_1, sub.user
2676                SubqueryAlias: sub
2677                  TableScan: test projection=[user]
2678
2679        ## After Pushdown
2680        Projection: __datafusion_extracted_2 AS leaf_udf(sub.user,Utf8("name"))
2681          Projection: sub.user, __datafusion_extracted_2
2682            Filter: __datafusion_extracted_1 = Utf8("active")
2683              SubqueryAlias: sub
2684                Projection: leaf_udf(test.user, Utf8("status")) AS __datafusion_extracted_1, leaf_udf(test.user, Utf8("name")) AS __datafusion_extracted_2, test.user
2685                  TableScan: test projection=[user]
2686
2687        ## Optimized
2688        Projection: __datafusion_extracted_2 AS leaf_udf(sub.user,Utf8("name"))
2689          Filter: __datafusion_extracted_1 = Utf8("active")
2690            SubqueryAlias: sub
2691              Projection: leaf_udf(test.user, Utf8("status")) AS __datafusion_extracted_1, leaf_udf(test.user, Utf8("name")) AS __datafusion_extracted_2
2692                TableScan: test projection=[user]
2693        "#)
2694    }
2695
2696    /// Two layers of SubqueryAlias: extraction pushes through both.
2697    #[test]
2698    fn test_extract_through_nested_subquery_alias() -> Result<()> {
2699        let table_scan = test_table_scan_with_struct()?;
2700        let plan = LogicalPlanBuilder::from(table_scan)
2701            .alias("inner_sub")?
2702            .alias("outer_sub")?
2703            .project(vec![leaf_udf(col("outer_sub.user"), "name")])?
2704            .build()?;
2705
2706        assert_stages!(plan, @r#"
2707        ## Original Plan
2708        Projection: leaf_udf(outer_sub.user, Utf8("name"))
2709          SubqueryAlias: outer_sub
2710            SubqueryAlias: inner_sub
2711              TableScan: test projection=[user]
2712
2713        ## After Extraction
2714        (same as original)
2715
2716        ## After Pushdown
2717        Projection: __datafusion_extracted_1 AS leaf_udf(outer_sub.user,Utf8("name"))
2718          SubqueryAlias: outer_sub
2719            SubqueryAlias: inner_sub
2720              Projection: leaf_udf(test.user, Utf8("name")) AS __datafusion_extracted_1, test.user
2721                TableScan: test projection=[user]
2722
2723        ## Optimized
2724        Projection: __datafusion_extracted_1 AS leaf_udf(outer_sub.user,Utf8("name"))
2725          SubqueryAlias: outer_sub
2726            SubqueryAlias: inner_sub
2727              Projection: leaf_udf(test.user, Utf8("name")) AS __datafusion_extracted_1
2728                TableScan: test projection=[user]
2729        "#)
2730    }
2731
2732    /// Plain columns through SubqueryAlias -- no extraction needed.
2733    #[test]
2734    fn test_subquery_alias_no_extraction() -> Result<()> {
2735        let table_scan = test_table_scan()?;
2736        let plan = LogicalPlanBuilder::from(table_scan)
2737            .alias("sub")?
2738            .project(vec![col("sub.a"), col("sub.b")])?
2739            .build()?;
2740
2741        assert_stages!(plan, @"
2742        ## Original Plan
2743        SubqueryAlias: sub
2744          TableScan: test projection=[a, b]
2745
2746        ## After Extraction
2747        (same as original)
2748
2749        ## After Pushdown
2750        (same as after extraction)
2751
2752        ## Optimized
2753        (same as after pushdown)
2754        ")
2755    }
2756
2757    /// Two UDFs with the same `name()` but different concrete types should NOT be
2758    /// deduplicated -- they are semantically different expressions that happen to
2759    /// collide on `schema_name()`.
2760    #[test]
2761    fn test_different_udfs_same_schema_name_not_deduplicated() -> Result<()> {
2762        let udf_a = Arc::new(ScalarUDF::new_from_impl(
2763            PlacementTestUDF::new()
2764                .with_placement(ExpressionPlacement::MoveTowardsLeafNodes)
2765                .with_id(1),
2766        ));
2767        let udf_b = Arc::new(ScalarUDF::new_from_impl(
2768            PlacementTestUDF::new()
2769                .with_placement(ExpressionPlacement::MoveTowardsLeafNodes)
2770                .with_id(2),
2771        ));
2772
2773        let expr_a = Expr::ScalarFunction(ScalarFunction::new_udf(
2774            udf_a,
2775            vec![col("user"), lit("field")],
2776        ));
2777        let expr_b = Expr::ScalarFunction(ScalarFunction::new_udf(
2778            udf_b,
2779            vec![col("user"), lit("field")],
2780        ));
2781
2782        // Verify preconditions: same schema_name but different Expr
2783        assert_eq!(
2784            expr_a.schema_name().to_string(),
2785            expr_b.schema_name().to_string(),
2786            "Both expressions should have the same schema_name"
2787        );
2788        assert_ne!(
2789            expr_a, expr_b,
2790            "Expressions should NOT be equal (different UDF instances)"
2791        );
2792
2793        let table_scan = test_table_scan_with_struct()?;
2794        let plan = LogicalPlanBuilder::from(table_scan.clone())
2795            .filter(expr_a.clone().eq(lit("a")).and(expr_b.clone().eq(lit("b"))))?
2796            .select(vec![
2797                table_scan
2798                    .schema()
2799                    .index_of_column_by_name(None, "id")
2800                    .unwrap(),
2801            ])?
2802            .build()?;
2803
2804        assert_stages!(plan, @r#"
2805        ## Original Plan
2806        Projection: test.id
2807          Filter: leaf_udf(test.user, Utf8("field")) = Utf8("a") AND leaf_udf(test.user, Utf8("field")) = Utf8("b")
2808            TableScan: test projection=[id, user]
2809
2810        ## After Extraction
2811        Projection: test.id
2812          Projection: test.id, test.user
2813            Filter: __datafusion_extracted_1 = Utf8("a") AND __datafusion_extracted_2 = Utf8("b")
2814              Projection: leaf_udf(test.user, Utf8("field")) AS __datafusion_extracted_1, leaf_udf(test.user, Utf8("field")) AS __datafusion_extracted_2, test.id, test.user
2815                TableScan: test projection=[id, user]
2816
2817        ## After Pushdown
2818        (same as after extraction)
2819
2820        ## Optimized
2821        Projection: test.id
2822          Filter: __datafusion_extracted_1 = Utf8("a") AND __datafusion_extracted_2 = Utf8("b")
2823            Projection: leaf_udf(test.user, Utf8("field")) AS __datafusion_extracted_1, leaf_udf(test.user, Utf8("field")) AS __datafusion_extracted_2, test.id
2824              TableScan: test projection=[id, user]
2825        "#)
2826    }
2827
2828    // =========================================================================
2829    // Filter pushdown interaction tests
2830    // =========================================================================
2831
2832    /// Extraction pushdown through a filter that already had its own
2833    /// `leaf_udf` extracted.
2834    #[test]
2835    fn test_extraction_pushdown_through_filter_with_extracted_predicate() -> Result<()> {
2836        let table_scan = test_table_scan_with_struct()?;
2837        let plan = LogicalPlanBuilder::from(table_scan)
2838            .filter(leaf_udf(col("user"), "status").eq(lit("active")))?
2839            .project(vec![col("id"), leaf_udf(col("user"), "name")])?
2840            .build()?;
2841
2842        assert_stages!(plan, @r#"
2843        ## Original Plan
2844        Projection: test.id, leaf_udf(test.user, Utf8("name"))
2845          Filter: leaf_udf(test.user, Utf8("status")) = Utf8("active")
2846            TableScan: test projection=[id, user]
2847
2848        ## After Extraction
2849        Projection: test.id, leaf_udf(test.user, Utf8("name"))
2850          Projection: test.id, test.user
2851            Filter: __datafusion_extracted_1 = Utf8("active")
2852              Projection: leaf_udf(test.user, Utf8("status")) AS __datafusion_extracted_1, test.id, test.user
2853                TableScan: test projection=[id, user]
2854
2855        ## After Pushdown
2856        Projection: test.id, __datafusion_extracted_2 AS leaf_udf(test.user,Utf8("name"))
2857          Projection: test.id, test.user, __datafusion_extracted_2
2858            Filter: __datafusion_extracted_1 = Utf8("active")
2859              Projection: leaf_udf(test.user, Utf8("status")) AS __datafusion_extracted_1, test.id, test.user, leaf_udf(test.user, Utf8("name")) AS __datafusion_extracted_2
2860                TableScan: test projection=[id, user]
2861
2862        ## Optimized
2863        Projection: test.id, __datafusion_extracted_2 AS leaf_udf(test.user,Utf8("name"))
2864          Filter: __datafusion_extracted_1 = Utf8("active")
2865            Projection: leaf_udf(test.user, Utf8("status")) AS __datafusion_extracted_1, test.id, leaf_udf(test.user, Utf8("name")) AS __datafusion_extracted_2
2866              TableScan: test projection=[id, user]
2867        "#)
2868    }
2869
2870    /// Same expression in filter predicate and projection output.
2871    #[test]
2872    fn test_extraction_pushdown_same_expr_in_filter_and_projection() -> Result<()> {
2873        let table_scan = test_table_scan_with_struct()?;
2874        let field_expr = leaf_udf(col("user"), "status");
2875        let plan = LogicalPlanBuilder::from(table_scan)
2876            .filter(field_expr.clone().gt(lit(5)))?
2877            .project(vec![col("id"), field_expr])?
2878            .build()?;
2879
2880        assert_stages!(plan, @r#"
2881        ## Original Plan
2882        Projection: test.id, leaf_udf(test.user, Utf8("status"))
2883          Filter: leaf_udf(test.user, Utf8("status")) > Int32(5)
2884            TableScan: test projection=[id, user]
2885
2886        ## After Extraction
2887        Projection: test.id, leaf_udf(test.user, Utf8("status"))
2888          Projection: test.id, test.user
2889            Filter: __datafusion_extracted_1 > Int32(5)
2890              Projection: leaf_udf(test.user, Utf8("status")) AS __datafusion_extracted_1, test.id, test.user
2891                TableScan: test projection=[id, user]
2892
2893        ## After Pushdown
2894        Projection: test.id, __datafusion_extracted_2 AS leaf_udf(test.user,Utf8("status"))
2895          Projection: test.id, test.user, __datafusion_extracted_2
2896            Filter: __datafusion_extracted_1 > Int32(5)
2897              Projection: leaf_udf(test.user, Utf8("status")) AS __datafusion_extracted_1, test.id, test.user, leaf_udf(test.user, Utf8("status")) AS __datafusion_extracted_2
2898                TableScan: test projection=[id, user]
2899
2900        ## Optimized
2901        Projection: test.id, __datafusion_extracted_2 AS leaf_udf(test.user,Utf8("status"))
2902          Filter: __datafusion_extracted_1 > Int32(5)
2903            Projection: leaf_udf(test.user, Utf8("status")) AS __datafusion_extracted_1, test.id, leaf_udf(test.user, Utf8("status")) AS __datafusion_extracted_2
2904              TableScan: test projection=[id, user]
2905        "#)
2906    }
2907
2908    /// Left join with a `leaf_udf` filter on the right side AND
2909    /// the projection also selects `leaf_udf` from the right side.
2910    #[test]
2911    fn test_left_join_with_filter_and_projection_extraction() -> Result<()> {
2912        use datafusion_expr::JoinType;
2913
2914        let left = test_table_scan_with_struct()?;
2915        let right = test_table_scan_with_struct_named("right")?;
2916
2917        let plan = LogicalPlanBuilder::from(left)
2918            .join_on(
2919                right,
2920                JoinType::Left,
2921                vec![
2922                    col("test.id").eq(col("right.id")),
2923                    leaf_udf(col("right.user"), "status").gt(lit(5)),
2924                ],
2925            )?
2926            .project(vec![
2927                col("test.id"),
2928                leaf_udf(col("test.user"), "name"),
2929                leaf_udf(col("right.user"), "status"),
2930            ])?
2931            .build()?;
2932
2933        assert_stages!(plan, @r#"
2934        ## Original Plan
2935        Projection: test.id, leaf_udf(test.user, Utf8("name")), leaf_udf(right.user, Utf8("status"))
2936          Left Join:  Filter: test.id = right.id AND leaf_udf(right.user, Utf8("status")) > Int32(5)
2937            TableScan: test projection=[id, user]
2938            TableScan: right projection=[id, user]
2939
2940        ## After Extraction
2941        Projection: test.id, leaf_udf(test.user, Utf8("name")), leaf_udf(right.user, Utf8("status"))
2942          Projection: test.id, test.user, right.id, right.user
2943            Left Join:  Filter: test.id = right.id AND __datafusion_extracted_1 > Int32(5)
2944              TableScan: test projection=[id, user]
2945              Projection: leaf_udf(right.user, Utf8("status")) AS __datafusion_extracted_1, right.id, right.user
2946                TableScan: right projection=[id, user]
2947
2948        ## After Pushdown
2949        Projection: test.id, __datafusion_extracted_2 AS leaf_udf(test.user,Utf8("name")), __datafusion_extracted_3 AS leaf_udf(right.user,Utf8("status"))
2950          Projection: test.id, test.user, right.id, right.user, __datafusion_extracted_2, __datafusion_extracted_3
2951            Left Join:  Filter: test.id = right.id AND __datafusion_extracted_1 > Int32(5)
2952              Projection: leaf_udf(test.user, Utf8("name")) AS __datafusion_extracted_2, test.id, test.user
2953                TableScan: test projection=[id, user]
2954              Projection: leaf_udf(right.user, Utf8("status")) AS __datafusion_extracted_1, right.id, right.user, leaf_udf(right.user, Utf8("status")) AS __datafusion_extracted_3
2955                TableScan: right projection=[id, user]
2956
2957        ## Optimized
2958        Projection: test.id, __datafusion_extracted_2 AS leaf_udf(test.user,Utf8("name")), __datafusion_extracted_3 AS leaf_udf(right.user,Utf8("status"))
2959          Left Join:  Filter: test.id = right.id AND __datafusion_extracted_1 > Int32(5)
2960            Projection: leaf_udf(test.user, Utf8("name")) AS __datafusion_extracted_2, test.id
2961              TableScan: test projection=[id, user]
2962            Projection: leaf_udf(right.user, Utf8("status")) AS __datafusion_extracted_1, right.id, leaf_udf(right.user, Utf8("status")) AS __datafusion_extracted_3
2963              TableScan: right projection=[id, user]
2964        "#)
2965    }
2966
2967    /// Extraction projection pushed through a filter whose predicate
2968    /// references a different extracted expression.
2969    #[test]
2970    fn test_pure_extraction_proj_push_through_filter() -> Result<()> {
2971        let table_scan = test_table_scan_with_struct()?;
2972        let plan = LogicalPlanBuilder::from(table_scan)
2973            .filter(leaf_udf(col("user"), "status").gt(lit(5)))?
2974            .project(vec![
2975                col("id"),
2976                leaf_udf(col("user"), "name"),
2977                leaf_udf(col("user"), "status"),
2978            ])?
2979            .build()?;
2980
2981        assert_stages!(plan, @r#"
2982        ## Original Plan
2983        Projection: test.id, leaf_udf(test.user, Utf8("name")), leaf_udf(test.user, Utf8("status"))
2984          Filter: leaf_udf(test.user, Utf8("status")) > Int32(5)
2985            TableScan: test projection=[id, user]
2986
2987        ## After Extraction
2988        Projection: test.id, leaf_udf(test.user, Utf8("name")), leaf_udf(test.user, Utf8("status"))
2989          Projection: test.id, test.user
2990            Filter: __datafusion_extracted_1 > Int32(5)
2991              Projection: leaf_udf(test.user, Utf8("status")) AS __datafusion_extracted_1, test.id, test.user
2992                TableScan: test projection=[id, user]
2993
2994        ## After Pushdown
2995        Projection: test.id, __datafusion_extracted_2 AS leaf_udf(test.user,Utf8("name")), __datafusion_extracted_3 AS leaf_udf(test.user,Utf8("status"))
2996          Projection: test.id, test.user, __datafusion_extracted_2, __datafusion_extracted_3
2997            Filter: __datafusion_extracted_1 > Int32(5)
2998              Projection: leaf_udf(test.user, Utf8("status")) AS __datafusion_extracted_1, test.id, test.user, leaf_udf(test.user, Utf8("name")) AS __datafusion_extracted_2, leaf_udf(test.user, Utf8("status")) AS __datafusion_extracted_3
2999                TableScan: test projection=[id, user]
3000
3001        ## Optimized
3002        Projection: test.id, __datafusion_extracted_2 AS leaf_udf(test.user,Utf8("name")), __datafusion_extracted_3 AS leaf_udf(test.user,Utf8("status"))
3003          Filter: __datafusion_extracted_1 > Int32(5)
3004            Projection: leaf_udf(test.user, Utf8("status")) AS __datafusion_extracted_1, test.id, leaf_udf(test.user, Utf8("name")) AS __datafusion_extracted_2, leaf_udf(test.user, Utf8("status")) AS __datafusion_extracted_3
3005              TableScan: test projection=[id, user]
3006        "#)
3007    }
3008
3009    /// When an extraction projection's __extracted alias references a column
3010    /// (e.g. `user`) that is NOT a standalone expression in the projection,
3011    /// the merge into the inner projection should still succeed.
3012    #[test]
3013    fn test_merge_extraction_into_projection_with_column_ref_inflation() -> Result<()> {
3014        let table_scan = test_table_scan_with_struct()?;
3015
3016        // Inner projection (simulates a trimmed projection)
3017        let inner = LogicalPlanBuilder::from(table_scan)
3018            .project(vec![col("user"), col("id")])?
3019            .build()?;
3020
3021        // Outer projection: __extracted alias + id (but NOT user as standalone).
3022        // The alias references `user` internally, inflating columns_needed.
3023        let plan = LogicalPlanBuilder::from(inner)
3024            .project(vec![
3025                leaf_udf(col("user"), "status")
3026                    .alias(format!("{EXTRACTED_EXPR_PREFIX}_1")),
3027                col("id"),
3028            ])?
3029            .build()?;
3030
3031        // Run only PushDownLeafProjections
3032        let ctx = OptimizerContext::new().with_max_passes(1);
3033        let optimizer =
3034            Optimizer::with_rules(vec![Arc::new(PushDownLeafProjections::new())]);
3035        let result = optimizer.optimize(plan, &ctx, |_, _| {})?;
3036
3037        // With the fix: merge succeeds → extraction merged into inner projection.
3038        // Without the fix: merge rejected → two separate projections remain.
3039        insta::assert_snapshot!(format!("{result}"), @r#"
3040        Projection: __datafusion_extracted_1, test.id
3041          Projection: test.user, test.id, leaf_udf(test.user, Utf8("status")) AS __datafusion_extracted_1
3042            TableScan: test
3043        "#);
3044
3045        Ok(())
3046    }
3047
3048    /// Regression test: the optimizer must not push extractions through
3049    /// `Unnest`.
3050    ///
3051    /// `try_push_into_inputs` routes extracted pairs to inputs by column name.
3052    /// `Unnest` can emit an output column with the same name as its input
3053    /// column but a different value/type (the unnested element), so name-based
3054    /// routing cannot tell the two apart. `try_push_into_inputs` therefore
3055    /// treats `Unnest` as a barrier and bails instead of pushing through it
3056    /// (see the `matches!(node, LogicalPlan::Unnest(_))` guard there).
3057    #[test]
3058    fn test_no_push_through_unnest() -> Result<()> {
3059        use arrow::datatypes::{DataType, Field, Schema};
3060
3061        let schema = Schema::new(vec![
3062            Field::new("list_col", DataType::new_list(DataType::Int32, true), true),
3063            Field::new("other_col", DataType::Int32, true),
3064        ]);
3065        let table_scan =
3066            datafusion_expr::logical_plan::table_scan(Some("t"), &schema, None)?
3067                .build()?;
3068        let plan = LogicalPlanBuilder::from(table_scan)
3069            .unnest_column("list_col")?
3070            .filter(leaf_udf(col("list_col"), "x").eq(lit(1i32)))?
3071            .build()?;
3072
3073        let ctx = OptimizerContext::new().with_max_passes(1);
3074        let optimizer = Optimizer::with_rules(vec![
3075            Arc::new(ExtractLeafExpressions::new()),
3076            Arc::new(PushDownLeafProjections::new()),
3077        ]);
3078        let optimized = optimizer.optimize(plan, &ctx, |_, _| {})?;
3079
3080        insta::assert_snapshot!(format!("{optimized}"), @r#"
3081        Projection: list_col, t.other_col
3082          Filter: __datafusion_extracted_1 = Int32(1)
3083            Projection: leaf_udf(list_col, Utf8("x")) AS __datafusion_extracted_1, list_col, t.other_col
3084              Unnest: lists[t.list_col|depth=1] structs[]
3085                TableScan: t
3086        "#);
3087
3088        Ok(())
3089    }
3090
3091    /// Regression test: a leaf expression used in **both** the filter and the
3092    /// projection, with the **bare base column** also projected, over a
3093    /// `SubqueryAlias` whose projection emits an **extra column the outer query
3094    /// never consumes** (`synth`).
3095    ///
3096    /// This reproduces a production failure where the leaf-pushdown passes drop
3097    /// the bare passthrough column from an intermediate schema, causing the
3098    /// subsequent `optimize_projections` run to fail with:
3099    /// `Schema error: No field named __datafusion_extracted_N`.
3100    ///
3101    /// Equivalent SQL:
3102    /// ```sql
3103    /// CREATE VIEW v AS SELECT user, id, id + 1 AS synth FROM test;
3104    /// SELECT user['status'], user, id FROM v WHERE user['status'] IS NOT NULL;
3105    /// ```
3106    #[test]
3107    fn test_subquery_alias_with_unconsumed_column() -> Result<()> {
3108        let table_scan = test_table_scan_with_struct()?;
3109
3110        // This is the plan shape *after* `push_down_filter` has run: it pushes
3111        // the `leaf_udf(...)` filter down through the `SubqueryAlias` and below
3112        // the view's inner projection. The filter and the outer projection now
3113        // each contain the same leaf expression but are separated by the
3114        // `SubqueryAlias`, so they extract into two *independent* aliases
3115        // (`__datafusion_extracted_1` from the filter, `__datafusion_extracted_2`
3116        // from the projection) instead of deduplicating into one.
3117        //
3118        // The view projects an extra `synth` column the outer query never
3119        // consumes — without it the bug does not manifest.
3120        let inner = LogicalPlanBuilder::from(table_scan)
3121            .filter(leaf_udf(col("user"), "status").is_not_null())?
3122            .project(vec![
3123                col("user"),
3124                col("id"),
3125                (col("id") + lit(1u32)).alias("synth"),
3126            ])?
3127            .alias("v")?
3128            .build()?;
3129
3130        // Outer projection: leaf expr + the bare base column + id.
3131        let plan = LogicalPlanBuilder::from(inner)
3132            .project(vec![
3133                leaf_udf(col("v.user"), "status"),
3134                col("v.user"),
3135                col("v.id"),
3136            ])?
3137            .build()?;
3138
3139        // Run the leaf-pushdown passes followed by `optimize_projections`,
3140        // exactly as the default optimizer schedules them. `optimize_projections`
3141        // is what prunes the unused `synth` column and validates the plan; if the
3142        // leaf passes drop the bare `v.user` passthrough column it fails with
3143        // `Schema error: No field named __datafusion_extracted_N`.
3144        let ctx = OptimizerContext::new();
3145        let optimizer = Optimizer::with_rules(vec![
3146            Arc::new(ExtractLeafExpressions::new()),
3147            Arc::new(PushDownLeafProjections::new()),
3148            Arc::new(OptimizeProjections::new()),
3149        ]);
3150        let optimized = optimizer.optimize(plan, &ctx, |_, _| {})?;
3151
3152        // The bare `test.user` passthrough column is preserved and the view's
3153        // output schema (`user`, `id`, `__datafusion_extracted_2`) is restored
3154        // by a recovery projection, so `optimize_projections` succeeds.
3155        insta::assert_snapshot!(format!("{optimized}"), @r#"
3156        Projection: __datafusion_extracted_2 AS leaf_udf(v.user,Utf8("status")), v.user, v.id
3157          SubqueryAlias: v
3158            Projection: test.user, test.id, __datafusion_extracted_2
3159              Filter: __datafusion_extracted_1 IS NOT NULL
3160                Projection: leaf_udf(test.user, Utf8("status")) AS __datafusion_extracted_1, test.id, test.user, leaf_udf(test.user, Utf8("status")) AS __datafusion_extracted_2
3161                  TableScan: test projection=[id, user]
3162        "#);
3163
3164        Ok(())
3165    }
3166}