This is an automated email from the ASF dual-hosted git repository.

dianfu pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/flink.git


The following commit(s) were added to refs/heads/master by this push:
     new 8e9e69cb0ed [FLINK-39986][table-planner] Support Python UDF 
deduplication in projection and condition (#28638)
8e9e69cb0ed is described below

commit 8e9e69cb0ed430fbce873ea0e6ab6afdbad85421
Author: RaoraoXiong <[email protected]>
AuthorDate: Fri Aug 28 11:14:24 2026 +0800

    [FLINK-39986][table-planner] Support Python UDF deduplication in projection 
and condition (#28638)
    
    The duplication originates in the planner: RexProgram already shares
    structurally identical expressions through RexLocalRef, but the Python calc
    translation calls RexProgram#expandLocalRef, which expands the shared
    reference back into independent expression trees.
    
    This is now addressed by two logical optimizer rules, so that neither
    CommonExecPythonCalc nor the projection codegen needs to be aware of it:
    
    - RemoteCalcConditionProjectionCseRule rewrites a Calc projection to
      reference UDF calls already computed by the Calc below it, which removes
      duplicates shared between a WHERE condition and a SELECT projection.
    - RemoteCalcProjectionCseRule splits a Calc containing duplicated
      deterministic calls into a bottom Calc computing each distinct call once
      plus a top Calc projecting the shared results back into their original
      positions.
    
    Both rules share their reusability predicate through RemoteCalcCseUtil so
    they agree on which calls may safely share a single evaluation.
    Non-deterministic calls are always evaluated independently.
    
    Generated-by: Claude-4.6-Opus
---
 .../planner/plan/rules/FlinkBatchRuleSets.scala    |   2 +
 .../planner/plan/rules/FlinkStreamRuleSets.scala   |   4 +
 .../plan/rules/logical/PythonCalcSplitRule.scala   |   9 +-
 .../RemoteCalcConditionProjectionCseRule.java      | 252 +++++++++++++++++++++
 .../plan/rules/logical/RemoteCalcCseUtil.java      | 125 ++++++++++
 .../rules/logical/RemoteCalcProjectionCseRule.java | 237 +++++++++++++++++++
 .../planner/plan/stream/sql/PythonCalcCseTest.java | 126 +++++++++++
 .../utils/JavaUserDefinedScalarFunctions.java      |  34 +++
 .../planner/plan/stream/sql/PythonCalcCseTest.xml  | 208 +++++++++++++++++
 9 files changed, 995 insertions(+), 2 deletions(-)

diff --git 
a/flink-table/flink-table-planner/src/main/scala/org/apache/flink/table/planner/plan/rules/FlinkBatchRuleSets.scala
 
b/flink-table/flink-table-planner/src/main/scala/org/apache/flink/table/planner/plan/rules/FlinkBatchRuleSets.scala
index 26553f24ddc..780041f862c 100644
--- 
a/flink-table/flink-table-planner/src/main/scala/org/apache/flink/table/planner/plan/rules/FlinkBatchRuleSets.scala
+++ 
b/flink-table/flink-table-planner/src/main/scala/org/apache/flink/table/planner/plan/rules/FlinkBatchRuleSets.scala
@@ -391,11 +391,13 @@ object FlinkBatchRuleSets {
     PythonCalcSplitRule.SPLIT_CONDITION_REX_FIELD,
     PythonCalcSplitRule.SPLIT_PROJECTION_REX_FIELD,
     PythonCalcSplitRule.SPLIT_CONDITION,
+    PythonCalcSplitRule.CONDITION_PROJECTION_CSE,
     PythonCalcSplitRule.SPLIT_PROJECT,
     PythonCalcSplitRule.SPLIT_PANDAS_IN_PROJECT,
     PythonCalcSplitRule.EXPAND_PROJECT,
     PythonCalcSplitRule.PUSH_CONDITION,
     PythonCalcSplitRule.REWRITE_PROJECT,
+    PythonCalcSplitRule.PROJECTION_CSE,
     PythonMapRenameRule.INSTANCE,
     PythonMapMergeRule.INSTANCE,
     AsyncCalcSplitRule.SPLIT_CONDITION_REX_FIELD,
diff --git 
a/flink-table/flink-table-planner/src/main/scala/org/apache/flink/table/planner/plan/rules/FlinkStreamRuleSets.scala
 
b/flink-table/flink-table-planner/src/main/scala/org/apache/flink/table/planner/plan/rules/FlinkStreamRuleSets.scala
index de637d07a24..ed5327e49fa 100644
--- 
a/flink-table/flink-table-planner/src/main/scala/org/apache/flink/table/planner/plan/rules/FlinkStreamRuleSets.scala
+++ 
b/flink-table/flink-table-planner/src/main/scala/org/apache/flink/table/planner/plan/rules/FlinkStreamRuleSets.scala
@@ -415,6 +415,8 @@ object FlinkStreamRuleSets {
     PythonCalcSplitRule.SPLIT_PROJECTION_REX_FIELD,
     // Avoids dealing with a python call in the condition.
     PythonCalcSplitRule.SPLIT_CONDITION,
+    // Deduplicates Python UDF calls shared between condition and projection.
+    PythonCalcSplitRule.CONDITION_PROJECTION_CSE,
     // Avoids dealing with Java calls in the same Calc as python calls.
     PythonCalcSplitRule.SPLIT_PROJECT,
     // Splits calcs which contain both general Python functions and pandas 
Python functions
@@ -425,6 +427,8 @@ object FlinkStreamRuleSets {
     PythonCalcSplitRule.PUSH_CONDITION,
     // Orders the projections so that input references are first, followed by 
python calls.
     PythonCalcSplitRule.REWRITE_PROJECT,
+    // Deduplicates identical deterministic python calls within the projection.
+    PythonCalcSplitRule.PROJECTION_CSE,
     // Renames the field names of the Flatten calc which is right after a calc 
representing a
     // Python Map operation to the output names of the map function
     PythonMapRenameRule.INSTANCE,
diff --git 
a/flink-table/flink-table-planner/src/main/scala/org/apache/flink/table/planner/plan/rules/logical/PythonCalcSplitRule.scala
 
b/flink-table/flink-table-planner/src/main/scala/org/apache/flink/table/planner/plan/rules/logical/PythonCalcSplitRule.scala
index 7e54ffbca03..642ca5f5691 100644
--- 
a/flink-table/flink-table-planner/src/main/scala/org/apache/flink/table/planner/plan/rules/logical/PythonCalcSplitRule.scala
+++ 
b/flink-table/flink-table-planner/src/main/scala/org/apache/flink/table/planner/plan/rules/logical/PythonCalcSplitRule.scala
@@ -96,11 +96,14 @@ class PythonRemoteCallFinder extends RemoteCallFinder {
 object PythonCalcSplitRule {
 
   /**
-   * These rules should be applied sequentially in the order of 
SPLIT_CONDITION, SPLIT_PROJECT,
-   * SPLIT_PANDAS_IN_PROJECT, EXPAND_PROJECT, PUSH_CONDITION and 
REWRITE_PROJECT.
+   * These rules should be applied sequentially in the order of 
SPLIT_CONDITION,
+   * CONDITION_PROJECTION_CSE, SPLIT_PROJECT, SPLIT_PANDAS_IN_PROJECT, 
EXPAND_PROJECT,
+   * PUSH_CONDITION, REWRITE_PROJECT and PROJECTION_CSE.
    */
   private val callFinder = new PythonRemoteCallFinder()
   val SPLIT_CONDITION: RelOptRule = new 
RemoteCalcSplitConditionRule(callFinder)
+  val CONDITION_PROJECTION_CSE: RelOptRule =
+    
RemoteCalcConditionProjectionCseRule.Config.DEFAULT.withRemoteCallFinder(callFinder).toRule()
   val SPLIT_PROJECT: RelOptRule = new RemoteCalcSplitProjectionRule(callFinder)
   val SPLIT_PANDAS_IN_PROJECT: RelOptRule = new 
PythonCalcSplitPandasInProjectionRule(callFinder)
   val SPLIT_PROJECTION_REX_FIELD: RelOptRule = new 
RemoteCalcSplitProjectionRexFieldRule(callFinder)
@@ -108,4 +111,6 @@ object PythonCalcSplitRule {
   val EXPAND_PROJECT: RelOptRule = new RemoteCalcExpandProjectRule(callFinder)
   val PUSH_CONDITION: RelOptRule = new RemoteCalcPushConditionRule(callFinder)
   val REWRITE_PROJECT: RelOptRule = new 
RemoteCalcRewriteProjectionRule(callFinder)
+  val PROJECTION_CSE: RelOptRule =
+    
RemoteCalcProjectionCseRule.Config.DEFAULT.withRemoteCallFinder(callFinder).toRule()
 }
diff --git 
a/flink-table/flink-table-planner/src/main/scala/org/apache/flink/table/planner/plan/rules/logical/RemoteCalcConditionProjectionCseRule.java
 
b/flink-table/flink-table-planner/src/main/scala/org/apache/flink/table/planner/plan/rules/logical/RemoteCalcConditionProjectionCseRule.java
new file mode 100644
index 00000000000..a1f9b328e48
--- /dev/null
+++ 
b/flink-table/flink-table-planner/src/main/scala/org/apache/flink/table/planner/plan/rules/logical/RemoteCalcConditionProjectionCseRule.java
@@ -0,0 +1,252 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to you under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.flink.table.planner.plan.rules.logical;
+
+import org.apache.flink.table.planner.plan.nodes.logical.FlinkLogicalCalc;
+
+import org.apache.calcite.plan.RelOptRuleCall;
+import org.apache.calcite.plan.RelRule;
+import org.apache.calcite.rel.type.RelDataType;
+import org.apache.calcite.rex.RexBuilder;
+import org.apache.calcite.rex.RexCall;
+import org.apache.calcite.rex.RexInputRef;
+import org.apache.calcite.rex.RexNode;
+import org.apache.calcite.rex.RexProgram;
+import org.apache.calcite.rex.RexShuttle;
+import org.immutables.value.Value;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+/**
+ * Rule that eliminates common remote (e.g. Python or async) UDF 
sub-expressions between the
+ * condition and projection of a Calc node.
+ *
+ * <p>After {@link RemoteCalcSplitConditionRule} splits a Calc with remote 
UDFs in its condition, the
+ * result is a two-level Calc structure:
+ *
+ * <pre>
+ * TopCalc(projection=[remoteFunc(a, b) + 1, remoteFunc(a, b) + 2], 
condition=[$2 &gt; 0])
+ *   BottomCalc(projection=[a, b, remoteFunc(a, b) AS f0])
+ * </pre>
+ *
+ * <p>The TopCalc's projection still contains {@code remoteFunc(a, b)} which 
is structurally
+ * identical to the already-computed {@code f0} in the BottomCalc. This rule 
detects such duplicates
+ * and rewrites the TopCalc's projection to reference the BottomCalc's output 
directly:
+ *
+ * <pre>
+ * TopCalc(projection=[$2 + 1, $2 + 2], condition=[$2 &gt; 0])
+ *   BottomCalc(projection=[a, b, remoteFunc(a, b) AS f0])
+ * </pre>
+ *
+ * <p>Note that the two Calcs do not share a coordinate system: the 
BottomCalc's expressions are
+ * written against its own input, while the TopCalc's are written against the 
BottomCalc's output.
+ * The BottomCalc's calls are therefore translated into the TopCalc's frame of 
reference before
+ * being compared, so that calls which merely look alike are not treated as 
equal.
+ */
[email protected]
+public class RemoteCalcConditionProjectionCseRule
+        extends RelRule<RemoteCalcConditionProjectionCseRule.Config> {
+
+    protected RemoteCalcConditionProjectionCseRule(Config config) {
+        super(config);
+    }
+
+    @Override
+    public boolean matches(RelOptRuleCall call) {
+        FlinkLogicalCalc topCalc = call.rel(0);
+        FlinkLogicalCalc bottomCalc = call.rel(1);
+        RemoteCallFinder callFinder = config.remoteCallFinder();
+
+        // Only applies when the top calc has a condition.
+        if (topCalc.getProgram().getCondition() == null) {
+            return false;
+        }
+
+        Map<RexNode, Integer> bottomRemoteCalls = 
buildBottomRemoteCallMap(bottomCalc, callFinder);
+        if (bottomRemoteCalls.isEmpty()) {
+            return false;
+        }
+
+        List<RexNode> topProjects = RemoteCalcCseUtil.expandProjects(topCalc);
+        return topProjects.stream()
+                .anyMatch(node -> containsCallMatchingBottom(node, 
bottomRemoteCalls, callFinder));
+    }
+
+    @Override
+    public void onMatch(RelOptRuleCall call) {
+        FlinkLogicalCalc topCalc = call.rel(0);
+        FlinkLogicalCalc bottomCalc = call.rel(1);
+        RemoteCallFinder callFinder = config.remoteCallFinder();
+        RexBuilder rexBuilder = call.builder().getRexBuilder();
+
+        List<RexNode> topProjects = RemoteCalcCseUtil.expandProjects(topCalc);
+        RexNode topCondition =
+                
topCalc.getProgram().expandLocalRef(topCalc.getProgram().getCondition());
+
+        Map<RexNode, Integer> bottomRemoteCalls = 
buildBottomRemoteCallMap(bottomCalc, callFinder);
+        RelDataType bottomRowType = bottomCalc.getRowType();
+
+        // Rewrite top projections: replace matching calls with RexInputRef.
+        CseRewriteShuttle rewriter = new CseRewriteShuttle(bottomRemoteCalls, 
bottomRowType);
+        List<RexNode> newTopProjects =
+                topProjects.stream().map(p -> 
p.accept(rewriter)).collect(Collectors.toList());
+
+        if (!rewriter.hasRewritten()) {
+            return;
+        }
+
+        // Build the new top calc with rewritten projections.
+        call.transformTo(
+                topCalc.copy(
+                        topCalc.getTraitSet(),
+                        bottomCalc,
+                        RexProgram.create(
+                                bottomRowType,
+                                newTopProjects,
+                                topCondition,
+                                topCalc.getRowType(),
+                                rexBuilder)));
+    }
+
+    /**
+     * Builds a map from the bottom calc's reusable remote calls to their 
output index, with each
+     * call rewritten into the top calc's frame of reference.
+     *
+     * <p>The bottom calc's expressions are written against its own input, 
whereas the top calc
+     * addresses the bottom calc's output, so the two must be brought into a 
common frame before
+     * being compared. See {@link RemoteCalcCseUtil#translateToOutputFrame}.
+     */
+    private static Map<RexNode, Integer> buildBottomRemoteCallMap(
+            FlinkLogicalCalc bottomCalc, RemoteCallFinder callFinder) {
+        List<RexNode> bottomProjects = 
RemoteCalcCseUtil.expandProjects(bottomCalc);
+        Map<Integer, Integer> forwardedFieldPositions =
+                RemoteCalcCseUtil.forwardedFieldPositions(bottomProjects);
+
+        Map<RexNode, Integer> result = new HashMap<>();
+        for (int i = 0; i < bottomProjects.size(); i++) {
+            RexNode project = bottomProjects.get(i);
+            if (!RemoteCalcCseUtil.containsReusableRemoteCall(project, 
callFinder)) {
+                continue;
+            }
+            RexNode translated =
+                    RemoteCalcCseUtil.translateToOutputFrame(project, 
forwardedFieldPositions);
+            if (translated != null) {
+                result.put(translated, i);
+            }
+        }
+        return result;
+    }
+
+    private static boolean containsCallMatchingBottom(
+            RexNode node, Map<RexNode, Integer> bottomRemoteCalls, 
RemoteCallFinder callFinder) {
+        if (node instanceof RexCall) {
+            RexCall rexCall = (RexCall) node;
+            if (callFinder.isRemoteCall(rexCall) && 
bottomRemoteCalls.containsKey(rexCall)) {
+                return true;
+            }
+            return rexCall.getOperands().stream()
+                    .anyMatch(op -> containsCallMatchingBottom(op, 
bottomRemoteCalls, callFinder));
+        }
+        return false;
+    }
+
+    @Override
+    public boolean equals(Object obj) {
+        if (this == obj) {
+            return true;
+        }
+        if (!(obj instanceof RemoteCalcConditionProjectionCseRule)) {
+            return false;
+        }
+        RemoteCalcConditionProjectionCseRule other = 
(RemoteCalcConditionProjectionCseRule) obj;
+        return super.equals(other)
+                && config.remoteCallFinder()
+                        .getClass()
+                        .equals(other.config.remoteCallFinder().getClass());
+    }
+
+    @Override
+    public int hashCode() {
+        return super.hashCode() * 31 + 
config.remoteCallFinder().getClass().hashCode();
+    }
+
+    // 
-------------------------------------------------------------------------
+
+    /**
+     * Replaces remote UDF calls in the top calc's projection with a 
RexInputRef pointing at the
+     * bottom calc's output position where the same call was already computed.
+     */
+    private static class CseRewriteShuttle extends RexShuttle {
+        private final Map<RexNode, Integer> bottomRemoteCalls;
+        private final RelDataType bottomRowType;
+        private boolean rewritten = false;
+
+        CseRewriteShuttle(Map<RexNode, Integer> bottomRemoteCalls, RelDataType 
bottomRowType) {
+            this.bottomRemoteCalls = bottomRemoteCalls;
+            this.bottomRowType = bottomRowType;
+        }
+
+        boolean hasRewritten() {
+            return rewritten;
+        }
+
+        @Override
+        public RexNode visitCall(RexCall call) {
+            Integer idx = bottomRemoteCalls.get(call);
+            if (idx != null) {
+                rewritten = true;
+                return new RexInputRef(idx, 
bottomRowType.getFieldList().get(idx).getType());
+            }
+            return super.visitCall(call);
+        }
+    }
+
+    // 
-------------------------------------------------------------------------
+
+    /** Rule configuration. */
+    @Value.Immutable(singleton = false)
+    public interface Config extends RelRule.Config {
+        Config DEFAULT =
+                ImmutableRemoteCalcConditionProjectionCseRule.Config.builder()
+                        .operandSupplier(
+                                b0 ->
+                                        b0.operand(FlinkLogicalCalc.class)
+                                                .oneInput(
+                                                        b1 ->
+                                                                
b1.operand(FlinkLogicalCalc.class)
+                                                                        
.anyInputs()))
+                        .description("RemoteCalcConditionProjectionCseRule")
+                        .build();
+
+        @Value.Default
+        default RemoteCallFinder remoteCallFinder() {
+            return new PythonRemoteCallFinder();
+        }
+
+        /** Sets {@link #remoteCallFinder()}. */
+        Config withRemoteCallFinder(RemoteCallFinder callFinder);
+
+        @Override
+        default RemoteCalcConditionProjectionCseRule toRule() {
+            return new RemoteCalcConditionProjectionCseRule(this);
+        }
+    }
+}
diff --git 
a/flink-table/flink-table-planner/src/main/scala/org/apache/flink/table/planner/plan/rules/logical/RemoteCalcCseUtil.java
 
b/flink-table/flink-table-planner/src/main/scala/org/apache/flink/table/planner/plan/rules/logical/RemoteCalcCseUtil.java
new file mode 100644
index 00000000000..e6bddec8c24
--- /dev/null
+++ 
b/flink-table/flink-table-planner/src/main/scala/org/apache/flink/table/planner/plan/rules/logical/RemoteCalcCseUtil.java
@@ -0,0 +1,125 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to you under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.flink.table.planner.plan.rules.logical;
+
+import org.apache.flink.table.planner.plan.nodes.logical.FlinkLogicalCalc;
+import org.apache.flink.table.planner.utils.ShortcutUtils;
+
+import org.apache.calcite.rex.RexInputRef;
+import org.apache.calcite.rex.RexNode;
+import org.apache.calcite.rex.RexProgram;
+import org.apache.calcite.rex.RexShuttle;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+/**
+ * Shared helpers for the remote (e.g. Python) call common sub-expression 
elimination rules, namely
+ * {@link RemoteCalcProjectionCseRule} and {@link 
RemoteCalcConditionProjectionCseRule}.
+ *
+ * <p>Keeping the reusability predicate in one place ensures both rules agree 
on which calls may
+ * safely share a single evaluation.
+ */
+class RemoteCalcCseUtil {
+
+    private RemoteCalcCseUtil() {}
+
+    /** Expands the local refs of a calc's projection into self-contained 
expression trees. */
+    static List<RexNode> expandProjects(FlinkLogicalCalc calc) {
+        RexProgram program = calc.getProgram();
+        return program.getProjectList().stream()
+                .map(program::expandLocalRef)
+                .collect(Collectors.toList());
+    }
+
+    /**
+     * Returns true if the node is itself a remote call whose result may be 
reused. A
+     * non-deterministic call must be evaluated once per occurrence, so it 
never qualifies.
+     */
+    static boolean isReusableRemoteCall(RexNode node, RemoteCallFinder 
callFinder) {
+        return callFinder.isRemoteCall(node) && isReusable(node);
+    }
+
+    /**
+     * Returns true if the node contains a remote call and its result may be 
reused. Unlike {@link
+     * #isReusableRemoteCall}, the remote call may be nested inside the 
expression tree.
+     */
+    static boolean containsReusableRemoteCall(RexNode node, RemoteCallFinder 
callFinder) {
+        return callFinder.containsRemoteCall(node) && isReusable(node);
+    }
+
+    /**
+     * Maps each field a calc forwards unchanged to the output position at 
which it is exposed.
+     *
+     * <p>Only plain {@link RexInputRef} projections are forwarding; the first 
occurrence wins when a
+     * field is projected more than once.
+     */
+    static Map<Integer, Integer> forwardedFieldPositions(List<RexNode> 
projects) {
+        Map<Integer, Integer> positions = new HashMap<>();
+        for (int i = 0; i < projects.size(); i++) {
+            RexNode project = projects.get(i);
+            if (project instanceof RexInputRef) {
+                positions.putIfAbsent(((RexInputRef) project).getIndex(), i);
+            }
+        }
+        return positions;
+    }
+
+    /**
+     * Rewrites an expression written against a calc's input so that it is 
written against that
+     * calc's output instead, or returns {@code null} if it reads a field the 
calc does not forward.
+     *
+     * <p>Splitting a Calc changes what an input ref means: the lower Calc 
addresses the original
+     * input while the upper one addresses the lower Calc's output, and the 
lower Calc forwards only
+     * the fields still needed above. Two expressions that print identically 
may therefore read
+     * different columns, so they must be brought into a common frame of 
reference before being
+     * compared for reuse. A {@code null} result means no valid comparison 
exists, because the upper
+     * Calc cannot express the expression at all.
+     */
+    static RexNode translateToOutputFrame(
+            RexNode node, Map<Integer, Integer> forwardedFieldPositions) {
+        try {
+            return node.accept(
+                    new RexShuttle() {
+                        @Override
+                        public RexNode visitInputRef(RexInputRef inputRef) {
+                            Integer position = 
forwardedFieldPositions.get(inputRef.getIndex());
+                            if (position == null) {
+                                throw new NotForwardedException();
+                            }
+                            return new RexInputRef(position, 
inputRef.getType());
+                        }
+                    });
+        } catch (NotForwardedException e) {
+            return null;
+        }
+    }
+
+    private static boolean isReusable(RexNode node) {
+        return ShortcutUtils.isDeterministicThroughProgram(node, null);
+    }
+
+    /** Signals that an expression reads a field which the calc does not 
forward. */
+    private static class NotForwardedException extends RuntimeException {
+        private NotForwardedException() {
+            super(null, null, false, false);
+        }
+    }
+}
diff --git 
a/flink-table/flink-table-planner/src/main/scala/org/apache/flink/table/planner/plan/rules/logical/RemoteCalcProjectionCseRule.java
 
b/flink-table/flink-table-planner/src/main/scala/org/apache/flink/table/planner/plan/rules/logical/RemoteCalcProjectionCseRule.java
new file mode 100644
index 00000000000..547ced1819c
--- /dev/null
+++ 
b/flink-table/flink-table-planner/src/main/scala/org/apache/flink/table/planner/plan/rules/logical/RemoteCalcProjectionCseRule.java
@@ -0,0 +1,237 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to you under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.flink.table.planner.plan.rules.logical;
+
+import org.apache.flink.table.planner.plan.nodes.logical.FlinkLogicalCalc;
+
+import org.apache.calcite.plan.RelOptRuleCall;
+import org.apache.calcite.plan.RelRule;
+import org.apache.calcite.rel.RelNode;
+import org.apache.calcite.rel.type.RelDataType;
+import org.apache.calcite.rex.RexBuilder;
+import org.apache.calcite.rex.RexInputRef;
+import org.apache.calcite.rex.RexNode;
+import org.apache.calcite.rex.RexProgram;
+import org.apache.calcite.sql.validate.SqlValidatorUtil;
+import org.immutables.value.Value;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+/**
+ * Rule that deduplicates identical remote (e.g. Python) calls repeated in the 
projection of a {@link
+ * FlinkLogicalCalc}.
+ *
+ * <p>The underlying {@link RexProgram} already shares structurally identical 
expressions through
+ * {@link org.apache.calcite.rex.RexLocalRef}s. However, the remote calc 
translation expands those
+ * local refs into independent expression trees, which re-introduces the 
duplication and makes the
+ * same UDF be shipped to the remote worker once per occurrence. This rule 
makes the sharing explicit
+ * in the plan by splitting the calc into two:
+ *
+ * <pre>
+ * Calc(projection=[pyFunc(a, b), pyFunc(a, b)])
+ * </pre>
+ *
+ * <p>becomes
+ *
+ * <pre>
+ * TopCalc(projection=[$0, $0])
+ *   BottomCalc(projection=[pyFunc(a, b) AS f0])
+ * </pre>
+ *
+ * <p>The bottom calc keeps one occurrence of every distinct remote call, and 
the top calc is a pure
+ * {@link RexInputRef} projection restoring the original output schema.
+ *
+ * <p>Only deterministic calls are deduplicated; a non-deterministic call must 
be evaluated
+ * independently for each occurrence.
+ */
[email protected]
+public class RemoteCalcProjectionCseRule extends 
RelRule<RemoteCalcProjectionCseRule.Config> {
+
+    protected RemoteCalcProjectionCseRule(Config config) {
+        super(config);
+    }
+
+    @Override
+    public boolean matches(RelOptRuleCall call) {
+        FlinkLogicalCalc calc = call.rel(0);
+        RexProgram program = calc.getProgram();
+
+        // Conditions are pushed away by RemoteCalcPushConditionRule 
beforehand, and duplicates
+        // shared with a condition are handled by 
RemoteCalcConditionProjectionCseRule.
+        if (program.getCondition() != null) {
+            return false;
+        }
+
+        List<RexNode> projects = RemoteCalcCseUtil.expandProjects(calc);
+        RemoteCallFinder callFinder = config.remoteCallFinder();
+
+        // Only a projection already normalized by 
RemoteCalcRewriteProjectionRule is handled, i.e.
+        // it consists of plain input refs and top-level remote calls only.
+        if (projects.stream().noneMatch(callFinder::isRemoteCall)) {
+            return false;
+        }
+        if (!projects.stream()
+                .allMatch(p -> p instanceof RexInputRef || 
callFinder.isRemoteCall(p))) {
+            return false;
+        }
+
+        return findDuplicates(projects, callFinder) != null;
+    }
+
+    @Override
+    public void onMatch(RelOptRuleCall call) {
+        FlinkLogicalCalc calc = call.rel(0);
+        RelNode input = calc.getInput();
+        RexBuilder rexBuilder = call.builder().getRexBuilder();
+        RemoteCallFinder callFinder = config.remoteCallFinder();
+
+        List<RexNode> projects = RemoteCalcCseUtil.expandProjects(calc);
+        int[] originalToUnique = findDuplicates(projects, callFinder);
+        if (originalToUnique == null) {
+            return;
+        }
+
+        // The bottom calc keeps the first occurrence of every distinct 
expression.
+        List<RexNode> bottomProjects = new ArrayList<>();
+        Map<Integer, Integer> uniqueIndexToBottomIndex = new LinkedHashMap<>();
+        for (int i = 0; i < projects.size(); i++) {
+            if (originalToUnique[i] == i) {
+                uniqueIndexToBottomIndex.put(i, bottomProjects.size());
+                bottomProjects.add(projects.get(i));
+            }
+        }
+
+        List<String> bottomFieldNames =
+                SqlValidatorUtil.uniquify(
+                        java.util.stream.IntStream.range(0, 
bottomProjects.size())
+                                .mapToObj(i -> "f" + i)
+                                .collect(Collectors.toList()),
+                        
rexBuilder.getTypeFactory().getTypeSystem().isSchemaCaseSensitive());
+
+        FlinkLogicalCalc bottomCalc =
+                new FlinkLogicalCalc(
+                        calc.getCluster(),
+                        calc.getTraitSet(),
+                        input,
+                        RexProgram.create(
+                                input.getRowType(),
+                                bottomProjects,
+                                null,
+                                bottomFieldNames,
+                                rexBuilder));
+
+        // The top calc only forwards the shared results back to their 
original positions.
+        RelDataType bottomRowType = bottomCalc.getRowType();
+        List<RexNode> topProjects = new ArrayList<>();
+        for (int i = 0; i < projects.size(); i++) {
+            int bottomIndex = 
uniqueIndexToBottomIndex.get(originalToUnique[i]);
+            topProjects.add(
+                    new RexInputRef(
+                            bottomIndex, 
bottomRowType.getFieldList().get(bottomIndex).getType()));
+        }
+
+        call.transformTo(
+                calc.copy(
+                        calc.getTraitSet(),
+                        bottomCalc,
+                        RexProgram.create(
+                                bottomRowType,
+                                topProjects,
+                                null,
+                                calc.getRowType(),
+                                rexBuilder)));
+    }
+
+    /**
+     * Maps every projection index to the index of the first projection 
computing the same value.
+     *
+     * @return the mapping, or {@code null} when there is nothing to 
deduplicate
+     */
+    private int[] findDuplicates(List<RexNode> projects, RemoteCallFinder 
callFinder) {
+        Map<RexNode, Integer> firstOccurrence = new LinkedHashMap<>();
+        int[] originalToUnique = new int[projects.size()];
+        boolean hasDuplicate = false;
+
+        for (int i = 0; i < projects.size(); i++) {
+            RexNode project = projects.get(i);
+            // Forwarded fields are cheap and already shared, so only remote 
calls are considered.
+            boolean canReuse = RemoteCalcCseUtil.isReusableRemoteCall(project, 
callFinder);
+            Integer existing = canReuse ? firstOccurrence.get(project) : null;
+            if (existing != null) {
+                originalToUnique[i] = existing;
+                hasDuplicate = true;
+            } else {
+                if (canReuse) {
+                    firstOccurrence.put(project, i);
+                }
+                originalToUnique[i] = i;
+            }
+        }
+
+        return hasDuplicate ? originalToUnique : null;
+    }
+
+    @Override
+    public boolean equals(Object obj) {
+        if (this == obj) {
+            return true;
+        }
+        if (!(obj instanceof RemoteCalcProjectionCseRule)) {
+            return false;
+        }
+        RemoteCalcProjectionCseRule other = (RemoteCalcProjectionCseRule) obj;
+        return super.equals(other)
+                && config.remoteCallFinder()
+                        .getClass()
+                        .equals(other.config.remoteCallFinder().getClass());
+    }
+
+    @Override
+    public int hashCode() {
+        return super.hashCode() * 31 + 
config.remoteCallFinder().getClass().hashCode();
+    }
+
+    // 
-------------------------------------------------------------------------
+
+    /** Rule configuration. */
+    @Value.Immutable(singleton = false)
+    public interface Config extends RelRule.Config {
+        Config DEFAULT =
+                ImmutableRemoteCalcProjectionCseRule.Config.builder()
+                        .operandSupplier(b0 -> 
b0.operand(FlinkLogicalCalc.class).anyInputs())
+                        .description("RemoteCalcProjectionCseRule")
+                        .build();
+
+        @Value.Default
+        default RemoteCallFinder remoteCallFinder() {
+            return new PythonRemoteCallFinder();
+        }
+
+        /** Sets {@link #remoteCallFinder()}. */
+        Config withRemoteCallFinder(RemoteCallFinder callFinder);
+
+        @Override
+        default RemoteCalcProjectionCseRule toRule() {
+            return new RemoteCalcProjectionCseRule(this);
+        }
+    }
+}
diff --git 
a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/stream/sql/PythonCalcCseTest.java
 
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/stream/sql/PythonCalcCseTest.java
new file mode 100644
index 00000000000..15b7a92ea81
--- /dev/null
+++ 
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/stream/sql/PythonCalcCseTest.java
@@ -0,0 +1,126 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.flink.table.planner.plan.stream.sql;
+
+import org.apache.flink.table.api.DataTypes;
+import org.apache.flink.table.api.Schema;
+import 
org.apache.flink.table.planner.runtime.utils.JavaUserDefinedScalarFunctions.NonDeterministicPythonScalarFunction;
+import 
org.apache.flink.table.planner.runtime.utils.JavaUserDefinedScalarFunctions.PythonScalarFunction;
+import org.apache.flink.table.planner.utils.JavaStreamTableTestUtil;
+import org.apache.flink.table.planner.utils.TableTestBase;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests for Python UDF common sub-expression elimination in the planner, 
covering {@link
+ * 
org.apache.flink.table.planner.plan.rules.logical.RemoteCalcProjectionCseRule} 
(duplicates within
+ * a projection) and {@link
+ * 
org.apache.flink.table.planner.plan.rules.logical.RemoteCalcConditionProjectionCseRule}
+ * (duplicates shared between a condition and a projection).
+ */
+class PythonCalcCseTest extends TableTestBase {
+
+    private final JavaStreamTableTestUtil util = javaStreamTestUtil();
+
+    @BeforeEach
+    void setup() {
+        util.addTableSource(
+                "MyTable",
+                Schema.newBuilder()
+                        .column("a", DataTypes.INT())
+                        .column("b", DataTypes.INT())
+                        .column("c", DataTypes.INT())
+                        .build());
+        util.tableEnv()
+                .createTemporarySystemFunction("pyFunc1", new 
PythonScalarFunction("pyFunc1"));
+        util.tableEnv()
+                .createTemporarySystemFunction("pyFunc2", new 
PythonScalarFunction("pyFunc2"));
+        util.tableEnv()
+                .createTemporarySystemFunction(
+                        "pyFuncNonDet", new 
NonDeterministicPythonScalarFunction("pyFuncNonDet"));
+    }
+
+    // 
-------------------------------------------------------------------------
+    //  Duplicates within a projection
+    // 
-------------------------------------------------------------------------
+
+    @Test
+    void testDuplicatedCallsInProjection() {
+        util.verifyExecPlan("SELECT pyFunc1(a, b), pyFunc1(a, b), pyFunc1(a, 
b) FROM MyTable");
+    }
+
+    @Test
+    void testDuplicatedCallsWithForwardedField() {
+        util.verifyExecPlan("SELECT a, pyFunc1(a, b), pyFunc1(a, b) FROM 
MyTable");
+    }
+
+    @Test
+    void testDistinctCallsAreNotDeduplicated() {
+        util.verifyExecPlan("SELECT pyFunc1(a, b), pyFunc1(b, a), pyFunc2(a, 
b) FROM MyTable");
+    }
+
+    @Test
+    void testNonDeterministicCallsAreNotDeduplicated() {
+        util.verifyExecPlan("SELECT pyFuncNonDet(a, b), pyFuncNonDet(a, b) 
FROM MyTable");
+    }
+
+    // 
-------------------------------------------------------------------------
+    //  Duplicates shared between a condition and a projection
+    // 
-------------------------------------------------------------------------
+
+    @Test
+    void testSameUdfInConditionAndProjection() {
+        util.verifyExecPlan(
+                "SELECT pyFunc1(a, b) + 1, pyFunc1(a, b) + 2 FROM MyTable 
WHERE pyFunc1(a, b) > 0");
+    }
+
+    @Test
+    void testDifferentUdfInConditionAndProjection() {
+        util.verifyExecPlan("SELECT pyFunc1(a, b) FROM MyTable WHERE 
pyFunc2(a, c) > 0");
+    }
+
+    @Test
+    void testNestedUdfInProjectionWithSameInCondition() {
+        util.verifyExecPlan(
+                "SELECT pyFunc2(pyFunc1(a, b), c), pyFunc1(a, b) FROM MyTable 
WHERE pyFunc1(a, b) > 0");
+    }
+
+    @Test
+    void testDuplicatedCallsInProjectionWithCondition() {
+        util.verifyExecPlan(
+                "SELECT pyFunc1(a, b), pyFunc1(a, b) FROM MyTable WHERE 
pyFunc1(a, b) > 0");
+    }
+
+    /**
+     * The same function with different arguments must not be shared. After 
the condition split both
+     * calls can be printed as {@code pyFunc1($0, $1)} even though they read 
different columns, so
+     * comparing them without accounting for the frame of reference would 
silently return the
+     * condition's result.
+     */
+    @Test
+    void testSameUdfWithDifferentArgsInConditionAndProjection() {
+        util.verifyExecPlan("SELECT pyFunc1(b, c) FROM MyTable WHERE 
pyFunc1(a, b) > 0");
+    }
+
+    @Test
+    void testSameUdfWithSwappedArgsInConditionAndProjection() {
+        util.verifyExecPlan("SELECT pyFunc1(b, a) FROM MyTable WHERE 
pyFunc1(a, b) > 0");
+    }
+}
diff --git 
a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/runtime/utils/JavaUserDefinedScalarFunctions.java
 
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/runtime/utils/JavaUserDefinedScalarFunctions.java
index dfd11550fb3..0b93bd42882 100644
--- 
a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/runtime/utils/JavaUserDefinedScalarFunctions.java
+++ 
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/runtime/utils/JavaUserDefinedScalarFunctions.java
@@ -175,6 +175,40 @@ public class JavaUserDefinedScalarFunctions {
         }
     }
 
+    /** Test for non-deterministic Python Scalar Function. */
+    public static class NonDeterministicPythonScalarFunction extends 
ScalarFunction
+            implements PythonFunction {
+        private final String name;
+
+        public NonDeterministicPythonScalarFunction(String name) {
+            this.name = name;
+        }
+
+        public int eval(Integer i, Integer j) {
+            return i + j;
+        }
+
+        @Override
+        public boolean isDeterministic() {
+            return false;
+        }
+
+        @Override
+        public String toString() {
+            return name;
+        }
+
+        @Override
+        public byte[] getSerializedPythonFunction() {
+            return new byte[0];
+        }
+
+        @Override
+        public PythonEnv getPythonEnv() {
+            return new PythonEnv(PythonEnv.ExecType.PROCESS);
+        }
+    }
+
     /** Test for Python Scalar Function. */
     public static class BooleanPythonScalarFunction extends ScalarFunction
             implements PythonFunction {
diff --git 
a/flink-table/flink-table-planner/src/test/resources/org/apache/flink/table/planner/plan/stream/sql/PythonCalcCseTest.xml
 
b/flink-table/flink-table-planner/src/test/resources/org/apache/flink/table/planner/plan/stream/sql/PythonCalcCseTest.xml
new file mode 100644
index 00000000000..3f3f708fae0
--- /dev/null
+++ 
b/flink-table/flink-table-planner/src/test/resources/org/apache/flink/table/planner/plan/stream/sql/PythonCalcCseTest.xml
@@ -0,0 +1,208 @@
+<?xml version="1.0" ?>
+<!--
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to you under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<Root>
+  <TestCase name="testDifferentUdfInConditionAndProjection">
+    <Resource name="sql">
+      <![CDATA[SELECT pyFunc1(a, b) FROM MyTable WHERE pyFunc2(a, c) > 0]]>
+    </Resource>
+    <Resource name="ast">
+      <![CDATA[
+LogicalProject(EXPR$0=[pyFunc1($0, $1)])
++- LogicalFilter(condition=[>(pyFunc2($0, $2), 0)])
+   +- LogicalTableScan(table=[[default_catalog, default_database, MyTable]])
+]]>
+    </Resource>
+    <Resource name="optimized exec plan">
+      <![CDATA[
+PythonCalc(select=[pyFunc1(a, b) AS EXPR$0])
++- Calc(select=[a, b], where=[(f0 > 0)])
+   +- PythonCalc(select=[a, b, pyFunc2(a, c) AS f0])
+      +- TableSourceScan(table=[[default_catalog, default_database, MyTable]], 
fields=[a, b, c])
+]]>
+    </Resource>
+  </TestCase>
+  <TestCase name="testDistinctCallsAreNotDeduplicated">
+    <Resource name="sql">
+      <![CDATA[SELECT pyFunc1(a, b), pyFunc1(b, a), pyFunc2(a, b) FROM 
MyTable]]>
+    </Resource>
+    <Resource name="ast">
+      <![CDATA[
+LogicalProject(EXPR$0=[pyFunc1($0, $1)], EXPR$1=[pyFunc1($1, $0)], 
EXPR$2=[pyFunc2($0, $1)])
++- LogicalTableScan(table=[[default_catalog, default_database, MyTable]])
+]]>
+    </Resource>
+    <Resource name="optimized exec plan">
+      <![CDATA[
+PythonCalc(select=[pyFunc1(a, b) AS EXPR$0, pyFunc1(b, a) AS EXPR$1, 
pyFunc2(a, b) AS EXPR$2])
++- TableSourceScan(table=[[default_catalog, default_database, MyTable]], 
fields=[a, b, c])
+]]>
+    </Resource>
+  </TestCase>
+  <TestCase name="testDuplicatedCallsInProjection">
+    <Resource name="sql">
+      <![CDATA[SELECT pyFunc1(a, b), pyFunc1(a, b), pyFunc1(a, b) FROM 
MyTable]]>
+    </Resource>
+    <Resource name="ast">
+      <![CDATA[
+LogicalProject(EXPR$0=[pyFunc1($0, $1)], EXPR$1=[pyFunc1($0, $1)], 
EXPR$2=[pyFunc1($0, $1)])
++- LogicalTableScan(table=[[default_catalog, default_database, MyTable]])
+]]>
+    </Resource>
+    <Resource name="optimized exec plan">
+      <![CDATA[
+Calc(select=[f0 AS EXPR$0, f0 AS EXPR$1, f0 AS EXPR$2])
++- PythonCalc(select=[pyFunc1(a, b) AS f0])
+   +- TableSourceScan(table=[[default_catalog, default_database, MyTable]], 
fields=[a, b, c])
+]]>
+    </Resource>
+  </TestCase>
+  <TestCase name="testDuplicatedCallsInProjectionWithCondition">
+    <Resource name="sql">
+      <![CDATA[SELECT pyFunc1(a, b), pyFunc1(a, b) FROM MyTable WHERE 
pyFunc1(a, b) > 0]]>
+    </Resource>
+    <Resource name="ast">
+      <![CDATA[
+LogicalProject(EXPR$0=[pyFunc1($0, $1)], EXPR$1=[pyFunc1($0, $1)])
++- LogicalFilter(condition=[>(pyFunc1($0, $1), 0)])
+   +- LogicalTableScan(table=[[default_catalog, default_database, MyTable]])
+]]>
+    </Resource>
+    <Resource name="optimized exec plan">
+      <![CDATA[
+Calc(select=[f0 AS EXPR$0, f0 AS EXPR$1], where=[(f0 > 0)])
++- PythonCalc(select=[pyFunc1(a, b) AS f0])
+   +- TableSourceScan(table=[[default_catalog, default_database, MyTable]], 
fields=[a, b, c])
+]]>
+    </Resource>
+  </TestCase>
+  <TestCase name="testDuplicatedCallsWithForwardedField">
+    <Resource name="sql">
+      <![CDATA[SELECT a, pyFunc1(a, b), pyFunc1(a, b) FROM MyTable]]>
+    </Resource>
+    <Resource name="ast">
+      <![CDATA[
+LogicalProject(a=[$0], EXPR$1=[pyFunc1($0, $1)], EXPR$2=[pyFunc1($0, $1)])
++- LogicalTableScan(table=[[default_catalog, default_database, MyTable]])
+]]>
+    </Resource>
+    <Resource name="optimized exec plan">
+      <![CDATA[
+Calc(select=[f0 AS a, f1 AS EXPR$1, f1 AS EXPR$2])
++- PythonCalc(select=[a AS f0, pyFunc1(a, b) AS f1])
+   +- TableSourceScan(table=[[default_catalog, default_database, MyTable]], 
fields=[a, b, c])
+]]>
+    </Resource>
+  </TestCase>
+  <TestCase name="testNestedUdfInProjectionWithSameInCondition">
+    <Resource name="sql">
+      <![CDATA[SELECT pyFunc2(pyFunc1(a, b), c), pyFunc1(a, b) FROM MyTable 
WHERE pyFunc1(a, b) > 0]]>
+    </Resource>
+    <Resource name="ast">
+      <![CDATA[
+LogicalProject(EXPR$0=[pyFunc2(pyFunc1($0, $1), $2)], EXPR$1=[pyFunc1($0, $1)])
++- LogicalFilter(condition=[>(pyFunc1($0, $1), 0)])
+   +- LogicalTableScan(table=[[default_catalog, default_database, MyTable]])
+]]>
+    </Resource>
+    <Resource name="optimized exec plan">
+      <![CDATA[
+Calc(select=[f00 AS EXPR$0, f0 AS EXPR$1])
++- PythonCalc(select=[f0, pyFunc2(f0, c) AS f00])
+   +- Calc(select=[f0, c], where=[(f0 > 0)])
+      +- PythonCalc(select=[a, b, c, pyFunc1(a, b) AS f0])
+         +- TableSourceScan(table=[[default_catalog, default_database, 
MyTable]], fields=[a, b, c])
+]]>
+    </Resource>
+  </TestCase>
+  <TestCase name="testNonDeterministicCallsAreNotDeduplicated">
+    <Resource name="sql">
+      <![CDATA[SELECT pyFuncNonDet(a, b), pyFuncNonDet(a, b) FROM MyTable]]>
+    </Resource>
+    <Resource name="ast">
+      <![CDATA[
+LogicalProject(EXPR$0=[pyFuncNonDet($0, $1)], EXPR$1=[pyFuncNonDet($0, $1)])
++- LogicalTableScan(table=[[default_catalog, default_database, MyTable]])
+]]>
+    </Resource>
+    <Resource name="optimized exec plan">
+      <![CDATA[
+PythonCalc(select=[pyFuncNonDet(a, b) AS EXPR$0, pyFuncNonDet(a, b) AS EXPR$1])
++- TableSourceScan(table=[[default_catalog, default_database, MyTable]], 
fields=[a, b, c])
+]]>
+    </Resource>
+  </TestCase>
+  <TestCase name="testSameUdfInConditionAndProjection">
+    <Resource name="sql">
+      <![CDATA[SELECT pyFunc1(a, b) + 1, pyFunc1(a, b) + 2 FROM MyTable WHERE 
pyFunc1(a, b) > 0]]>
+    </Resource>
+    <Resource name="ast">
+      <![CDATA[
+LogicalProject(EXPR$0=[+(pyFunc1($0, $1), 1)], EXPR$1=[+(pyFunc1($0, $1), 2)])
++- LogicalFilter(condition=[>(pyFunc1($0, $1), 0)])
+   +- LogicalTableScan(table=[[default_catalog, default_database, MyTable]])
+]]>
+    </Resource>
+    <Resource name="optimized exec plan">
+      <![CDATA[
+Calc(select=[(f0 + 1) AS EXPR$0, (f0 + 2) AS EXPR$1], where=[(f0 > 0)])
++- PythonCalc(select=[a, b, pyFunc1(a, b) AS f0])
+   +- TableSourceScan(table=[[default_catalog, default_database, MyTable]], 
fields=[a, b, c])
+]]>
+    </Resource>
+  </TestCase>
+  <TestCase name="testSameUdfWithDifferentArgsInConditionAndProjection">
+    <Resource name="sql">
+      <![CDATA[SELECT pyFunc1(b, c) FROM MyTable WHERE pyFunc1(a, b) > 0]]>
+    </Resource>
+    <Resource name="ast">
+      <![CDATA[
+LogicalProject(EXPR$0=[pyFunc1($1, $2)])
++- LogicalFilter(condition=[>(pyFunc1($0, $1), 0)])
+   +- LogicalTableScan(table=[[default_catalog, default_database, MyTable]])
+]]>
+    </Resource>
+    <Resource name="optimized exec plan">
+      <![CDATA[
+PythonCalc(select=[pyFunc1(b, c) AS EXPR$0])
++- Calc(select=[b, c], where=[(f0 > 0)])
+   +- PythonCalc(select=[b, c, pyFunc1(a, b) AS f0])
+      +- TableSourceScan(table=[[default_catalog, default_database, MyTable]], 
fields=[a, b, c])
+]]>
+    </Resource>
+  </TestCase>
+  <TestCase name="testSameUdfWithSwappedArgsInConditionAndProjection">
+    <Resource name="sql">
+      <![CDATA[SELECT pyFunc1(b, a) FROM MyTable WHERE pyFunc1(a, b) > 0]]>
+    </Resource>
+    <Resource name="ast">
+      <![CDATA[
+LogicalProject(EXPR$0=[pyFunc1($1, $0)])
++- LogicalFilter(condition=[>(pyFunc1($0, $1), 0)])
+   +- LogicalTableScan(table=[[default_catalog, default_database, MyTable]])
+]]>
+    </Resource>
+    <Resource name="optimized exec plan">
+      <![CDATA[
+PythonCalc(select=[pyFunc1(b, a) AS EXPR$0])
++- Calc(select=[b, a], where=[(f0 > 0)])
+   +- PythonCalc(select=[b, a, pyFunc1(a, b) AS f0])
+      +- TableSourceScan(table=[[default_catalog, default_database, MyTable]], 
fields=[a, b, c])
+]]>
+    </Resource>
+  </TestCase>
+</Root>

Reply via email to