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 52ef7d53dbd [FLINK-40885][table] Support LATERAL SNAPSHOT join in the 
Table API (#29368)
52ef7d53dbd is described below

commit 52ef7d53dbd3a2f8b2c64607dc253e9c97e08aef
Author: Fabian Hueske <[email protected]>
AuthorDate: Thu Oct 8 15:38:52 2026 +0200

    [FLINK-40885][table] Support LATERAL SNAPSHOT join in the Table API (#29368)
    
    Enable the processing-time LATERAL SNAPSHOT temporal join to be expressed 
through
    Table API via Table.joinLateral / Table.leftOuterJoinLateral with a 
call("SNAPSHOT", ...) build side,
    producing the same optimized plan and operator 
(StreamExecLateralSnapshotJoin) as the equivalent SQL.
    Also adds Table API join documentation.
    
    Generated-by: Claude Code, Opus 4.8
---
 docs/content.zh/docs/dev/table/tableApi.md         |  41 +++++
 docs/content/docs/dev/table/tableApi.md            |  41 +++++
 .../utils/CorrelatedFunctionTableFactory.java      |  18 +-
 .../operations/utils/JoinOperationFactory.java     |  15 +-
 .../planner/plan/QueryOperationConverter.java      | 151 ++++++++++------
 .../api/QueryOperationSqlSerializationTest.java    |   4 +-
 .../table/api/QueryOperationTestPrograms.java      |  85 +++++++++
 .../LateralSnapshotJoinSemanticTestPrograms.java   |  52 ++++++
 .../stream/LateralSnapshotJoinSemanticTests.java   |   3 +-
 .../sql/join/LateralSnapshotJoinTableApiTest.java  | 201 +++++++++++++++++++++
 10 files changed, 553 insertions(+), 58 deletions(-)

diff --git a/docs/content.zh/docs/dev/table/tableApi.md 
b/docs/content.zh/docs/dev/table/tableApi.md
index 2ed8f2e395b..a88533fa0af 100644
--- a/docs/content.zh/docs/dev/table/tableApi.md
+++ b/docs/content.zh/docs/dev/table/tableApi.md
@@ -1152,6 +1152,47 @@ val result = orders
 {{< /tab >}}
 {{< /tabs >}}
 
+#### Lateral Snapshot Join
+
+{{< label "Batch" >}} {{< label "Streaming" >}}
+
+Enriches an append-only table with the current state of an updating table by 
calling the built-in `SNAPSHOT` [process table function]({{< ref 
"docs/sql/functions/built-in-functions" >}}) as the build (right) side of a 
`LATERAL` join. Each probe-side row is joined with the build-side state that is 
current when the row is processed. Both inner and left outer joins are 
supported, and the predicate must contain at least one equality condition.
+
+See [LATERAL SNAPSHOT join]({{< ref "docs/sql/reference/queries/joins" 
>}}#lateral-snapshot-join) for the full list of `SNAPSHOT` arguments and the 
join semantics.
+
+{{< tabs "snapshotjoin" >}}
+{{< tab "Java" >}}
+```java
+Table orders = tableEnv.from("Orders");
+Table currencyRates = tableEnv.from("CurrencyRates");
+
+// inner join: enrich every order with the current conversion rate
+Table result = orders
+    .joinLateral(
+        call(
+            "SNAPSHOT",
+            currencyRates.asArgument("input"),
+            descriptor("update_time").asArgument("on_time")),
+        $("currency").isEqual($("r_currency")));
+
+// left outer join: unmatched orders are preserved and padded with nulls
+Table leftResult = orders
+    .leftOuterJoinLateral(
+        call(
+            "SNAPSHOT",
+            currencyRates.asArgument("input"),
+            descriptor("update_time").asArgument("on_time")),
+        $("currency").isEqual($("r_currency")));
+```
+{{< /tab >}}
+{{< tab "Scala" >}}
+Currently not supported in Scala Table API.
+{{< /tab >}}
+{{< tab "Python" >}}
+Currently not supported in Python Table API.
+{{< /tab >}}
+{{< /tabs >}}
+
 {{< top >}}
 
 ### Set 操作
diff --git a/docs/content/docs/dev/table/tableApi.md 
b/docs/content/docs/dev/table/tableApi.md
index 118befaaadd..f50b150c711 100644
--- a/docs/content/docs/dev/table/tableApi.md
+++ b/docs/content/docs/dev/table/tableApi.md
@@ -1151,6 +1151,47 @@ Currently not supported in Python Table API.
 {{< /tab >}}
 {{< /tabs >}}
 
+#### Lateral Snapshot Join
+
+{{< label "Batch" >}} {{< label "Streaming" >}}
+
+Enriches an append-only table with the current state of an updating table by 
calling the built-in `SNAPSHOT` [process table function]({{< ref 
"docs/sql/functions/built-in-functions" >}}) as the build (right) side of a 
`LATERAL` join. Each probe-side row is joined with the build-side state that is 
current when the row is processed. Both inner and left outer joins are 
supported, and the predicate must contain at least one equality condition.
+
+See [LATERAL SNAPSHOT join]({{< ref "docs/sql/reference/queries/joins" 
>}}#lateral-snapshot-join) for the full list of `SNAPSHOT` arguments and the 
join semantics.
+
+{{< tabs "snapshotjoin" >}}
+{{< tab "Java" >}}
+```java
+Table orders = tableEnv.from("Orders");
+Table currencyRates = tableEnv.from("CurrencyRates");
+
+// inner join: enrich every order with the current conversion rate
+Table result = orders
+    .joinLateral(
+        call(
+            "SNAPSHOT",
+            currencyRates.asArgument("input"),
+            descriptor("update_time").asArgument("on_time")),
+        $("currency").isEqual($("r_currency")));
+
+// left outer join: unmatched orders are preserved and padded with nulls
+Table leftResult = orders
+    .leftOuterJoinLateral(
+        call(
+            "SNAPSHOT",
+            currencyRates.asArgument("input"),
+            descriptor("update_time").asArgument("on_time")),
+        $("currency").isEqual($("r_currency")));
+```
+{{< /tab >}}
+{{< tab "Scala" >}}
+Currently not supported in Scala Table API.
+{{< /tab >}}
+{{< tab "Python" >}}
+Currently not supported in Python Table API.
+{{< /tab >}}
+{{< /tabs >}}
+
 {{< top >}}
 
 ### Set Operations
diff --git 
a/flink-table/flink-table-api-java/src/main/java/org/apache/flink/table/operations/utils/CorrelatedFunctionTableFactory.java
 
b/flink-table/flink-table-api-java/src/main/java/org/apache/flink/table/operations/utils/CorrelatedFunctionTableFactory.java
index 0a477a3d0d8..7d957d657f6 100644
--- 
a/flink-table/flink-table-api-java/src/main/java/org/apache/flink/table/operations/utils/CorrelatedFunctionTableFactory.java
+++ 
b/flink-table/flink-table-api-java/src/main/java/org/apache/flink/table/operations/utils/CorrelatedFunctionTableFactory.java
@@ -27,6 +27,7 @@ import org.apache.flink.table.expressions.Expression;
 import org.apache.flink.table.expressions.ExpressionUtils;
 import org.apache.flink.table.expressions.ResolvedExpression;
 import 
org.apache.flink.table.expressions.utils.ResolvedExpressionDefaultVisitor;
+import org.apache.flink.table.functions.BuiltInFunctionDefinitions;
 import org.apache.flink.table.functions.FunctionDefinition;
 import org.apache.flink.table.functions.FunctionKind;
 import org.apache.flink.table.operations.CorrelatedFunctionQueryOperation;
@@ -39,8 +40,10 @@ import java.util.Collections;
 import java.util.List;
 
 import static java.util.stream.Collectors.toList;
+import static org.apache.flink.table.expressions.ApiExpressionUtils.isFunction;
 import static 
org.apache.flink.table.expressions.ApiExpressionUtils.isFunctionOfKind;
 import static org.apache.flink.table.functions.BuiltInFunctionDefinitions.AS;
+import static 
org.apache.flink.table.functions.BuiltInFunctionDefinitions.SNAPSHOT;
 
 /** Utility class for creating a valid {@link 
CorrelatedFunctionQueryOperation} operation. */
 @Internal
@@ -58,6 +61,14 @@ final class CorrelatedFunctionTableFactory {
         return callExpr.accept(calculatedTableCreator);
     }
 
+    /**
+     * Whether the given function is {@code SNAPSHOT}, currently the only 
{@link
+     * FunctionKind#PROCESS_TABLE} function that is valid as the build side of 
a lateral join.
+     */
+    static boolean isSnapshot(FunctionDefinition definition) {
+        return definition.equals(BuiltInFunctionDefinitions.SNAPSHOT);
+    }
+
     private static class FunctionTableCallVisitor
             extends 
ResolvedExpressionDefaultVisitor<CorrelatedFunctionQueryOperation> {
         private final List<String> leftTableFieldNames;
@@ -91,11 +102,14 @@ final class CorrelatedFunctionTableFactory {
                                                                                
     + alias)))
                             .collect(toList());
 
-            if (!isFunctionOfKind(children.get(0), FunctionKind.TABLE)) {
+            // SNAPSHOT is PROCESS_TABLE rather than TABLE but is valid in a 
LATERAL join.
+            Expression firstChild = children.get(0);
+            if (!isFunctionOfKind(firstChild, FunctionKind.TABLE)
+                    && !isFunction(firstChild, SNAPSHOT)) {
                 throw fail();
             }
 
-            CallExpression tableCall = (CallExpression) children.get(0);
+            CallExpression tableCall = (CallExpression) firstChild;
             return createFunctionCall(tableCall, aliases, 
tableCall.getResolvedChildren());
         }
 
diff --git 
a/flink-table/flink-table-api-java/src/main/java/org/apache/flink/table/operations/utils/JoinOperationFactory.java
 
b/flink-table/flink-table-api-java/src/main/java/org/apache/flink/table/operations/utils/JoinOperationFactory.java
index 5b5cd81d0c4..5ab89c7bda2 100644
--- 
a/flink-table/flink-table-api-java/src/main/java/org/apache/flink/table/operations/utils/JoinOperationFactory.java
+++ 
b/flink-table/flink-table-api-java/src/main/java/org/apache/flink/table/operations/utils/JoinOperationFactory.java
@@ -80,8 +80,21 @@ final class JoinOperationFactory {
             JoinType joinType,
             ResolvedExpression condition,
             boolean correlated) {
-        boolean alwaysTrue = ExpressionUtils.extractValue(condition, 
Boolean.class).orElse(false);
+        // Unlike other lateral joins with a table function (which only allow 
an empty/literal-true
+        // condition), a lateral SNAPSHOT join supports an ON predicate.
+        final boolean isLateralSnapshot =
+                correlated
+                        && right instanceof CorrelatedFunctionQueryOperation
+                        && CorrelatedFunctionTableFactory.isSnapshot(
+                                ((CorrelatedFunctionQueryOperation) right)
+                                        .getResolvedFunction()
+                                        .getDefinition());
+        if (isLateralSnapshot) {
+            return;
+        }
 
+        final boolean alwaysTrue =
+                ExpressionUtils.extractValue(condition, 
Boolean.class).orElse(false);
         if (alwaysTrue) {
             return;
         }
diff --git 
a/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/plan/QueryOperationConverter.java
 
b/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/plan/QueryOperationConverter.java
index 468430dbc7b..10531fd28e8 100644
--- 
a/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/plan/QueryOperationConverter.java
+++ 
b/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/plan/QueryOperationConverter.java
@@ -93,6 +93,7 @@ import 
org.apache.flink.table.planner.plan.schema.DataStreamTable$;
 import org.apache.flink.table.planner.plan.schema.LegacyTableSourceTable;
 import org.apache.flink.table.planner.plan.schema.TypedFlinkTableFunction;
 import org.apache.flink.table.planner.plan.stats.FlinkStatistic;
+import org.apache.flink.table.planner.plan.utils.LateralSnapshotJoinUtil;
 import org.apache.flink.table.planner.sources.TableSourceUtil;
 import org.apache.flink.table.planner.utils.ShortcutUtils;
 import org.apache.flink.table.runtime.groupwindow.NamedWindowProperty;
@@ -284,7 +285,17 @@ public class QueryOperationConverter extends 
QueryOperationDefaultVisitor<RelNod
         @Override
         public RelNode visit(JoinQueryOperation join) {
             final Set<CorrelationId> corSet;
-            if (join.isCorrelated()) {
+            // Check if this is a LATERAL SNAPSHOT join. The SNAPSHOT function 
never references the
+            // outer row. Emit a plain LogicalJoin instead of a Correlate, so 
that
+            // LogicalJoinToLateralSnapshotJoinRule matches.
+            final QueryOperation right = join.getChildren().get(1);
+            final boolean isLateralSnapshotJoin =
+                    right instanceof CorrelatedFunctionQueryOperation
+                            && LateralSnapshotJoinUtil.isSnapshotFunction(
+                                    ((CorrelatedFunctionQueryOperation) right)
+                                            .getResolvedFunction()
+                                            .getDefinition());
+            if (join.isCorrelated() && !isLateralSnapshotJoin) {
                 corSet = 
Collections.singleton(relBuilder.peek().getCluster().createCorrel());
             } else {
                 corSet = Collections.emptySet();
@@ -352,44 +363,11 @@ public class QueryOperationConverter extends 
QueryOperationDefaultVisitor<RelNod
                             .map(
                                     resolvedArg -> {
                                         if (resolvedArg instanceof 
TableReferenceExpression) {
-                                            final TableReferenceExpression 
tableRef =
-                                                    (TableReferenceExpression) 
resolvedArg;
-                                            final LogicalType tableArgType =
-                                                    
tableRef.getOutputDataType().getLogicalType();
-                                            final RelDataType rowType =
-                                                    
typeFactory.buildRelNodeRowType(
-                                                            (RowType) 
tableArgType);
-                                            final int[] partitionKeys;
-                                            final int[] orderKeys;
-                                            final SortOrder[] sortOrders;
-                                            if (tableRef.getQueryOperation()
-                                                    instanceof 
PartitionQueryOperation) {
-                                                final PartitionQueryOperation 
partitionOperation =
-                                                        
(PartitionQueryOperation)
-                                                                
tableRef.getQueryOperation();
-                                                partitionKeys =
-                                                        
partitionOperation.getPartitionKeys();
-                                                orderKeys = 
partitionOperation.getOrderKeys();
-                                                final SortDirection[] 
directions =
-                                                        
partitionOperation.getOrderDirections();
-                                                sortOrders = new 
SortOrder[directions.length];
-                                                for (int i = 0; i < 
directions.length; i++) {
-                                                    sortOrders[i] =
-                                                            
SortOrder.fromSortDirection(
-                                                                    
directions[i]);
-                                                }
-                                            } else {
-                                                partitionKeys = new int[0];
-                                                orderKeys = new int[0];
-                                                sortOrders = new SortOrder[0];
-                                            }
                                             final RexTableArgCall tableArgCall 
=
-                                                    new RexTableArgCall(
-                                                            rowType,
+                                                    buildTableArgCall(
+                                                            
(TableReferenceExpression) resolvedArg,
                                                             inputStack.size(),
-                                                            partitionKeys,
-                                                            orderKeys,
-                                                            sortOrders);
+                                                            typeFactory);
                                             inputStack.add(relBuilder.build());
                                             return tableArgCall;
                                         }
@@ -400,22 +378,9 @@ public class QueryOperationConverter extends 
QueryOperationDefaultVisitor<RelNod
             // relBuilder.build() works in LIFO fashion, this restores the 
original input order
             Collections.reverse(inputStack);
 
-            final BridgingSqlFunction sqlFunction =
-                    BridgingSqlFunction.of(relBuilder.getCluster(), 
contextFunction);
-
-            final RexNode call =
-                    relBuilder
-                            .getRexBuilder()
-                            .makeCall(outputRelDataType, sqlFunction, 
rexNodeArgs);
-            final RelNode functionScan =
-                    LogicalTableFunctionScan.create(
-                            relBuilder.getCluster(),
-                            inputStack,
-                            call,
-                            null,
-                            outputRelDataType,
-                            Collections.emptySet());
-            relBuilder.push(functionScan);
+            relBuilder.push(
+                    createTableFunctionScan(
+                            contextFunction, rexNodeArgs, inputStack, 
outputRelDataType));
             return relBuilder.build();
         }
 
@@ -430,6 +395,11 @@ public class QueryOperationConverter extends 
QueryOperationDefaultVisitor<RelNod
         public RelNode visit(CorrelatedFunctionQueryOperation 
correlatedFunction) {
             final ContextResolvedFunction contextFunction =
                     correlatedFunction.getResolvedFunction();
+            // SNAPSHOT is currently the only PTF valid in a lateral join and 
hence the only
+            // function accepting table args.
+            if 
(LateralSnapshotJoinUtil.isSnapshotFunction(contextFunction.getDefinition())) {
+                return 
convertCorrelatedFunctionWithTableArgs(correlatedFunction);
+            }
             final List<RexNode> parameters = 
convertToRexNodes(correlatedFunction.getArguments());
 
             final FunctionDefinition functionDefinition = 
contextFunction.getDefinition();
@@ -455,6 +425,81 @@ public class QueryOperationConverter extends 
QueryOperationDefaultVisitor<RelNod
             return relBuilder.build();
         }
 
+        private RelNode convertCorrelatedFunctionWithTableArgs(
+                CorrelatedFunctionQueryOperation correlatedFunction) {
+            final FlinkTypeFactory typeFactory = 
ShortcutUtils.unwrapTypeFactory(relBuilder);
+            final List<RelNode> inputs = new ArrayList<>();
+            final List<RexNode> args = new ArrayList<>();
+            for (ResolvedExpression arg : correlatedFunction.getArguments()) {
+                if (arg instanceof TableReferenceExpression) {
+                    final TableReferenceExpression tableRef = 
(TableReferenceExpression) arg;
+                    args.add(buildTableArgCall(tableRef, inputs.size(), 
typeFactory));
+                    
inputs.add(tableRef.getQueryOperation().accept(QueryOperationConverter.this));
+                } else {
+                    args.add(convertExprToRexNode(arg));
+                }
+            }
+            // Derive the output row type like the FROM-clause PTF path
+            // (FunctionQueryOperation#getOutputDataType).
+            final RelDataType outputType =
+                    typeFactory.buildRelNodeRowType(
+                            (RowType)
+                                    
DataTypeUtils.fromResolvedSchemaPreservingTimeAttributes(
+                                                    
correlatedFunction.getResolvedSchema())
+                                            .getLogicalType());
+            return createTableFunctionScan(
+                    correlatedFunction.getResolvedFunction(), args, inputs, 
outputType);
+        }
+
+        /**
+         * Converts a {@link TableReferenceExpression} table argument into a 
{@link
+         * RexTableArgCall}, carrying PARTITION BY / ORDER BY keys when the 
referenced operation is
+         * a {@link PartitionQueryOperation}.
+         */
+        private RexTableArgCall buildTableArgCall(
+                TableReferenceExpression tableRef, int inputIndex, 
FlinkTypeFactory typeFactory) {
+            final RelDataType rowType =
+                    typeFactory.buildRelNodeRowType(
+                            (RowType) 
tableRef.getOutputDataType().getLogicalType());
+            final int[] partitionKeys;
+            final int[] orderKeys;
+            final SortOrder[] sortOrders;
+            if (tableRef.getQueryOperation() instanceof 
PartitionQueryOperation) {
+                final PartitionQueryOperation partitionOperation =
+                        (PartitionQueryOperation) tableRef.getQueryOperation();
+                partitionKeys = partitionOperation.getPartitionKeys();
+                orderKeys = partitionOperation.getOrderKeys();
+                final SortDirection[] directions = 
partitionOperation.getOrderDirections();
+                sortOrders = new SortOrder[directions.length];
+                for (int i = 0; i < directions.length; i++) {
+                    sortOrders[i] = SortOrder.fromSortDirection(directions[i]);
+                }
+            } else {
+                partitionKeys = new int[0];
+                orderKeys = new int[0];
+                sortOrders = new SortOrder[0];
+            }
+            return new RexTableArgCall(rowType, inputIndex, partitionKeys, 
orderKeys, sortOrders);
+        }
+
+        /** Builds a {@link LogicalTableFunctionScan} for a function call with 
table arguments. */
+        private RelNode createTableFunctionScan(
+                ContextResolvedFunction function,
+                List<RexNode> args,
+                List<RelNode> inputs,
+                RelDataType outputType) {
+            final BridgingSqlFunction sqlFunction =
+                    BridgingSqlFunction.of(relBuilder.getCluster(), function);
+            final RexNode call = 
relBuilder.getRexBuilder().makeCall(outputType, sqlFunction, args);
+            return LogicalTableFunctionScan.create(
+                    relBuilder.getCluster(),
+                    inputs,
+                    call,
+                    null,
+                    outputType,
+                    Collections.emptySet());
+        }
+
         private RelNode convertLegacyTableFunction(
                 CorrelatedFunctionQueryOperation correlatedFunction,
                 TableFunctionDefinition functionDefinition,
diff --git 
a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/api/QueryOperationSqlSerializationTest.java
 
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/api/QueryOperationSqlSerializationTest.java
index 7b5795fe9a7..7924486711a 100644
--- 
a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/api/QueryOperationSqlSerializationTest.java
+++ 
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/api/QueryOperationSqlSerializationTest.java
@@ -91,7 +91,9 @@ public class QueryOperationSqlSerializationTest implements 
TableTestProgramRunne
                 QueryOperationTestPrograms.INLINE_FUNCTION_SERIALIZATION,
                 QueryOperationTestPrograms.ML_PREDICT_MODEL_API,
                 QueryOperationTestPrograms.ASYNC_ML_PREDICT_MODEL_API,
-                
QueryOperationTestPrograms.ASYNC_ML_PREDICT_TABLE_API_MAP_EXPRESSION_CONFIG);
+                
QueryOperationTestPrograms.ASYNC_ML_PREDICT_TABLE_API_MAP_EXPRESSION_CONFIG,
+                QueryOperationTestPrograms.SNAPSHOT_INNER_JOIN,
+                QueryOperationTestPrograms.SNAPSHOT_LEFT_JOIN);
     }
 
     @ParameterizedTest
diff --git 
a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/api/QueryOperationTestPrograms.java
 
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/api/QueryOperationTestPrograms.java
index 6c11367955f..fb41ab9c8ac 100644
--- 
a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/api/QueryOperationTestPrograms.java
+++ 
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/api/QueryOperationTestPrograms.java
@@ -40,6 +40,7 @@ import org.apache.flink.types.Row;
 import org.apache.flink.types.RowKind;
 
 import java.math.BigDecimal;
+import java.time.Duration;
 import java.time.Instant;
 import java.time.LocalDateTime;
 import java.time.LocalTime;
@@ -1878,4 +1879,88 @@ public class QueryOperationTestPrograms {
                                             lit(1).asArgument("i")),
                             "sink")
                     .build();
+
+    private static final String SNAPSHOT_PROBE_DDL =
+            "CREATE TABLE probe (pk STRING, pv INT, pts TIMESTAMP(3), 
WATERMARK FOR pts AS pts)"
+                    + " WITH ('connector' = 'values', 'bounded' = 'false')";
+
+    private static final String SNAPSHOT_BUILD_DDL =
+            "CREATE TABLE b (bk STRING, bv INT, bts TIMESTAMP(3), WATERMARK 
FOR bts AS bts)"
+                    + " WITH ('connector' = 'values', 'bounded' = 'false',"
+                    + " 'changelog-mode' = 'I,UB,UA,D')";
+
+    private static ApiExpression snapshotLoadCompletedTime() {
+        return 
lit(LocalDateTime.parse("2026-07-01T00:00:00")).cast(DataTypes.TIMESTAMP_LTZ(3));
+    }
+
+    static final TableTestProgram SNAPSHOT_INNER_JOIN =
+            TableTestProgram.of(
+                            "lateral-snapshot-inner-join-serialization",
+                            "verifies SQL serialization of a LATERAL SNAPSHOT 
inner join")
+                    .setupSql(SNAPSHOT_PROBE_DDL)
+                    .setupSql(SNAPSHOT_BUILD_DDL)
+                    .runTableApi(
+                            env ->
+                                    env.from("probe")
+                                            .joinLateral(
+                                                    call(
+                                                            "SNAPSHOT",
+                                                            
env.from("b").asArgument("input"),
+                                                            descriptor("bts")
+                                                                    
.asArgument("on_time")),
+                                                    $("pk").isEqual($("bk")))
+                                            .select($("pk"), $("pv"), $("bk"), 
$("bv")),
+                            "sink")
+                    .runSql(
+                            "SELECT `$$T_PROJECT`.`pk`, `$$T_PROJECT`.`pv`, 
`$$T_PROJECT`.`bk`, `$$T_PROJECT`.`bv` FROM (\n"
+                                    + "    SELECT `$$T1_JOIN`.`pk`, 
`$$T1_JOIN`.`pv`, `$$T1_JOIN`.`pts`, `$$T_LAT`.`bk`, `$$T_LAT`.`bv`, 
`$$T_LAT`.`bts` FROM (\n"
+                                    + "        SELECT `$$T_SOURCE`.`pk`, 
`$$T_SOURCE`.`pv`, `$$T_SOURCE`.`pts` FROM 
`default_catalog`.`default_database`.`probe` $$T_SOURCE\n"
+                                    + "    ) $$T1_JOIN INNER JOIN \n"
+                                    + "        LATERAL TABLE(SNAPSHOT((\n"
+                                    + "            SELECT `$$T_SOURCE`.`bk`, 
`$$T_SOURCE`.`bv`, `$$T_SOURCE`.`bts` FROM 
`default_catalog`.`default_database`.`b` $$T_SOURCE\n"
+                                    + "        ), DESCRIPTOR(`bts`), DEFAULT, 
DEFAULT, DEFAULT)) $$T_LAT(`bk`, `bv`, `bts`) ON `$$T1_JOIN`.`pk` = 
`$$T_LAT`.`bk`\n"
+                                    + ") $$T_PROJECT")
+                    .build();
+
+    static final TableTestProgram SNAPSHOT_LEFT_JOIN =
+            TableTestProgram.of(
+                            "lateral-snapshot-left-join-serialization",
+                            "verifies SQL serialization of a LATERAL SNAPSHOT 
left outer join with a "
+                                    + "filtered build side and all optional 
arguments passed as "
+                                    + "scrambled named arguments")
+                    .setupSql(SNAPSHOT_PROBE_DDL)
+                    .setupSql(SNAPSHOT_BUILD_DDL)
+                    .runTableApi(
+                            env ->
+                                    env.from("probe")
+                                            .leftOuterJoinLateral(
+                                                    call(
+                                                            "SNAPSHOT",
+                                                            
snapshotLoadCompletedTime()
+                                                                    
.asArgument(
+                                                                            
"load_completed_time"),
+                                                            
lit(Duration.ofDays(1))
+                                                                    
.asArgument("state_ttl"),
+                                                            
descriptor("bts").asArgument("on_time"),
+                                                            
lit(Duration.ofSeconds(10))
+                                                                    
.asArgument(
+                                                                            
"load_completed_idle_timeout"),
+                                                            env.from("b")
+                                                                    
.filter($("bv").isGreater(10))
+                                                                    
.asArgument("input")),
+                                                    $("pk").isEqual($("bk")))
+                                            .select($("pk"), $("pv"), $("bk"), 
$("bv")),
+                            "sink")
+                    .runSql(
+                            "SELECT `$$T_PROJECT`.`pk`, `$$T_PROJECT`.`pv`, 
`$$T_PROJECT`.`bk`, `$$T_PROJECT`.`bv` FROM (\n"
+                                    + "    SELECT `$$T1_JOIN`.`pk`, 
`$$T1_JOIN`.`pv`, `$$T1_JOIN`.`pts`, `$$T_LAT`.`bk`, `$$T_LAT`.`bv`, 
`$$T_LAT`.`bts` FROM (\n"
+                                    + "        SELECT `$$T_SOURCE`.`pk`, 
`$$T_SOURCE`.`pv`, `$$T_SOURCE`.`pts` FROM 
`default_catalog`.`default_database`.`probe` $$T_SOURCE\n"
+                                    + "    ) $$T1_JOIN LEFT OUTER JOIN \n"
+                                    + "        LATERAL TABLE(SNAPSHOT((\n"
+                                    + "            SELECT `$$T_FILTER`.`bk`, 
`$$T_FILTER`.`bv`, `$$T_FILTER`.`bts` FROM (\n"
+                                    + "                SELECT 
`$$T_SOURCE`.`bk`, `$$T_SOURCE`.`bv`, `$$T_SOURCE`.`bts` FROM 
`default_catalog`.`default_database`.`b` $$T_SOURCE\n"
+                                    + "            ) $$T_FILTER WHERE 
`$$T_FILTER`.`bv` > 10\n"
+                                    + "        ), DESCRIPTOR(`bts`), 
CAST(TIMESTAMP '2026-07-01 00:00:00' AS TIMESTAMP(3) WITH LOCAL TIME ZONE), 
INTERVAL '0 00:00:10' DAY(1) TO SECOND(0), INTERVAL '1 00:00:00' DAY(1) TO 
SECOND(0))) $$T_LAT(`bk`, `bv`, `bts`) ON `$$T1_JOIN`.`pk` = `$$T_LAT`.`bk`\n"
+                                    + ") $$T_PROJECT")
+                    .build();
 }
diff --git 
a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/nodes/exec/stream/LateralSnapshotJoinSemanticTestPrograms.java
 
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/nodes/exec/stream/LateralSnapshotJoinSemanticTestPrograms.java
index 275323610ec..0d951924c87 100644
--- 
a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/nodes/exec/stream/LateralSnapshotJoinSemanticTestPrograms.java
+++ 
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/nodes/exec/stream/LateralSnapshotJoinSemanticTestPrograms.java
@@ -18,6 +18,8 @@
 
 package org.apache.flink.table.planner.plan.nodes.exec.stream;
 
+import org.apache.flink.table.api.ApiExpression;
+import org.apache.flink.table.api.DataTypes;
 import org.apache.flink.table.test.program.SinkTestStep;
 import org.apache.flink.table.test.program.SourceTestStep;
 import org.apache.flink.table.test.program.TableTestProgram;
@@ -33,6 +35,11 @@ import java.util.Map;
 import java.util.stream.Collectors;
 import java.util.stream.IntStream;
 
+import static org.apache.flink.table.api.Expressions.$;
+import static org.apache.flink.table.api.Expressions.call;
+import static org.apache.flink.table.api.Expressions.descriptor;
+import static org.apache.flink.table.api.Expressions.lit;
+
 /**
  * Deterministic result {@link TableTestProgram} definitions for the {@code 
LATERAL SNAPSHOT}
  * processing-time temporal join ({@link StreamExecLateralSnapshotJoin}).
@@ -336,6 +343,42 @@ public class LateralSnapshotJoinSemanticTestPrograms {
                     .runSql(innerJoin("probe.pv, s.bv", MID_FLIP, "probe.pk = 
s.bk"))
                     .build();
 
+    // 
------------------------------------------------------------------------------------------
+    // Table API
+    //
+    // The Table API is a thin wrapper that converges on the same 
plan/operator as SQL (asserted by
+    // LateralSnapshotJoinTableApiTest), so a single execution smoke is enough 
here.
+    // 
------------------------------------------------------------------------------------------
+
+    public static final TableTestProgram INNER_JOIN_TABLE_API =
+            TableTestProgram.of(
+                            "lateral-snapshot-inner-join-table-api",
+                            "LATERAL SNAPSHOT inner join expressed through the 
Table API")
+                    .setupTableSource(throttledProbe(defaultProbe(), 40L))
+                    
.setupTableSource(appendBuild(withFlipTrigger(defaultBuild())))
+                    .setupTableSink(
+                            keyValueSink()
+                                    .consumedValues(
+                                            "+I[a, 100, a, 10]",
+                                            "+I[a, 100, a, 11]",
+                                            "+I[b, 200, b, 20]")
+                                    .build())
+                    .runTableApi(
+                            env ->
+                                    env.from("probe")
+                                            .joinLateral(
+                                                    call(
+                                                            "SNAPSHOT",
+                                                            
env.from("b").asArgument("input"),
+                                                            
descriptor("bts").asArgument("on_time"),
+                                                            
loadCompletedTime(FLIP_TRIGGER_TS)
+                                                                    
.asArgument(
+                                                                            
"load_completed_time")),
+                                                    $("pk").isEqual($("bk")))
+                                            .select($("pk"), $("pv"), $("bk"), 
$("bv")),
+                            "sink")
+                    .build();
+
     // 
------------------------------------------------------------------------------------------
     // Helpers
     // 
------------------------------------------------------------------------------------------
@@ -442,4 +485,13 @@ public class LateralSnapshotJoinSemanticTestPrograms {
     private static LocalDateTime ts(String time) {
         return LocalDateTime.parse("2020-01-01T" + time);
     }
+
+    /**
+     * Builds the {@code load_completed_time} argument as a Table API 
expression equivalent to the
+     * SQL {@code CAST(TIMESTAMP '2020-01-01 <time>' AS TIMESTAMP_LTZ(3))} 
used by the SQL programs
+     * for the same flip timestamp.
+     */
+    private static ApiExpression loadCompletedTime(String time) {
+        return lit(ts(time)).cast(DataTypes.TIMESTAMP_LTZ(3));
+    }
 }
diff --git 
a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/nodes/exec/stream/LateralSnapshotJoinSemanticTests.java
 
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/nodes/exec/stream/LateralSnapshotJoinSemanticTests.java
index 6536f34a591..5ab4917eaea 100644
--- 
a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/nodes/exec/stream/LateralSnapshotJoinSemanticTests.java
+++ 
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/nodes/exec/stream/LateralSnapshotJoinSemanticTests.java
@@ -38,6 +38,7 @@ public class LateralSnapshotJoinSemanticTests extends 
SemanticTestBase {
                 LateralSnapshotJoinSemanticTestPrograms.FLIP_AT_END,
                 LateralSnapshotJoinSemanticTestPrograms.DEFAULT_COMPILE_TIME,
                 LateralSnapshotJoinSemanticTestPrograms.LIVE_JOIN,
-                LateralSnapshotJoinSemanticTestPrograms.BUFFERED_THEN_DRAINED);
+                LateralSnapshotJoinSemanticTestPrograms.BUFFERED_THEN_DRAINED,
+                LateralSnapshotJoinSemanticTestPrograms.INNER_JOIN_TABLE_API);
     }
 }
