Skip to content

Implement ExecutionPlan::map_expressions #20899

Description

@LiaCastaneda

Is your feature request related to a problem or challenge?

ExecutionPlan::apply_expressions (#20337) added a read-only visitor API for physical expressions. To complete the API that mirrors LogicalPlan::map_expressions — we should also implement a write counterpart that allows transforming expressions and reconstructing the node.

Use cases:

Describe the solution you'd like

Proposed API:

fn map_expressions<F>(
    self: Arc<Self>,
    f: &mut F,
) -> Result<Transformed<Arc<dyn ExecutionPlan>>>
where
    F: FnMut(Arc<dyn PhysicalExpr>) -> Result<Transformed<Arc<dyn PhysicalExpr>>>;

Each ExecutionPlan implementor is responsible for transforming its expressions and reconstructing itself using direct struct construction, so that the PlanProperties (schema, ordering, partitioning) are reused without recomputation -- matching the behavior of LogicalPlan::map_expressions.

Describe alternatives you've considered

No response

Additional context

No response

Activity

  1. LiaCastaneda commented on Mar 13, 2026

    @LiaCastaneda
    ContributorAuthor

    Bringing some discussions from the apply_expressions PR:

    I think we can reuse the properties of the rest of the plan (avoiding ::new()), similar to how LogicalPlan::map_expressions does it.

    I created this issue #20899. I haven't started working on it yet and probably won't have much time this week, so I'll likely give it a try next week, but feel free to take it if you'd like'

    Actually, now that I think about it, there are some cases where we would need to recompute properties right? for example, if a user changes an expression from a > something to a < something. How do we specify in this API whether we want to recompute properties or not? should map_expressions have a recompute_properties: bool argument? 🤔

    From @askalt

    Yes, it may be useful to explicitly ask for properties re-computation. And it seems for me that by default the safest option is to force properties to be re-computed.

    Another way to satisfy it is to introduce "args struct" like:

    struct MapExpressionsArgs<'a> {
    f: &'a dyn FnMut(&Arc) -> Result<Arc>,
    preserve_properties: bool,
    }
    Like is done here:

    datafusion/datafusion/catalog/src/table.rs

    Lines 366 to 372 in 8d9b080

    /// Arguments for scanning a table with [TableProvider::scan_with_args].
    #[derive(Debug, Clone, Default)]
    pub struct ScanArgs<'a> {
    filters: Option<&'a [Expr]>,
    projection: Option<&'a [usize]>,
    limit: Option,
    }
    to not add a bool argument each time when the method semantics is extended. But maybe this is overkill here and bool parameter will be enough.

  2. LiaCastaneda commented on Mar 13, 2026

    @LiaCastaneda
    ContributorAuthor

    I think the only thing that feels weird to me is that implementors will receive preserve_properties and will have to do something different based on its value... 🤔

    if preserve_properties {
        recreate node using new::
    } else {
        recreate node not using new::
    }
    

    which adds boilerplate to every ExecutionPlan implementation since this method would be required.

    I see other execution plan nodes have separate functions for explicitly not recomputing properties, for example with_new_children_and_same_properties

  3. askalt commented on Mar 13, 2026

    @askalt
    Contributor

    Implementing two methods with the similar semantic seems hard to maintain. What if we inject recompute properties behavior to the closure return type with a lightweight structure?

    pub enum RecomputePropertiesBehavior {
        Preserve,
        Recompute,
    }
    
    pub struct MappedExpr {
        pub properties_behavior: RecomputePropertiesBehavior,
        pub expr: Arc<dyn PhysicalExpr>,
    }
    
    pub trait ExecutionPlan {
       ...
    
        fn map_expressions(
            self: Arc<Self>,
            f: &dyn FnMut(Arc<dyn PhysicalExpr>) -> Result<Transformed<MappedExpr>>,
        ) -> Result<Transformed<Arc<dyn ExecutionPlan>>>;
    }

    And then if at least one returned expression has RecomputePropertiesBehavior::Recompute -- the implementation will recompute them and leave as is otherwise.

  4. LiaCastaneda commented on Mar 15, 2026

    @LiaCastaneda
    ContributorAuthor

    Implementing two methods with the similar semantic seems hard to maintain. What if we inject recompute properties behavior to the closure return type with a lightweight structure?

    mmm i think one limitation would be that the Recompute path assumes each ExecutionPlan implementor knows how to rebuild their own plan properties. For native nodes this might be fine since compute_properties is called internally, but for people implementing a custom ExecutionPlan, there is no standard public API to say "recompute my properties given my current state." They would have to call either their own ::new() constructor (which may do more than just recompute properties) or manually construct PlanProperties . Maybe it makes semse to make recompute_properties part of the executionPlan trait (probably optional to implement)?

  5. askalt commented on Mar 17, 2026

    @askalt
    Contributor

    Maybe it makes semse to make recompute_properties part of the executionPlan trait (probably optional to implement)?

    Do not fully understand how this will help map_expressions implementation to recompute them. Each implementation could have its own PlanStruct::recompute_properties and call it (it's not necessary to make it as the trait method).

    There is an alternative, add ExecutionPlan::recompute_properties and force to implement it and map_expressions_preserve_properties. Then we can define:

    pub fn map_expressions(...) -> Result<Arc<dyn ExecutionPlan>> {
           let mapped = self.map_expressions_preserve_properties(...);
           mapped.recompute_properties()
    }

    Please note that recompute_properties currently cannot be expressed as just plan.with_new_children(plan.children()), as there is an optimization that does not recompute properties if children properties stay the same.

  6. LiaCastaneda commented on Mar 17, 2026

    @LiaCastaneda
    ContributorAuthor

    Do not fully understand how this will help map_expressions implementation to recompute them. Each implementation could have its own PlanStruct::recompute_properties and call it (it's not necessary to make it as the trait method).

    I think it just gives users a uniform way to trigger property recomputation on any ExecutionPlan without knowing the concrete type, and avoids the need to downcast to specific nodes if we ever need to call it externally. I think you are right though, this could be a follow-up rather than a prerequisite. I wasn't aware that properties() is already part of the ExecutionPlan trait, which makes all of this more standard than I initially thought.

  7. LiaCastaneda commented on Mar 17, 2026

    @LiaCastaneda
    ContributorAuthor

    made a rough draft here #20986 (mostly claude generated). I still need to review it in detail.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions