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

fhueske 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 f5aaf91f2db [FLINK-40158][table-planner] Support LATERAL SNAPSHOT join 
in batch mode (#28763)
f5aaf91f2db is described below

commit f5aaf91f2db1f3eadffc325ad278680dc7aeac56
Author: Fabian Hueske <[email protected]>
AuthorDate: Mon Jul 20 16:08:32 2026 +0200

    [FLINK-40158][table-planner] Support LATERAL SNAPSHOT join in batch mode 
(#28763)
    
    In batch, all input is bounded and append-only, so the processing-time
    LATERAL SNAPSHOT join degenerates to a regular join of the probe side
    against the (final) build side; the SNAPSHOT-specific arguments are
    dropped. BatchPhysicalLateralSnapshotJoinRule converts the logical
    snapshot join into a shuffle hash join that builds the (smaller) SNAPSHOT
    side, mirroring StreamPhysicalLateralSnapshotJoinRule.
    
    Generated-By: Claude Opus 4.8 (1M context)
---
 .../LogicalJoinToLateralSnapshotJoinRule.java      |  38 ++--
 .../BatchPhysicalLateralSnapshotJoinRule.java      | 108 ++++++++++++
 .../planner/plan/rules/FlinkBatchRuleSets.scala    |  12 +-
 .../batch/sql/join/LateralSnapshotJoinTest.java    | 189 ++++++++++++++++++++
 .../LateralSnapshotJoinBatchSemanticTests.java     |  42 +++++
 .../batch/LateralSnapshotJoinTestPrograms.java     | 129 ++++++++++++++
 .../batch/sql/join/LateralSnapshotJoinTest.xml     | 191 +++++++++++++++++++++
 7 files changed, 691 insertions(+), 18 deletions(-)

diff --git 
a/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/plan/rules/logical/LogicalJoinToLateralSnapshotJoinRule.java
 
b/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/plan/rules/logical/LogicalJoinToLateralSnapshotJoinRule.java
index f76154803a4..c4b1012df97 100644
--- 
a/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/plan/rules/logical/LogicalJoinToLateralSnapshotJoinRule.java
+++ 
b/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/plan/rules/logical/LogicalJoinToLateralSnapshotJoinRule.java
@@ -28,6 +28,7 @@ import 
org.apache.flink.table.planner.plan.nodes.logical.FlinkLogicalLateralSnap
 import 
org.apache.flink.table.planner.plan.nodes.logical.FlinkLogicalTableFunctionScan;
 import org.apache.flink.table.planner.plan.utils.FlinkRexUtil;
 import org.apache.flink.table.planner.plan.utils.LateralSnapshotJoinUtil;
+import org.apache.flink.table.planner.utils.ShortcutUtils;
 import 
org.apache.flink.table.types.inference.strategies.LateralSnapshotTypeStrategy;
 
 import org.apache.calcite.plan.RelOptRuleCall;
@@ -123,22 +124,27 @@ public class LogicalJoinToLateralSnapshotJoinRule
                     "Could not resolve the TABLE input of the SNAPSHOT scan on 
the build side of "
                             + "a LATERAL SNAPSHOT join. This is a bug, please 
file an issue.");
         }
-        // The build-side input must declare exactly one watermark, otherwise 
the operator cannot
-        // determine when the LOAD phase is complete.
-        final long rowtimeCount =
-                rawTableInput.getRowType().getFieldList().stream()
-                        .filter(f -> 
FlinkTypeFactory.isRowtimeIndicatorType(f.getType()))
-                        .count();
-        if (rowtimeCount == 0) {
-            throw new ValidationException(
-                    "LATERAL SNAPSHOT requires a watermark on the build-side 
input.");
-        }
-        if (rowtimeCount > 1) {
-            throw new ValidationException(
-                    String.format(
-                            "The build-side input of a LATERAL SNAPSHOT join 
must not have more than one "
-                                    + "row-time attribute, but found %d.",
-                            rowtimeCount));
+        // The build-side row-time attribute drives the streaming operator's 
LOAD phase. In batch
+        // all input is bounded and the join degrades to a regular join (see
+        // BatchPhysicalLateralSnapshotJoinRule), so no watermark is required.
+        if (!ShortcutUtils.unwrapContext(join).isBatchMode()) {
+            // The build-side input must declare exactly one watermark, 
otherwise the operator
+            // cannot determine when the LOAD phase is complete.
+            final long rowtimeCount =
+                    rawTableInput.getRowType().getFieldList().stream()
+                            .filter(f -> 
FlinkTypeFactory.isRowtimeIndicatorType(f.getType()))
+                            .count();
+            if (rowtimeCount == 0) {
+                throw new ValidationException(
+                        "LATERAL SNAPSHOT requires a watermark on the 
build-side input.");
+            }
+            if (rowtimeCount > 1) {
+                throw new ValidationException(
+                        String.format(
+                                "The build-side input of a LATERAL SNAPSHOT 
join must not have more than one "
+                                        + "row-time attribute, but found %d.",
+                                rowtimeCount));
+            }
         }
 
         // Replace the SNAPSHOT TableFunctionScan with its input, preserving 
any FlinkLogicalCalc
diff --git 
a/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/plan/rules/physical/batch/BatchPhysicalLateralSnapshotJoinRule.java
 
b/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/plan/rules/physical/batch/BatchPhysicalLateralSnapshotJoinRule.java
new file mode 100644
index 00000000000..9338ffb8feb
--- /dev/null
+++ 
b/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/plan/rules/physical/batch/BatchPhysicalLateralSnapshotJoinRule.java
@@ -0,0 +1,108 @@
+/*
+ * 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.physical.batch;
+
+import org.apache.flink.table.planner.plan.nodes.FlinkConventions;
+import 
org.apache.flink.table.planner.plan.nodes.logical.FlinkLogicalLateralSnapshotJoin;
+import 
org.apache.flink.table.planner.plan.nodes.physical.batch.BatchPhysicalHashJoin;
+import 
org.apache.flink.table.planner.plan.rules.logical.LogicalJoinToLateralSnapshotJoinRule;
+import 
org.apache.flink.table.planner.plan.rules.physical.stream.StreamPhysicalLateralSnapshotJoinRule;
+import org.apache.flink.table.planner.plan.trait.FlinkRelDistribution;
+
+import org.apache.calcite.plan.RelOptRule;
+import org.apache.calcite.plan.RelTraitSet;
+import org.apache.calcite.rel.RelNode;
+import org.apache.calcite.rel.convert.ConverterRule;
+import org.apache.calcite.rel.core.JoinInfo;
+import org.apache.calcite.util.ImmutableIntList;
+
+/**
+ * Converts a {@link FlinkLogicalLateralSnapshotJoin} (created by {@link
+ * LogicalJoinToLateralSnapshotJoinRule}) into a regular batch {@link 
BatchPhysicalHashJoin} for
+ * batch execution.
+ *
+ * <p>In batch all input is bounded and append-only (batch rejects or in the 
future materializes
+ * non-insert-only sources up front), so the processing-time {@code LATERAL 
SNAPSHOT} join
+ * degenerates to a regular join of the probe side against the (final) build 
side; the
+ * SNAPSHOT-specific arguments are irrelevant and dropped. This rule mirrors 
the streaming {@link
+ * StreamPhysicalLateralSnapshotJoinRule}, which converts the same logical 
node to a dedicated
+ * stream operator.
+ *
+ * <p>The SNAPSHOT input is the build (right) side of the LATERAL join and is 
the dimension-like
+ * side, expected to be smaller than the probe (left) side. The join is 
therefore emitted as a
+ * shuffle hash join that builds on the right input.
+ */
+public class BatchPhysicalLateralSnapshotJoinRule extends ConverterRule {
+
+    public static final BatchPhysicalLateralSnapshotJoinRule INSTANCE =
+            new BatchPhysicalLateralSnapshotJoinRule(
+                    Config.INSTANCE.withConversion(
+                            FlinkLogicalLateralSnapshotJoin.class,
+                            FlinkConventions.LOGICAL(),
+                            FlinkConventions.BATCH_PHYSICAL(),
+                            "BatchPhysicalLateralSnapshotJoinRule"));
+
+    private BatchPhysicalLateralSnapshotJoinRule(Config config) {
+        super(config);
+    }
+
+    @Override
+    public RelNode convert(RelNode rel) {
+        final FlinkLogicalLateralSnapshotJoin join = 
(FlinkLogicalLateralSnapshotJoin) rel;
+        final RelTraitSet providedTraitSet =
+                rel.getTraitSet().replace(FlinkConventions.BATCH_PHYSICAL());
+
+        // Both inputs are hash-partitioned on their join keys (shuffle hash 
join).
+        final JoinInfo joinInfo = join.analyzeCondition();
+        final RelNode newLeft = convertInput(join.getLeft(), 
joinInfo.leftKeys);
+        final RelNode newRight = convertInput(join.getRight(), 
joinInfo.rightKeys);
+
+        return new BatchPhysicalHashJoin(
+                join.getCluster(),
+                providedTraitSet,
+                newLeft,
+                newRight,
+                join.getCondition(),
+                join.getJoinType(),
+                // leftIsBuild = false: build the right (SNAPSHOT) side, the 
smaller dimension side.
+                false,
+                // isBroadcast = false: shuffle hash join.
+                false,
+                // tryDistinctBuildRow = false: only relevant for semi/anti 
joins.
+                false,
+                // withJobStrategyHint = false: not driven by a user join hint.
+                false);
+    }
+
+    /**
+     * Converts a join input to the batch-physical convention and requires it 
to be hash-partitioned
+     * on the given join {@code keys} (or a singleton distribution if there 
are none).
+     */
+    private static RelNode convertInput(RelNode input, ImmutableIntList keys) {
+        final FlinkRelDistribution distribution =
+                keys.isEmpty()
+                        ? FlinkRelDistribution.SINGLETON()
+                        : FlinkRelDistribution.hash(keys.toIntArray(), true);
+        final RelTraitSet requiredTraitSet =
+                input.getTraitSet()
+                        .replace(FlinkConventions.BATCH_PHYSICAL())
+                        .replace(distribution);
+        return RelOptRule.convert(input, requiredTraitSet);
+    }
+}
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 3fb057bc08d..26553f24ddc 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
@@ -18,7 +18,6 @@
 package org.apache.flink.table.planner.plan.rules
 
 import org.apache.flink.table.planner.plan.nodes.logical._
-import 
org.apache.flink.table.planner.plan.rules.FlinkStreamRuleSets.SIMPLIFY_COALESCE_RULES
 import org.apache.flink.table.planner.plan.rules.logical._
 import 
org.apache.flink.table.planner.plan.rules.physical.FlinkExpandConversionRule
 import org.apache.flink.table.planner.plan.rules.physical.batch._
@@ -419,7 +418,13 @@ object FlinkBatchRuleSets {
     // Avoid having async calls in multiple projections in a single calc.
     AsyncCalcSplitRule.ONE_PER_CALC_SPLIT,
     // remove output of rank number when it is not used by successor calc
-    RedundantRankNumberColumnRemoveRule.INSTANCE
+    RedundantRankNumberColumnRemoveRule.INSTANCE,
+    // Rewrites a join over a LATERAL SNAPSHOT table function call into a 
dedicated
+    // FlinkLogicalLateralSnapshotJoin
+    LogicalJoinToLateralSnapshotJoinRule.INSTANCE,
+    // Rejects SNAPSHOT scans that survived the rewrite above, i.e. SNAPSHOT 
calls used outside a
+    // LATERAL context. Must run after LogicalJoinToLateralSnapshotJoinRule.
+    ForbidSnapshotOutsideLateralRule.INSTANCE
   )
 
   /** RuleSet to do physical optimize for batch */
@@ -460,6 +465,9 @@ object FlinkBatchRuleSets {
     BatchPhysicalPythonWindowAggregateRule.INSTANCE,
     // window tvf
     BatchPhysicalWindowTableFunctionRule.INSTANCE,
+    // Converts a LATERAL SNAPSHOT join into a (shuffle hash) batch join with 
the SNAPSHOT input as
+    // build side
+    BatchPhysicalLateralSnapshotJoinRule.INSTANCE,
     // join
     BatchPhysicalHashJoinRule.INSTANCE,
     BatchPhysicalSortMergeJoinRule.INSTANCE,
diff --git 
a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/batch/sql/join/LateralSnapshotJoinTest.java
 
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/batch/sql/join/LateralSnapshotJoinTest.java
new file mode 100644
index 00000000000..50416c1560f
--- /dev/null
+++ 
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/batch/sql/join/LateralSnapshotJoinTest.java
@@ -0,0 +1,189 @@
+/*
+ * 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.batch.sql.join;
+
+import org.apache.flink.table.api.TableConfig;
+import org.apache.flink.table.api.ValidationException;
+import 
org.apache.flink.table.planner.plan.rules.physical.batch.BatchPhysicalLateralSnapshotJoinRule;
+import org.apache.flink.table.planner.utils.BatchTableTestUtil;
+import org.apache.flink.table.planner.utils.TableTestBase;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Plan tests for {@code LATERAL SNAPSHOT} joins in batch mode.
+ *
+ * <p>In batch all input is bounded and append-only, so the processing-time 
{@code LATERAL SNAPSHOT}
+ * join degenerates to a regular join of the probe side against the (final) 
build side. {@link
+ * BatchPhysicalLateralSnapshotJoinRule} performs this translation and the 
SNAPSHOT-specific
+ * arguments are dropped.
+ */
+public class LateralSnapshotJoinTest extends TableTestBase {
+
+    private BatchTableTestUtil util;
+
+    @BeforeEach
+    void setup() {
+        util = batchTestUtil(TableConfig.getDefault());
+
+        util.tableEnv()
+                .executeSql(
+                        "CREATE TABLE probe ("
+                                + "  pk STRING,"
+                                + "  pv INT,"
+                                + "  pts TIMESTAMP(3),"
+                                + "  WATERMARK FOR pts AS pts"
+                                + ") WITH ('connector' = 'values', 'bounded' = 
'true')");
+
+        util.tableEnv()
+                .executeSql(
+                        "CREATE TABLE b ("
+                                + "  bk STRING,"
+                                + "  bv INT,"
+                                + "  bts TIMESTAMP(3),"
+                                + "  WATERMARK FOR bts AS bts"
+                                + ") WITH ('connector' = 'values', 'bounded' = 
'true')");
+    }
+
+    // 
------------------------------------------------------------------------------------------
+    // Translation to a regular join
+    // 
------------------------------------------------------------------------------------------
+
+    @Test
+    void testInnerJoin() {
+        util.verifyRelPlan(
+                "SELECT * FROM probe JOIN LATERAL TABLE(SNAPSHOT("
+                        + "input => TABLE b, "
+                        + "load_completed_condition => 'user_time', "
+                        + "load_completed_time => CAST(TIMESTAMP '2026-07-01 
00:00:00' AS TIMESTAMP_LTZ(3))"
+                        + ")) AS s "
+                        + "ON probe.pk = s.bk");
+    }
+
+    @Test
+    void testLeftJoin() {
+        util.verifyRelPlan(
+                "SELECT * FROM probe LEFT JOIN LATERAL TABLE(SNAPSHOT("
+                        + "input => TABLE b, "
+                        + "load_completed_condition => 'user_time', "
+                        + "load_completed_time => CAST(TIMESTAMP '2026-07-01 
00:00:00' AS TIMESTAMP_LTZ(3))"
+                        + ")) AS s "
+                        + "ON probe.pk = s.bk");
+    }
+
+    @Test
+    void testInnerJoinWithCompositeKeys() {
+        util.verifyRelPlan(
+                "SELECT * FROM probe JOIN LATERAL TABLE(SNAPSHOT("
+                        + "input => TABLE b, "
+                        + "load_completed_condition => 'user_time', "
+                        + "load_completed_time => CAST(TIMESTAMP '2026-07-01 
00:00:00' AS TIMESTAMP_LTZ(3))"
+                        + ")) AS s "
+                        + "ON probe.pk = s.bk AND probe.pv = s.bv");
+    }
+
+    @Test
+    void testInnerJoinWithNonEquiCondition() {
+        util.verifyRelPlan(
+                "SELECT * FROM probe JOIN LATERAL TABLE(SNAPSHOT("
+                        + "input => TABLE b, "
+                        + "load_completed_condition => 'user_time', "
+                        + "load_completed_time => CAST(TIMESTAMP '2026-07-01 
00:00:00' AS TIMESTAMP_LTZ(3))"
+                        + ")) AS s "
+                        + "ON probe.pk = s.bk AND probe.pv > s.bv");
+    }
+
+    @Test
+    void testInnerJoinWithoutBuildTimeColumn() {
+        util.verifyRelPlan(
+                "SELECT probe.pk, probe.pv, s.bv FROM probe JOIN LATERAL 
TABLE(SNAPSHOT("
+                        + "input => TABLE b, "
+                        + "load_completed_condition => 'user_time', "
+                        + "load_completed_time => CAST(TIMESTAMP '2026-07-01 
00:00:00' AS TIMESTAMP_LTZ(3))"
+                        + ")) AS s "
+                        + "ON probe.pk = s.bk");
+    }
+
+    @Test
+    void testBuildSideWithProctime() {
+        // A PROCTIME() build column is a time-attribute indicator in batch; 
the rule materializes
+        // it (into a PROCTIME_MATERIALIZE call) when degrading to a regular 
join.
+        util.tableEnv()
+                .executeSql(
+                        "CREATE TABLE b_proctime ("
+                                + "  bk STRING,"
+                                + "  bv INT,"
+                                + "  pt AS PROCTIME()"
+                                + ") WITH ('connector' = 'values', 'bounded' = 
'true')");
+        util.verifyRelPlan(
+                "SELECT probe.pk, s.bk, s.bv, s.pt FROM probe JOIN LATERAL 
TABLE(SNAPSHOT("
+                        + "input => TABLE b_proctime)) AS s "
+                        + "ON probe.pk = s.bk");
+    }
+
+    @Test
+    void testBuildSideWithoutWatermark() {
+        // A watermark on the build side is required in streaming (drives the 
LOAD phase) but not in
+        // batch, where the build side is already final.
+        util.tableEnv()
+                .executeSql(
+                        "CREATE TABLE b_no_wm ("
+                                + "  bk STRING,"
+                                + "  bv INT,"
+                                + "  bts TIMESTAMP(3)"
+                                + ") WITH ('connector' = 'values', 'bounded' = 
'true')");
+        util.verifyRelPlan(
+                "SELECT * FROM probe JOIN LATERAL TABLE(SNAPSHOT(input => 
TABLE b_no_wm)) AS s "
+                        + "ON probe.pk = s.bk");
+    }
+
+    // 
------------------------------------------------------------------------------------------
+    // Validation: rejection paths
+    // 
------------------------------------------------------------------------------------------
+
+    @Test
+    void testRejectMissingEqualityPredicate() {
+        final String sql =
+                "SELECT * FROM probe JOIN LATERAL TABLE(SNAPSHOT("
+                        + "input => TABLE b, "
+                        + "load_completed_condition => 'user_time', "
+                        + "load_completed_time => CAST(TIMESTAMP '2026-07-01 
00:00:00' AS TIMESTAMP_LTZ(3))"
+                        + ")) AS s "
+                        + "ON probe.pv > s.bv";
+        assertThatThrownBy(() -> util.verifyExecPlan(sql))
+                .isInstanceOf(ValidationException.class)
+                .hasMessageContaining(
+                        "LATERAL SNAPSHOT join requires at least one equality 
predicate.");
+    }
+
+    @Test
+    void testRejectSnapshotOutsideLateral() {
+        // SNAPSHOT used outside a LATERAL context is not rewritten into a 
join;
+        // ForbidSnapshotOutsideLateralRule rejects the surviving SNAPSHOT 
scan with a clear
+        // message.
+        assertThatThrownBy(() -> util.verifyExecPlan("SELECT * FROM 
SNAPSHOT(input => TABLE b)"))
+                .isInstanceOf(ValidationException.class)
+                .hasMessageContaining(
+                        "The SNAPSHOT function can only be used as the build 
side "
+                                + "(right-hand side) of a LATERAL join");
+    }
+}
diff --git 
a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/nodes/exec/batch/LateralSnapshotJoinBatchSemanticTests.java
 
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/nodes/exec/batch/LateralSnapshotJoinBatchSemanticTests.java
new file mode 100644
index 00000000000..87725a60a26
--- /dev/null
+++ 
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/nodes/exec/batch/LateralSnapshotJoinBatchSemanticTests.java
@@ -0,0 +1,42 @@
+/*
+ * 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.nodes.exec.batch;
+
+import 
org.apache.flink.table.planner.plan.nodes.exec.testutils.BatchSemanticTestBase;
+import 
org.apache.flink.table.planner.plan.rules.physical.batch.BatchPhysicalLateralSnapshotJoinRule;
+import org.apache.flink.table.test.program.TableTestProgram;
+
+import java.util.List;
+
+/**
+ * Semantic tests for {@code LATERAL SNAPSHOT} joins in batch mode. {@link
+ * BatchPhysicalLateralSnapshotJoinRule} degrades the join to a regular join 
of the probe side
+ * against the (final) build side.
+ */
+public class LateralSnapshotJoinBatchSemanticTests extends 
BatchSemanticTestBase {
+
+    @Override
+    public List<TableTestProgram> programs() {
+        return List.of(
+                LateralSnapshotJoinTestPrograms.INNER_JOIN,
+                LateralSnapshotJoinTestPrograms.LEFT_JOIN,
+                
LateralSnapshotJoinTestPrograms.INNER_JOIN_WITH_NON_EQUI_CONDITION,
+                LateralSnapshotJoinTestPrograms.SNAPSHOT_ARGUMENTS_IGNORED);
+    }
+}
diff --git 
a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/nodes/exec/batch/LateralSnapshotJoinTestPrograms.java
 
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/nodes/exec/batch/LateralSnapshotJoinTestPrograms.java
new file mode 100644
index 00000000000..8b8be211952
--- /dev/null
+++ 
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/nodes/exec/batch/LateralSnapshotJoinTestPrograms.java
@@ -0,0 +1,129 @@
+/*
+ * 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.nodes.exec.batch;
+
+import org.apache.flink.table.test.program.SinkTestStep;
+import org.apache.flink.table.test.program.SourceTestStep;
+import org.apache.flink.table.test.program.TableTestProgram;
+import org.apache.flink.types.Row;
+
+/**
+ * {@link TableTestProgram} definitions for semantically testing {@code 
LATERAL SNAPSHOT} joins in
+ * batch mode, where the join degenerates to a regular join of the probe side 
against the (final)
+ * build side.
+ */
+public class LateralSnapshotJoinTestPrograms {
+
+    private static SourceTestStep probe() {
+        return SourceTestStep.newBuilder("probe")
+                .addSchema("pk STRING", "pv INT")
+                .producedValues(Row.of("a", 1), Row.of("b", 2), Row.of("c", 3))
+                .build();
+    }
+
+    private static SourceTestStep build() {
+        // The key "a" appears twice: the batch join runs against the final 
(complete) build side,
+        // so both "a" rows participate.
+        return SourceTestStep.newBuilder("b")
+                .addSchema("bk STRING", "bv INT")
+                .producedValues(Row.of("a", 10), Row.of("a", 11), Row.of("b", 
20))
+                .build();
+    }
+
+    public static final TableTestProgram INNER_JOIN =
+            TableTestProgram.of(
+                            "lateral-snapshot-join-inner",
+                            "batch LATERAL SNAPSHOT inner join degrades to a 
regular join")
+                    .setupTableSource(probe())
+                    .setupTableSource(build())
+                    .setupTableSink(
+                            SinkTestStep.newBuilder("sink")
+                                    .addSchema("pk STRING", "pv INT", "bk 
STRING", "bv INT")
+                                    .consumedValues(
+                                            Row.of("a", 1, "a", 10),
+                                            Row.of("a", 1, "a", 11),
+                                            Row.of("b", 2, "b", 20))
+                                    .build())
+                    .runSql(
+                            "INSERT INTO sink SELECT pk, pv, bk, bv FROM probe 
JOIN LATERAL "
+                                    + "TABLE(SNAPSHOT(input => TABLE b)) AS s 
ON probe.pk = s.bk")
+                    .build();
+
+    public static final TableTestProgram LEFT_JOIN =
+            TableTestProgram.of(
+                            "lateral-snapshot-join-left",
+                            "batch LATERAL SNAPSHOT left join null-pads 
unmatched probe rows")
+                    .setupTableSource(probe())
+                    .setupTableSource(build())
+                    .setupTableSink(
+                            SinkTestStep.newBuilder("sink")
+                                    .addSchema("pk STRING", "pv INT", "bk 
STRING", "bv INT")
+                                    .consumedValues(
+                                            Row.of("a", 1, "a", 10),
+                                            Row.of("a", 1, "a", 11),
+                                            Row.of("b", 2, "b", 20),
+                                            Row.of("c", 3, null, null))
+                                    .build())
+                    .runSql(
+                            "INSERT INTO sink SELECT pk, pv, bk, bv FROM probe 
LEFT JOIN LATERAL "
+                                    + "TABLE(SNAPSHOT(input => TABLE b)) AS s 
ON probe.pk = s.bk")
+                    .build();
+
+    public static final TableTestProgram INNER_JOIN_WITH_NON_EQUI_CONDITION =
+            TableTestProgram.of(
+                            "lateral-snapshot-join-non-equi",
+                            "batch LATERAL SNAPSHOT join with an additional 
non-equi predicate")
+                    .setupTableSource(probe())
+                    .setupTableSource(build())
+                    .setupTableSink(
+                            SinkTestStep.newBuilder("sink")
+                                    .addSchema("pk STRING", "pv INT", "bk 
STRING", "bv INT")
+                                    .consumedValues(
+                                            Row.of("a", 1, "a", 11), 
Row.of("b", 2, "b", 20))
+                                    .build())
+                    .runSql(
+                            "INSERT INTO sink SELECT pk, pv, bk, bv FROM probe 
JOIN LATERAL "
+                                    + "TABLE(SNAPSHOT(input => TABLE b)) AS s "
+                                    + "ON probe.pk = s.bk AND s.bv > 10")
+                    .build();
+
+    public static final TableTestProgram SNAPSHOT_ARGUMENTS_IGNORED =
+            TableTestProgram.of(
+                            "lateral-snapshot-join-arguments-ignored",
+                            "streaming-only SNAPSHOT arguments do not affect 
the batch result")
+                    .setupTableSource(probe())
+                    .setupTableSource(build())
+                    .setupTableSink(
+                            SinkTestStep.newBuilder("sink")
+                                    .addSchema("pk STRING", "pv INT", "bk 
STRING", "bv INT")
+                                    .consumedValues(
+                                            Row.of("a", 1, "a", 10),
+                                            Row.of("a", 1, "a", 11),
+                                            Row.of("b", 2, "b", 20))
+                                    .build())
+                    .runSql(
+                            "INSERT INTO sink SELECT pk, pv, bk, bv FROM probe 
JOIN LATERAL "
+                                    + "TABLE(SNAPSHOT("
+                                    + "input => TABLE b, "
+                                    + "load_completed_condition => 
'compile_time', "
+                                    + "load_completed_idle_timeout => INTERVAL 
'10' SECOND, "
+                                    + "state_ttl => INTERVAL '1' DAY"
+                                    + ")) AS s ON probe.pk = s.bk")
+                    .build();
+}
diff --git 
a/flink-table/flink-table-planner/src/test/resources/org/apache/flink/table/planner/plan/batch/sql/join/LateralSnapshotJoinTest.xml
 
b/flink-table/flink-table-planner/src/test/resources/org/apache/flink/table/planner/plan/batch/sql/join/LateralSnapshotJoinTest.xml
new file mode 100644
index 00000000000..5375c4f7eb3
--- /dev/null
+++ 
b/flink-table/flink-table-planner/src/test/resources/org/apache/flink/table/planner/plan/batch/sql/join/LateralSnapshotJoinTest.xml
@@ -0,0 +1,191 @@
+<?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="testBuildSideWithProctime">
+    <Resource name="sql">
+      <![CDATA[SELECT probe.pk, s.bk, s.bv, s.pt FROM probe JOIN LATERAL 
TABLE(SNAPSHOT(input => TABLE b_proctime)) AS s ON probe.pk = s.bk]]>
+    </Resource>
+    <Resource name="ast">
+      <![CDATA[
+LogicalProject(pk=[$0], bk=[$3], bv=[$4], pt=[$5])
++- LogicalJoin(condition=[=($0, $3)], joinType=[inner])
+   :- LogicalTableScan(table=[[default_catalog, default_database, probe]])
+   +- LogicalTableFunctionScan(invocation=[SNAPSHOT(TABLE(#0), DEFAULT(), 
DEFAULT(), DEFAULT(), DEFAULT())], rowType=[RecordType(VARCHAR(2147483647) bk, 
INTEGER bv, TIMESTAMP_WITH_LOCAL_TIME_ZONE(3) pt)])
+      +- LogicalProject(bk=[$0], bv=[$1], pt=[$2])
+         +- LogicalProject(bk=[$0], bv=[$1], pt=[PROCTIME()])
+            +- LogicalTableScan(table=[[default_catalog, default_database, 
b_proctime]])
+]]>
+    </Resource>
+    <Resource name="optimized rel plan">
+      <![CDATA[
+HashJoin(joinType=[InnerJoin], where=[=(pk, bk)], select=[pk, bk, bv, pt], 
build=[right])
+:- Exchange(distribution=[hash[pk]])
+:  +- TableSourceScan(table=[[default_catalog, default_database, probe, 
project=[pk], metadata=[]]], fields=[pk])
++- Calc(select=[bk, bv, PROCTIME_MATERIALIZE(PROCTIME()) AS pt])
+   +- Exchange(distribution=[hash[bk]])
+      +- TableSourceScan(table=[[default_catalog, default_database, 
b_proctime]], fields=[bk, bv])
+]]>
+    </Resource>
+  </TestCase>
+  <TestCase name="testBuildSideWithoutWatermark">
+    <Resource name="sql">
+      <![CDATA[SELECT * FROM probe JOIN LATERAL TABLE(SNAPSHOT(input => TABLE 
b_no_wm)) AS s ON probe.pk = s.bk]]>
+    </Resource>
+    <Resource name="ast">
+      <![CDATA[
+LogicalProject(pk=[$0], pv=[$1], pts=[$2], bk=[$3], bv=[$4], bts=[$5])
++- LogicalJoin(condition=[=($0, $3)], joinType=[inner])
+   :- LogicalTableScan(table=[[default_catalog, default_database, probe]])
+   +- LogicalTableFunctionScan(invocation=[SNAPSHOT(TABLE(#0), DEFAULT(), 
DEFAULT(), DEFAULT(), DEFAULT())], rowType=[RecordType(VARCHAR(2147483647) bk, 
INTEGER bv, TIMESTAMP(3) bts)])
+      +- LogicalProject(bk=[$0], bv=[$1], bts=[$2])
+         +- LogicalTableScan(table=[[default_catalog, default_database, 
b_no_wm]])
+]]>
+    </Resource>
+    <Resource name="optimized rel plan">
+      <![CDATA[
+HashJoin(joinType=[InnerJoin], where=[=(pk, bk)], select=[pk, pv, pts, bk, bv, 
bts], build=[right])
+:- Exchange(distribution=[hash[pk]])
+:  +- TableSourceScan(table=[[default_catalog, default_database, probe]], 
fields=[pk, pv, pts])
++- Exchange(distribution=[hash[bk]])
+   +- TableSourceScan(table=[[default_catalog, default_database, b_no_wm]], 
fields=[bk, bv, bts])
+]]>
+    </Resource>
+  </TestCase>
+  <TestCase name="testInnerJoin">
+    <Resource name="sql">
+      <![CDATA[SELECT * FROM probe JOIN LATERAL TABLE(SNAPSHOT(input => TABLE 
b, load_completed_condition => 'user_time', load_completed_time => 
CAST(TIMESTAMP '2026-07-01 00:00:00' AS TIMESTAMP_LTZ(3)))) AS s ON probe.pk = 
s.bk]]>
+    </Resource>
+    <Resource name="ast">
+      <![CDATA[
+LogicalProject(pk=[$0], pv=[$1], pts=[$2], bk=[$3], bv=[$4], bts=[$5])
++- LogicalJoin(condition=[=($0, $3)], joinType=[inner])
+   :- LogicalTableScan(table=[[default_catalog, default_database, probe]])
+   +- LogicalTableFunctionScan(invocation=[SNAPSHOT(TABLE(#0), 
_UTF-16LE'user_time', CAST(2026-07-01 
00:00:00):TIMESTAMP_WITH_LOCAL_TIME_ZONE(3) NOT NULL, DEFAULT(), DEFAULT())], 
rowType=[RecordType(VARCHAR(2147483647) bk, INTEGER bv, TIMESTAMP(3) bts)])
+      +- LogicalProject(bk=[$0], bv=[$1], bts=[$2])
+         +- LogicalTableScan(table=[[default_catalog, default_database, b]])
+]]>
+    </Resource>
+    <Resource name="optimized rel plan">
+      <![CDATA[
+HashJoin(joinType=[InnerJoin], where=[=(pk, bk)], select=[pk, pv, pts, bk, bv, 
bts], build=[right])
+:- Exchange(distribution=[hash[pk]])
+:  +- TableSourceScan(table=[[default_catalog, default_database, probe]], 
fields=[pk, pv, pts])
++- Exchange(distribution=[hash[bk]])
+   +- TableSourceScan(table=[[default_catalog, default_database, b]], 
fields=[bk, bv, bts])
+]]>
+    </Resource>
+  </TestCase>
+  <TestCase name="testInnerJoinWithCompositeKeys">
+    <Resource name="sql">
+      <![CDATA[SELECT * FROM probe JOIN LATERAL TABLE(SNAPSHOT(input => TABLE 
b, load_completed_condition => 'user_time', load_completed_time => 
CAST(TIMESTAMP '2026-07-01 00:00:00' AS TIMESTAMP_LTZ(3)))) AS s ON probe.pk = 
s.bk AND probe.pv = s.bv]]>
+    </Resource>
+    <Resource name="ast">
+      <![CDATA[
+LogicalProject(pk=[$0], pv=[$1], pts=[$2], bk=[$3], bv=[$4], bts=[$5])
++- LogicalJoin(condition=[AND(=($0, $3), =($1, $4))], joinType=[inner])
+   :- LogicalTableScan(table=[[default_catalog, default_database, probe]])
+   +- LogicalTableFunctionScan(invocation=[SNAPSHOT(TABLE(#0), 
_UTF-16LE'user_time', CAST(2026-07-01 
00:00:00):TIMESTAMP_WITH_LOCAL_TIME_ZONE(3) NOT NULL, DEFAULT(), DEFAULT())], 
rowType=[RecordType(VARCHAR(2147483647) bk, INTEGER bv, TIMESTAMP(3) bts)])
+      +- LogicalProject(bk=[$0], bv=[$1], bts=[$2])
+         +- LogicalTableScan(table=[[default_catalog, default_database, b]])
+]]>
+    </Resource>
+    <Resource name="optimized rel plan">
+      <![CDATA[
+HashJoin(joinType=[InnerJoin], where=[AND(=(pk, bk), =(pv, bv))], select=[pk, 
pv, pts, bk, bv, bts], build=[right])
+:- Exchange(distribution=[hash[pk, pv]])
+:  +- TableSourceScan(table=[[default_catalog, default_database, probe]], 
fields=[pk, pv, pts])
++- Exchange(distribution=[hash[bk, bv]])
+   +- TableSourceScan(table=[[default_catalog, default_database, b]], 
fields=[bk, bv, bts])
+]]>
+    </Resource>
+  </TestCase>
+  <TestCase name="testInnerJoinWithNonEquiCondition">
+    <Resource name="sql">
+      <![CDATA[SELECT * FROM probe JOIN LATERAL TABLE(SNAPSHOT(input => TABLE 
b, load_completed_condition => 'user_time', load_completed_time => 
CAST(TIMESTAMP '2026-07-01 00:00:00' AS TIMESTAMP_LTZ(3)))) AS s ON probe.pk = 
s.bk AND probe.pv > s.bv]]>
+    </Resource>
+    <Resource name="ast">
+      <![CDATA[
+LogicalProject(pk=[$0], pv=[$1], pts=[$2], bk=[$3], bv=[$4], bts=[$5])
++- LogicalJoin(condition=[AND(=($0, $3), >($1, $4))], joinType=[inner])
+   :- LogicalTableScan(table=[[default_catalog, default_database, probe]])
+   +- LogicalTableFunctionScan(invocation=[SNAPSHOT(TABLE(#0), 
_UTF-16LE'user_time', CAST(2026-07-01 
00:00:00):TIMESTAMP_WITH_LOCAL_TIME_ZONE(3) NOT NULL, DEFAULT(), DEFAULT())], 
rowType=[RecordType(VARCHAR(2147483647) bk, INTEGER bv, TIMESTAMP(3) bts)])
+      +- LogicalProject(bk=[$0], bv=[$1], bts=[$2])
+         +- LogicalTableScan(table=[[default_catalog, default_database, b]])
+]]>
+    </Resource>
+    <Resource name="optimized rel plan">
+      <![CDATA[
+HashJoin(joinType=[InnerJoin], where=[AND(=(pk, bk), >(pv, bv))], select=[pk, 
pv, pts, bk, bv, bts], build=[right])
+:- Exchange(distribution=[hash[pk]])
+:  +- TableSourceScan(table=[[default_catalog, default_database, probe]], 
fields=[pk, pv, pts])
++- Exchange(distribution=[hash[bk]])
+   +- TableSourceScan(table=[[default_catalog, default_database, b]], 
fields=[bk, bv, bts])
+]]>
+    </Resource>
+  </TestCase>
+  <TestCase name="testInnerJoinWithoutBuildTimeColumn">
+    <Resource name="sql">
+      <![CDATA[SELECT probe.pk, probe.pv, s.bv FROM probe JOIN LATERAL 
TABLE(SNAPSHOT(input => TABLE b, load_completed_condition => 'user_time', 
load_completed_time => CAST(TIMESTAMP '2026-07-01 00:00:00' AS 
TIMESTAMP_LTZ(3)))) AS s ON probe.pk = s.bk]]>
+    </Resource>
+    <Resource name="ast">
+      <![CDATA[
+LogicalProject(pk=[$0], pv=[$1], bv=[$4])
++- LogicalJoin(condition=[=($0, $3)], joinType=[inner])
+   :- LogicalTableScan(table=[[default_catalog, default_database, probe]])
+   +- LogicalTableFunctionScan(invocation=[SNAPSHOT(TABLE(#0), 
_UTF-16LE'user_time', CAST(2026-07-01 
00:00:00):TIMESTAMP_WITH_LOCAL_TIME_ZONE(3) NOT NULL, DEFAULT(), DEFAULT())], 
rowType=[RecordType(VARCHAR(2147483647) bk, INTEGER bv, TIMESTAMP(3) bts)])
+      +- LogicalProject(bk=[$0], bv=[$1], bts=[$2])
+         +- LogicalTableScan(table=[[default_catalog, default_database, b]])
+]]>
+    </Resource>
+    <Resource name="optimized rel plan">
+      <![CDATA[
+Calc(select=[pk, pv, bv])
++- HashJoin(joinType=[InnerJoin], where=[=(pk, bk)], select=[pk, pv, bk, bv], 
build=[right])
+   :- Exchange(distribution=[hash[pk]])
+   :  +- TableSourceScan(table=[[default_catalog, default_database, probe, 
project=[pk, pv], metadata=[]]], fields=[pk, pv])
+   +- Exchange(distribution=[hash[bk]])
+      +- Calc(select=[bk, bv])
+         +- TableSourceScan(table=[[default_catalog, default_database, b]], 
fields=[bk, bv, bts])
+]]>
+    </Resource>
+  </TestCase>
+  <TestCase name="testLeftJoin">
+    <Resource name="sql">
+      <![CDATA[SELECT * FROM probe LEFT JOIN LATERAL TABLE(SNAPSHOT(input => 
TABLE b, load_completed_condition => 'user_time', load_completed_time => 
CAST(TIMESTAMP '2026-07-01 00:00:00' AS TIMESTAMP_LTZ(3)))) AS s ON probe.pk = 
s.bk]]>
+    </Resource>
+    <Resource name="ast">
+      <![CDATA[
+LogicalProject(pk=[$0], pv=[$1], pts=[$2], bk=[$3], bv=[$4], bts=[$5])
++- LogicalJoin(condition=[=($0, $3)], joinType=[left])
+   :- LogicalTableScan(table=[[default_catalog, default_database, probe]])
+   +- LogicalTableFunctionScan(invocation=[SNAPSHOT(TABLE(#0), 
_UTF-16LE'user_time', CAST(2026-07-01 
00:00:00):TIMESTAMP_WITH_LOCAL_TIME_ZONE(3) NOT NULL, DEFAULT(), DEFAULT())], 
rowType=[RecordType(VARCHAR(2147483647) bk, INTEGER bv, TIMESTAMP(3) bts)])
+      +- LogicalProject(bk=[$0], bv=[$1], bts=[$2])
+         +- LogicalTableScan(table=[[default_catalog, default_database, b]])
+]]>
+    </Resource>
+    <Resource name="optimized rel plan">
+      <![CDATA[
+HashJoin(joinType=[LeftOuterJoin], where=[=(pk, bk)], select=[pk, pv, pts, bk, 
bv, bts], build=[right])
+:- Exchange(distribution=[hash[pk]])
+:  +- TableSourceScan(table=[[default_catalog, default_database, probe]], 
fields=[pk, pv, pts])
++- Exchange(distribution=[hash[bk]])
+   +- TableSourceScan(table=[[default_catalog, default_database, b]], 
fields=[bk, bv, bts])
+]]>
+    </Resource>
+  </TestCase>
+</Root>


Reply via email to