diff --git 
a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/stream/sql/join/LateralSnapshotJoinTableApiTest.java
 
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/stream/sql/join/LateralSnapshotJoinTableApiTest.java
new file mode 100644
index 00000000000..544e21bf7e8
--- /dev/null
+++ 
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/stream/sql/join/LateralSnapshotJoinTableApiTest.java
@@ -0,0 +1,201 @@
+/*
+ * 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.join;
+
+import org.apache.flink.table.api.ApiExpression;
+import org.apache.flink.table.api.DataTypes;
+import org.apache.flink.table.api.Table;
+import org.apache.flink.table.api.TableConfig;
+import org.apache.flink.table.planner.utils.TableTestBase;
+import org.apache.flink.table.planner.utils.TableTestUtil;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.util.stream.Collectors;
+
+import static org.apache.flink.table.api.Expressions.$;
+import static org.apache.flink.table.api.Expressions.call;
+import static org.apache.flink.table.api.Expressions.descriptor;
+import static org.apache.flink.table.api.Expressions.lit;
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Verifies that the Table API surface of the {@code LATERAL SNAPSHOT} join 
produces the exact same
+ * optimized physical plan as the equivalent SQL.
+ *
+ * <p>This is the primary coverage for the Table API surface: it converges on 
the same {@code
+ * LateralSnapshotJoin} {@code RelNode} / {@code 
StreamExecLateralSnapshotJoin} operator as SQL,
+ * whose execution semantics are already fully covered by the SQL semantic 
tests ({@code
+ * LateralSnapshotJoinSemanticTests}) and {@code LateralSnapshotJoinITCase}. 
{@code
+ * LateralSnapshotJoinSemanticTestPrograms#INNER_JOIN_TABLE_API} keeps a 
single execution smoke on
+ * top of this.
+ */
+public class LateralSnapshotJoinTableApiTest extends TableTestBase {
+
+    private static final String LOAD_COMPLETED_TIME_SQL =
+            "CAST(TIMESTAMP '2026-07-01 00:00:00' AS TIMESTAMP_LTZ(3))";
+
+    private static final ApiExpression LOAD_COMPLETED_TIME_TABLE =
+            
lit(LocalDateTime.parse("2026-07-01T00:00:00")).cast(DataTypes.TIMESTAMP_LTZ(3));
+
+    private TableTestUtil util;
+
+    @BeforeEach
+    void setup() {
+        final TableConfig config = TableConfig.getDefault();
+        config.setLocalTimeZone(ZoneId.of("UTC"));
+        util = streamTestUtil(config);
+
+        util.tableEnv()
+                .executeSql(
+                        "CREATE TABLE probe ("
+                                + "  pk STRING,"
+                                + "  pv INT,"
+                                + "  pts TIMESTAMP(3),"
+                                + "  WATERMARK FOR pts AS pts"
+                                + ") WITH ('connector' = 'values', 'bounded' = 
'false')");
+
+        util.tableEnv()
+                .executeSql(
+                        "CREATE TABLE b ("
+                                + "  bk STRING,"
+                                + "  bv INT,"
+                                + "  bts TIMESTAMP(3),"
+                                + "  WATERMARK FOR bts AS bts"
+                                + ") WITH ("
+                                + "  'connector' = 'values',"
+                                + "  'bounded' = 'false',"
+                                + "  'changelog-mode' = 'I,UB,UA,D'"
+                                + ")");
+    }
+
+    // 
------------------------------------------------------------------------------------------
+    // Plan equivalence: the Table API surface should produce the same 
optimized plan as SQL.
+    // 
------------------------------------------------------------------------------------------
+
+    @Test
+    void testInnerJoinPlanParity() {
+        final String sql =
+                "SELECT probe.pk, probe.pv, s.bk, s.bv FROM probe JOIN LATERAL 
SNAPSHOT("
+                        + "input => TABLE b, on_time => DESCRIPTOR(bts), "
+                        + "load_completed_time => "
+                        + LOAD_COMPLETED_TIME_SQL
+                        + ") AS s "
+                        + "ON probe.pk = s.bk";
+
+        final Table apiResult =
+                util.tableEnv()
+                        .from("probe")
+                        .joinLateral(
+                                call(
+                                        "SNAPSHOT",
+                                        
util.tableEnv().from("b").asArgument("input"),
+                                        
descriptor("bts").asArgument("on_time"),
+                                        LOAD_COMPLETED_TIME_TABLE.asArgument(
+                                                "load_completed_time")),
+                                $("pk").isEqual($("bk")))
+                        .select($("pk"), $("pv"), $("bk"), $("bv"));
+
+        assertSameOptimizedPhysicalPlan(sql, apiResult);
+    }
+
+    @Test
+    void testLeftOuterJoinPlanParity() {
+        final String sql =
+                "SELECT probe.pk, probe.pv, s.bk, s.bv FROM probe LEFT JOIN 
LATERAL SNAPSHOT("
+                        + "input => TABLE b, on_time => DESCRIPTOR(bts), "
+                        + "load_completed_time => "
+                        + LOAD_COMPLETED_TIME_SQL
+                        + ") AS s "
+                        + "ON probe.pk = s.bk";
+
+        final Table apiResult =
+                util.tableEnv()
+                        .from("probe")
+                        .leftOuterJoinLateral(
+                                call(
+                                        "SNAPSHOT",
+                                        
util.tableEnv().from("b").asArgument("input"),
+                                        
descriptor("bts").asArgument("on_time"),
+                                        LOAD_COMPLETED_TIME_TABLE.asArgument(
+                                                "load_completed_time")),
+                                $("pk").isEqual($("bk")))
+                        .select($("pk"), $("pv"), $("bk"), $("bv"));
+
+        assertSameOptimizedPhysicalPlan(sql, apiResult);
+    }
+
+    @Test
+    void testAliasedColumnsPlanParity() {
+        // No narrowing projection on top: SELECT * keeps the aliased build 
columns, so this
+        // asserts both plan parity and that the Table API and SQL agree on 
the (aliased) schema.
+        final String sql =
+                "SELECT * FROM probe JOIN LATERAL SNAPSHOT("
+                        + "input => TABLE b, on_time => DESCRIPTOR(bts), "
+                        + "load_completed_time => "
+                        + LOAD_COMPLETED_TIME_SQL
+                        + ") AS s(k, v, t) "
+                        + "ON probe.pk = s.k";
+
+        final Table apiResult =
+                util.tableEnv()
+                        .from("probe")
+                        .joinLateral(
+                                call(
+                                                "SNAPSHOT",
+                                                
util.tableEnv().from("b").asArgument("input"),
+                                                
descriptor("bts").asArgument("on_time"),
+                                                
LOAD_COMPLETED_TIME_TABLE.asArgument(
+                                                        "load_completed_time"))
+                                        .as("k", "v", "t"),
+                                $("pk").isEqual($("k")));
+
+        assertSameOptimizedPhysicalPlan(sql, apiResult);
+        assertThat(apiResult.getResolvedSchema())
+                .isEqualTo(util.tableEnv().sqlQuery(sql).getResolvedSchema());
+    }
+
+    // 
------------------------------------------------------------------------------------------
+    // Helpers
+    // 
------------------------------------------------------------------------------------------
+
+    private void assertSameOptimizedPhysicalPlan(String sql, Table apiResult) {
+        final String sqlPlan = 
extractOptimizedPhysicalPlan(util.tableEnv().explainSql(sql));
+        final String apiPlan = 
extractOptimizedPhysicalPlan(apiResult.explain());
+        assertThat(apiPlan).isEqualTo(sqlPlan);
+    }
+
+    /** Extracts the full "Optimized Physical Plan" from an {@code EXPLAIN} 
output. */
+    private static String extractOptimizedPhysicalPlan(String plan) {
+        final String startMarker = "== Optimized Physical Plan ==";
+        final int start = plan.indexOf(startMarker);
+        if (start < 0) {
+            throw new AssertionError(
+                    "No 'Optimized Physical Plan' section found in plan:\n" + 
plan);
+        }
+        final int contentStart = start + startMarker.length();
+        final int end = plan.indexOf("== Optimized Execution Plan ==", 
contentStart);
+        final String section =
+                end >= 0 ? plan.substring(contentStart, end) : 
plan.substring(contentStart);
+        return 
section.lines().map(String::stripTrailing).collect(Collectors.joining("\n")).strip();
+    }
+}

Reply via email to