This is an automated email from the ASF dual-hosted git repository.
twalthr 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 9c674275ca2 [FLINK-40541][table] Add on_time argument to SNAPSHOT for
LATERAL SNAPSHOT join
9c674275ca2 is described below
commit 9c674275ca28acc05b9fc5755634ef8c39335c4f
Author: Fabian Hueske <[email protected]>
AuthorDate: Mon Sep 7 09:13:17 2026 +0200
[FLINK-40541][table] Add on_time argument to SNAPSHOT for LATERAL SNAPSHOT
join
This closes #29098.
Co-Generated: Claude Opus 4.8 (1M context)
---
.../content.zh/docs/sql/reference/queries/joins.md | 10 +-
docs/content/docs/sql/reference/queries/joins.md | 22 +--
.../resolver/rules/ResolveCallByArgumentsRule.java | 5 +-
.../functions/BuiltInFunctionDefinitions.java | 1 +
.../table/types/inference/SystemTypeInference.java | 22 ++-
.../strategies/LateralSnapshotTypeStrategy.java | 70 ++++++++-
.../LateralSnapshotInputTypeStrategyTest.java | 120 ++++++++++++---
.../planner/calcite/FlinkCalciteSqlValidator.java | 18 ++-
.../planner/calcite/RelTimeIndicatorConverter.java | 1 +
.../logical/FlinkLogicalLateralSnapshotJoin.java | 11 ++
.../stream/StreamPhysicalLateralSnapshotJoin.java | 25 ++-
.../LogicalJoinToLateralSnapshotJoinRule.java | 148 ++++++++++--------
.../StreamPhysicalLateralSnapshotJoinRule.java | 1 +
.../LateralSnapshotJoinSemanticTestPrograms.java | 8 +-
.../stream/LateralSnapshotJoinTestPrograms.java | 2 +-
.../plan/stream/sql/ColumnExpansionTest.java | 90 +++++++++++
.../plan/stream/sql/SnapshotTableFunctionTest.java | 17 ++-
.../stream/sql/join/LateralSnapshotJoinTest.java | 169 ++++++++++++++++-----
.../stream/sql/join/LateralSnapshotJoinITCase.java | 6 +-
.../batch/sql/join/LateralSnapshotJoinTest.xml | 14 +-
.../stream/sql/join/LateralSnapshotJoinTest.xml | 40 ++---
21 files changed, 598 insertions(+), 202 deletions(-)
diff --git a/docs/content.zh/docs/sql/reference/queries/joins.md
b/docs/content.zh/docs/sql/reference/queries/joins.md
index 2aad89d7588..b902a9a9929 100644
--- a/docs/content.zh/docs/sql/reference/queries/joins.md
+++ b/docs/content.zh/docs/sql/reference/queries/joins.md
@@ -323,7 +323,7 @@ For example, the following query enriches an append-only
stream of `orders` (the
SELECT o.order_id, o.currency, o.amount, r.rate
FROM orders AS o
-JOIN LATERAL SNAPSHOT(input => TABLE currency_rates) AS r
+JOIN LATERAL SNAPSHOT(input => TABLE currency_rates, on_time =>
DESCRIPTOR(update_time)) AS r
ON o.currency = r.currency;
order_id currency amount rate
@@ -368,6 +368,7 @@ SELECT [column_list]
FROM probe_table
[LEFT] JOIN LATERAL SNAPSHOT(
input => TABLE build_table,
+ [ on_time => DESCRIPTOR(<rowtime_column>), ]
[ load_completed_condition => <'compile_time' | 'user_time'>, ]
[ load_completed_time => <timestamp_ltz>, ]
[ load_completed_idle_timeout => <interval>, ]
@@ -379,13 +380,14 @@ The `SNAPSHOT` function accepts the following arguments:
| Argument | Type | Required | Description
|
| --- | --- | ---
|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
-| `input` | TABLE | yes | The build-side table. It may use any changelog mode
(inserts, updates, and deletes). In streaming mode it must declare a
[watermark]({{< ref "docs/concepts/sql-table-concepts/time_attributes"
>}}#event-time).
|
+| `input` | TABLE | yes | The build-side table. It may use any changelog mode
(inserts, updates, and deletes).
|
+| `on_time` | DESCRIPTOR | no | Declares a build-side rowtime column that
defines the order in which the build-side changes are applied. The referenced
column must exist in `input` and be a `TIMESTAMP` or `TIMESTAMP_LTZ` column (up
to precision 3) that is declared as a [watermarked rowtime attribute]({{< ref
"docs/concepts/sql-table-concepts/time_attributes" >}}#event-time). The
argument is **required for streaming queries**. |
| `load_completed_condition` | STRING | no | Determines when the initial load
phase completes. One of `'compile_time'` (default) or `'user_time'`. With
`'compile_time'`, the load phase completes once the build-side watermark
reaches the wall-clock time at which the query was compiled. With
`'user_time'`, it completes once the build-side watermark reaches the explicit
`load_completed_time`. |
| `load_completed_time` | TIMESTAMP_LTZ(3) | no | The build-side event time
that completes the load phase. Required when `load_completed_condition` is
`'user_time'` and must not be set otherwise.
|
| `load_completed_idle_timeout` | INTERVAL | no | A processing-time fallback
to complete the load phase. The transition to the join phase happens when the
build-side watermark does not advance for more than the configured interval.
|
| `state_ttl` | INTERVAL | no | Retention time for build-side state. Join keys
that are not accessed within this duration become eligible for eviction. Only
applied during the join phase. Defaults to the pipeline's [state TTL]({{< ref
"docs/dev/table/config" >}}#table-exec-state-ttl).
|
-`load_completed_condition`, `load_completed_time`,
`load_completed_idle_timeout`, and `state_ttl` only affect streaming execution
and are ignored in batch mode (see **Batch mode** below).
+`on_time`, `load_completed_condition`, `load_completed_time`,
`load_completed_idle_timeout`, and `state_ttl` only affect streaming execution
and are ignored in batch mode (see **Batch mode** below).
**Result and state characteristics**
@@ -397,7 +399,7 @@ The build-side state grows with the number of distinct
build-side keys, and duri
**Batch mode**
-In batch mode, a `LATERAL SNAPSHOT` join is executed as a regular (`INNER` or
`LEFT`) join between the probe side and the complete build side. Batch
execution reads the entire build side before joining, so there is no load phase
and no incremental state build-up. The streaming-specific arguments
(`load_completed_condition`, `load_completed_time`,
`load_completed_idle_timeout`, and `state_ttl`) are accepted but have no
effect, and the build side does not need to declare a watermark.
+In batch mode, a `LATERAL SNAPSHOT` join is executed as a regular (`INNER` or
`LEFT`) join between the probe side and the complete build side. Batch
execution reads the entire build side before joining, so there is no load phase
and no incremental state build-up. The streaming-specific arguments (`on_time`,
`load_completed_condition`, `load_completed_time`,
`load_completed_idle_timeout`, and `state_ttl`) are accepted but have no
effect, and the build side does not need to declare a water [...]
Because every probe-side row is joined against the final, complete build side,
the batch result is **deterministic**.
diff --git a/docs/content/docs/sql/reference/queries/joins.md
b/docs/content/docs/sql/reference/queries/joins.md
index c5a5123d96a..5a0f6f4a9dc 100644
--- a/docs/content/docs/sql/reference/queries/joins.md
+++ b/docs/content/docs/sql/reference/queries/joins.md
@@ -328,7 +328,7 @@ For example, the following query enriches an append-only
stream of `orders` (the
SELECT o.order_id, o.currency, o.amount, r.rate
FROM orders AS o
-JOIN LATERAL SNAPSHOT(input => TABLE currency_rates) AS r
+JOIN LATERAL SNAPSHOT(input => TABLE currency_rates, on_time =>
DESCRIPTOR(update_time)) AS r
ON o.currency = r.currency;
order_id currency amount rate
@@ -377,6 +377,7 @@ SELECT [column_list]
FROM probe_table
[LEFT] JOIN LATERAL SNAPSHOT(
input => TABLE build_table,
+ [ on_time => DESCRIPTOR(<rowtime_column>), ]
[ load_completed_condition => <'compile_time' | 'user_time'>, ]
[ load_completed_time => <timestamp_ltz>, ]
[ load_completed_idle_timeout => <interval>, ]
@@ -386,15 +387,16 @@ ON probe_table.col = s.col
The `SNAPSHOT` function accepts the following arguments:
-| Argument | Type | Required | Description
|
-| --- | --- | ---
|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
-| `input` | TABLE | yes | The build-side table. It may use any [changelog
mode]({{< ref "docs/sql/reference/queries/changelog" >}}) (inserts, updates,
and deletes). In streaming mode it must declare a [watermark]({{< ref
"docs/concepts/sql-table-concepts/time_attributes" >}}#event-time).
|
-| `load_completed_condition` | STRING | no | Determines when the initial load
phase completes. One of `'compile_time'` (default) or `'user_time'`. With
`'compile_time'`, the load phase completes once the build-side watermark
reaches the wall-clock time at which the query was compiled. With
`'user_time'`, it completes once the build-side watermark reaches the explicit
`load_completed_time`. |
-| `load_completed_time` | TIMESTAMP_LTZ(3) | no | The build-side event time
that completes the load phase. Required when `load_completed_condition` is
`'user_time'` and must not be set otherwise.
|
-| `load_completed_idle_timeout` | INTERVAL | no | A processing-time fallback
to complete the load phase. The transition to the join phase happens when the
build-side watermark does not advance for more than the configured interval.
|
-| `state_ttl` | INTERVAL | no | Retention time for build-side state. Join keys
that are not accessed within this duration become eligible for eviction. Only
applied during the join phase. Defaults to the pipeline's [state TTL]({{< ref
"docs/dev/table/config" >}}#table-exec-state-ttl).
|
+| Argument | Type | Required | Description
|
+| --- | --- | ---
|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| `input` | TABLE | yes | The build-side table. It may use any [changelog
mode]({{< ref "docs/sql/reference/queries/changelog" >}}) (inserts, updates,
and deletes).
|
+| `on_time` | DESCRIPTOR | no | Declares a build-side rowtime column that
defines the order in which the build-side changes are applied. The referenced
column must exist in `input` and be a `TIMESTAMP` or `TIMESTAMP_LTZ` column (up
to precision 3) that is declared as a [watermarked rowtime attribute]({{< ref
"docs/concepts/sql-table-concepts/time_attributes" >}}#event-time). The
argument is **required for streaming queries**. |
+| `load_completed_condition` | STRING | no | Determines when the initial load
phase completes. One of `'compile_time'` (default) or `'user_time'`. With
`'compile_time'`, the load phase completes once the build-side watermark
reaches the wall-clock time at which the query was compiled. With
`'user_time'`, it completes once the build-side watermark reaches the explicit
`load_completed_time`.
|
+| `load_completed_time` | TIMESTAMP_LTZ(3) | no | The build-side event time
that completes the load phase. Required when `load_completed_condition` is
`'user_time'` and must not be set otherwise.
|
+| `load_completed_idle_timeout` | INTERVAL | no | A processing-time fallback
to complete the load phase. The transition to the join phase happens when the
build-side watermark does not advance for more than the configured interval.
|
+| `state_ttl` | INTERVAL | no | Retention time for build-side state. Join keys
that are not accessed within this duration become eligible for eviction. Only
applied during the join phase. Defaults to the pipeline's [state TTL]({{< ref
"docs/dev/table/config" >}}#table-exec-state-ttl).
|
-`load_completed_condition`, `load_completed_time`,
`load_completed_idle_timeout`, and `state_ttl` only affect streaming execution
and are ignored in batch mode (see **Batch mode** below).
+`on_time`, `load_completed_condition`, `load_completed_time`,
`load_completed_idle_timeout`, and `state_ttl` only affect streaming execution
and are ignored in batch mode (see **Batch mode** below).
**Result and state characteristics**
@@ -406,7 +408,7 @@ The build-side state grows with the number of distinct
build-side keys, and duri
**Batch mode**
-In batch mode, a `LATERAL SNAPSHOT` join is executed as a regular (`INNER` or
`LEFT`) join between the probe side and the complete build side. Batch
execution reads the entire build side before joining, so there is no load phase
and no incremental state build-up. The streaming-specific arguments
(`load_completed_condition`, `load_completed_time`,
`load_completed_idle_timeout`, and `state_ttl`) are accepted but have no
effect, and the build side does not need to declare a watermark.
+In batch mode, a `LATERAL SNAPSHOT` join is executed as a regular (`INNER` or
`LEFT`) join between the probe side and the complete build side. Batch
execution reads the entire build side before joining, so there is no load phase
and no incremental state build-up. The streaming-specific arguments (`on_time`,
`load_completed_condition`, `load_completed_time`,
`load_completed_idle_timeout`, and `state_ttl`) are accepted but have no
effect, and the build side does not need to declare a water [...]
Because every probe-side row is joined against the final, complete build side,
the batch result is **deterministic**.
diff --git
a/flink-table/flink-table-api-java/src/main/java/org/apache/flink/table/expressions/resolver/rules/ResolveCallByArgumentsRule.java
b/flink-table/flink-table-api-java/src/main/java/org/apache/flink/table/expressions/resolver/rules/ResolveCallByArgumentsRule.java
index 7364536099a..9f28484f585 100644
---
a/flink-table/flink-table-api-java/src/main/java/org/apache/flink/table/expressions/resolver/rules/ResolveCallByArgumentsRule.java
+++
b/flink-table/flink-table-api-java/src/main/java/org/apache/flink/table/expressions/resolver/rules/ResolveCallByArgumentsRule.java
@@ -300,7 +300,10 @@ final class ResolveCallByArgumentsRule implements
ResolverRule {
}
SystemTypeInference.checkNoSystemArguments(
- inference.disableSystemArguments(), namedArgs.keySet(),
functionName);
+ inference.disableSystemArguments(),
+ namedArgs.keySet(),
+
declaredArgs.stream().map(StaticArgument::getName).collect(Collectors.toList()),
+ functionName);
fillInDefaultNamedArguments(declaredArgs, namedArgs);
fillInPtfSpecificNamedArguments(
diff --git
a/flink-table/flink-table-common/src/main/java/org/apache/flink/table/functions/BuiltInFunctionDefinitions.java
b/flink-table/flink-table-common/src/main/java/org/apache/flink/table/functions/BuiltInFunctionDefinitions.java
index a2a0cb5547f..fdfa00390d6 100644
---
a/flink-table/flink-table-common/src/main/java/org/apache/flink/table/functions/BuiltInFunctionDefinitions.java
+++
b/flink-table/flink-table-common/src/main/java/org/apache/flink/table/functions/BuiltInFunctionDefinitions.java
@@ -946,6 +946,7 @@ public final class BuiltInFunctionDefinitions {
StaticArgumentTrait.SUPPORT_UPDATES,
StaticArgumentTrait.REQUIRE_UPDATE_BEFORE,
StaticArgumentTrait.REQUIRE_FULL_DELETE)),
+ StaticArgument.scalar("on_time",
DataTypes.DESCRIPTOR(), true),
StaticArgument.scalar(
"load_completed_condition",
DataTypes.STRING(), true),
StaticArgument.scalar(
diff --git
a/flink-table/flink-table-common/src/main/java/org/apache/flink/table/types/inference/SystemTypeInference.java
b/flink-table/flink-table-common/src/main/java/org/apache/flink/table/types/inference/SystemTypeInference.java
index a1f9fbc5a9f..a4eadc77752 100644
---
a/flink-table/flink-table-common/src/main/java/org/apache/flink/table/types/inference/SystemTypeInference.java
+++
b/flink-table/flink-table-common/src/main/java/org/apache/flink/table/types/inference/SystemTypeInference.java
@@ -139,17 +139,25 @@ public class SystemTypeInference {
public static void checkNoSystemArguments(
boolean sysArgsDisabled,
Collection<String> suppliedArgumentNames,
+ Collection<String> declaredArgumentNames,
String functionName) {
if (!sysArgsDisabled) {
return;
}
for (StaticArgument systemArg : PROCESS_TABLE_FUNCTION_SYSTEM_ARGS) {
- if (suppliedArgumentNames.contains(systemArg.getName())) {
+ final String systemArgName = systemArg.getName();
+ // A function that disabled the automatic system arguments may
still declare an argument
+ // with a reserved name. A supplied argument that matches such a
declared name is
+ // handled as an ordinary argument, not a system argument.
+ if (declaredArgumentNames.contains(systemArgName)) {
+ continue;
+ }
+ if (suppliedArgumentNames.contains(systemArgName)) {
throw new ValidationException(
String.format(
"Invalid function call. The '%s' argument is
not supported "
+ "because function '%s' does not use
system arguments.",
- systemArg.getName(), functionName));
+ systemArgName, functionName));
}
}
}
@@ -184,7 +192,9 @@ public class SystemTypeInference {
"Function requires a static signature that is not
overloaded and doesn't contain varargs.");
}
- checkReservedArgs(declaredArgs);
+ if (!disableSystemArgs) {
+ checkReservedArgs(declaredArgs);
+ }
checkMultipleTableArgs(declaredArgs);
checkPassThroughColumns(declaredArgs);
@@ -804,7 +814,11 @@ public class SystemTypeInference {
}
}
- private static boolean isUnsupportedOnTimeColumn(LogicalType type) {
+ /**
+ * Checks whether a column referenced by an {@code on_time} argument has
an unsupported data
+ * type. A supported column is a TIMESTAMP or TIMESTAMP_LTZ column up to
precision 3.
+ */
+ public static boolean isUnsupportedOnTimeColumn(LogicalType type) {
return !LogicalTypeChecks.canBeTimeAttributeType(type)
|| LogicalTypeChecks.getPrecision(type) > 3;
}
diff --git
a/flink-table/flink-table-common/src/main/java/org/apache/flink/table/types/inference/strategies/LateralSnapshotTypeStrategy.java
b/flink-table/flink-table-common/src/main/java/org/apache/flink/table/types/inference/strategies/LateralSnapshotTypeStrategy.java
index 49bead4730d..24c22c85d27 100644
---
a/flink-table/flink-table-common/src/main/java/org/apache/flink/table/types/inference/strategies/LateralSnapshotTypeStrategy.java
+++
b/flink-table/flink-table-common/src/main/java/org/apache/flink/table/types/inference/strategies/LateralSnapshotTypeStrategy.java
@@ -31,8 +31,11 @@ import
org.apache.flink.table.types.inference.ConstantArgumentCount;
import org.apache.flink.table.types.inference.InputTypeStrategy;
import org.apache.flink.table.types.inference.Signature;
import org.apache.flink.table.types.inference.Signature.Argument;
+import org.apache.flink.table.types.inference.SystemTypeInference;
import org.apache.flink.table.types.inference.TypeStrategy;
+import org.apache.flink.table.types.logical.LogicalType;
import org.apache.flink.table.types.utils.DataTypeUtils;
+import org.apache.flink.types.ColumnList;
import java.util.List;
import java.util.Optional;
@@ -48,6 +51,7 @@ import java.util.stream.IntStream;
*
* <ul>
* <li>{@code input} (TABLE, required)
+ * <li>{@code on_time} (DESCRIPTOR, optional; required for streaming,
enforced by the rule)
* <li>{@code load_completed_condition} (STRING literal, optional, default
{@code 'compile_time'},
* allowed values: {@code 'compile_time'}, {@code 'user_time'})
* <li>{@code load_completed_time} (TIMESTAMP_LTZ(3), optional)
@@ -74,23 +78,28 @@ public final class LateralSnapshotTypeStrategy {
public static final String INPUT_ARG_NAME = "input";
+ /** The {@code on_time} DESCRIPTOR argument naming the build-side row-time
column. */
+ public static final int ON_TIME_ARG_INDEX = 1;
+
+ public static final String ON_TIME_ARG_NAME = "on_time";
+
/** The {@code load_completed_condition} STRING argument. */
- public static final int LOAD_COMPLETED_CONDITION_ARG_INDEX = 1;
+ public static final int LOAD_COMPLETED_CONDITION_ARG_INDEX = 2;
public static final String LOAD_COMPLETED_CONDITION_ARG_NAME =
"load_completed_condition";
/** The {@code load_completed_time} TIMESTAMP_LTZ argument. */
- public static final int LOAD_COMPLETED_TIME_ARG_INDEX = 2;
+ public static final int LOAD_COMPLETED_TIME_ARG_INDEX = 3;
public static final String LOAD_COMPLETED_TIME_ARG_NAME =
"load_completed_time";
/** The {@code load_completed_idle_timeout} INTERVAL argument. */
- public static final int LOAD_COMPLETED_IDLE_TIMEOUT_ARG_INDEX = 3;
+ public static final int LOAD_COMPLETED_IDLE_TIMEOUT_ARG_INDEX = 4;
public static final String LOAD_COMPLETED_IDLE_TIMEOUT_ARG_NAME =
"load_completed_idle_timeout";
/** The {@code state_ttl} INTERVAL argument. */
- public static final int STATE_TTL_ARG_INDEX = 4;
+ public static final int STATE_TTL_ARG_INDEX = 5;
public static final String STATE_TTL_ARG_NAME = "state_ttl";
@@ -119,7 +128,7 @@ public final class LateralSnapshotTypeStrategy {
new InputTypeStrategy() {
@Override
public ArgumentCount getArgumentCount() {
- return ConstantArgumentCount.between(1, 5);
+ return ConstantArgumentCount.between(1, 6);
}
@Override
@@ -133,6 +142,7 @@ public final class LateralSnapshotTypeStrategy {
return List.of(
Signature.of(
Argument.of(INPUT_ARG_NAME, "TABLE"),
+ Argument.of(ON_TIME_ARG_NAME,
"DESCRIPTOR"),
Argument.of(LOAD_COMPLETED_CONDITION_ARG_NAME, "STRING"),
Argument.of(LOAD_COMPLETED_TIME_ARG_NAME,
"TIMESTAMP_LTZ(3)"),
Argument.of(
@@ -188,6 +198,13 @@ public final class LateralSnapshotTypeStrategy {
throwOnFailure, "Argument 'input' of SNAPSHOT must be a
table.");
}
+ // Validate on_time if provided. Presence is enforced by the planner
rule for streaming.
+ final Optional<List<DataType>> timeColumnFailure =
+ validateOnTime(callContext, throwOnFailure);
+ if (timeColumnFailure != null) {
+ return timeColumnFailure;
+ }
+
// Reject non-literal load_completed_condition explicitly: the planner
needs the value
// at compile time to decide between 'compile_time' and 'user_time'.
final boolean hasLoadCompletedCondition =
@@ -233,6 +250,49 @@ public final class LateralSnapshotTypeStrategy {
return Optional.of(callContext.getArgumentDataTypes());
}
+ /**
+ * Validates {@code on_time} when present: it must name exactly one
existing TIMESTAMP or
+ * TIMESTAMP_LTZ column (precision up to 3). Returns {@code null} when the
argument is absent or
+ * valid; otherwise returns the failure result of {@link CallContext#fail}.
+ */
+ private static Optional<List<DataType>> validateOnTime(
+ final CallContext callContext, final boolean throwOnFailure) {
+ if (!isArgumentProvided(callContext, ON_TIME_ARG_INDEX)) {
+ return null;
+ }
+ final List<String> columns =
+ callContext
+ .getArgumentValue(ON_TIME_ARG_INDEX, ColumnList.class)
+ .map(ColumnList::getNames)
+ .orElse(List.of());
+ if (columns.size() != 1) {
+ return callContext.fail(
+ throwOnFailure,
+ "Argument 'on_time' of SNAPSHOT must reference exactly one
column.");
+ }
+ final String timeColumn = columns.get(0);
+ final DataType inputType =
callContext.getArgumentDataTypes().get(INPUT_ARG_INDEX);
+ final int idx = DataType.getFieldNames(inputType).indexOf(timeColumn);
+ if (idx < 0) {
+ return callContext.fail(
+ throwOnFailure,
+ "Argument 'on_time' of SNAPSHOT references column '%s'
which is not present "
+ + "in the input table.",
+ timeColumn);
+ }
+ final LogicalType columnType =
+
DataType.getFieldDataTypes(inputType).get(idx).getLogicalType();
+ if (SystemTypeInference.isUnsupportedOnTimeColumn(columnType)) {
+ return callContext.fail(
+ throwOnFailure,
+ "Argument 'on_time' of SNAPSHOT must reference a TIMESTAMP
or TIMESTAMP_LTZ "
+ + "column (up to precision 3), but column '%s' has
type '%s'.",
+ timeColumn,
+ columnType.asSummaryString());
+ }
+ return null;
+ }
+
private static boolean isArgumentProvided(final CallContext callContext,
final int index) {
return callContext.getArgumentDataTypes().size() > index
&& !callContext.isArgumentNull(index);
diff --git
a/flink-table/flink-table-common/src/test/java/org/apache/flink/table/types/inference/strategies/LateralSnapshotInputTypeStrategyTest.java
b/flink-table/flink-table-common/src/test/java/org/apache/flink/table/types/inference/strategies/LateralSnapshotInputTypeStrategyTest.java
index 05012a3b277..23e132c0c93 100644
---
a/flink-table/flink-table-common/src/test/java/org/apache/flink/table/types/inference/strategies/LateralSnapshotInputTypeStrategyTest.java
+++
b/flink-table/flink-table-common/src/test/java/org/apache/flink/table/types/inference/strategies/LateralSnapshotInputTypeStrategyTest.java
@@ -22,6 +22,7 @@ import org.apache.flink.table.api.DataTypes;
import org.apache.flink.table.types.DataType;
import org.apache.flink.table.types.inference.InputTypeStrategiesTestBase;
import org.apache.flink.table.types.inference.utils.TableSemanticsMock;
+import org.apache.flink.types.ColumnList;
import java.time.Duration;
import java.time.LocalDateTime;
@@ -41,17 +42,22 @@ class LateralSnapshotInputTypeStrategyTest extends
InputTypeStrategiesTestBase {
private static final DataType TABLE_TYPE =
DataTypes.ROW(
DataTypes.FIELD("k", DataTypes.STRING()),
- DataTypes.FIELD("v", DataTypes.INT()));
+ DataTypes.FIELD("v", DataTypes.INT()),
+ DataTypes.FIELD("ts", DataTypes.TIMESTAMP(3)));
private static final DataType STRING_TYPE = DataTypes.STRING();
+ private static final DataType DESCRIPTOR_TYPE = DataTypes.DESCRIPTOR();
private static final DataType TIMESTAMP_TYPE = DataTypes.TIMESTAMP(3);
private static final DataType INTERVAL_TYPE =
DataTypes.INTERVAL(DataTypes.SECOND());
+ private static final ColumnList ON_TIME = ColumnList.of("ts");
+
@Override
protected Stream<TestSpec> testData() {
return Stream.of(
//
----------------------------------------------------------------------------
- // Valid: just the build-side table.
+ // Valid: just the build-side table (on_time is optional at
this layer; the
+ // planner rule enforces it for streaming).
//
----------------------------------------------------------------------------
TestSpec.forStrategy(
"Valid: input only (default condition)",
@@ -60,16 +66,26 @@ class LateralSnapshotInputTypeStrategyTest extends
InputTypeStrategiesTestBase {
.calledWithTableSemanticsAt(0, new
TableSemanticsMock(TABLE_TYPE))
.expectArgumentTypes(TABLE_TYPE),
+ //
----------------------------------------------------------------------------
+ // Valid: input + on_time.
+ //
----------------------------------------------------------------------------
+ TestSpec.forStrategy("Valid: input + on_time",
LATERAL_SNAPSHOT_INPUT_TYPE_STRATEGY)
+ .calledWithArgumentTypes(TABLE_TYPE, DESCRIPTOR_TYPE)
+ .calledWithTableSemanticsAt(0, new
TableSemanticsMock(TABLE_TYPE))
+ .calledWithLiteralAt(1, ON_TIME)
+ .expectArgumentTypes(TABLE_TYPE, DESCRIPTOR_TYPE),
+
//
----------------------------------------------------------------------------
// Valid: explicit 'compile_time' condition without
load_completed_time.
//
----------------------------------------------------------------------------
TestSpec.forStrategy(
"Valid: condition='compile_time'",
LATERAL_SNAPSHOT_INPUT_TYPE_STRATEGY)
- .calledWithArgumentTypes(TABLE_TYPE, STRING_TYPE)
+ .calledWithArgumentTypes(TABLE_TYPE, DESCRIPTOR_TYPE,
STRING_TYPE)
.calledWithTableSemanticsAt(0, new
TableSemanticsMock(TABLE_TYPE))
- .calledWithLiteralAt(1, "compile_time")
- .expectArgumentTypes(TABLE_TYPE, STRING_TYPE),
+ .calledWithLiteralAt(1, ON_TIME)
+ .calledWithLiteralAt(2, "compile_time")
+ .expectArgumentTypes(TABLE_TYPE, DESCRIPTOR_TYPE,
STRING_TYPE),
//
----------------------------------------------------------------------------
// Valid: 'user_time' with a TIMESTAMP literal.
@@ -77,11 +93,14 @@ class LateralSnapshotInputTypeStrategyTest extends
InputTypeStrategiesTestBase {
TestSpec.forStrategy(
"Valid: condition='user_time' +
load_completed_time",
LATERAL_SNAPSHOT_INPUT_TYPE_STRATEGY)
- .calledWithArgumentTypes(TABLE_TYPE, STRING_TYPE,
TIMESTAMP_TYPE)
+ .calledWithArgumentTypes(
+ TABLE_TYPE, DESCRIPTOR_TYPE, STRING_TYPE,
TIMESTAMP_TYPE)
.calledWithTableSemanticsAt(0, new
TableSemanticsMock(TABLE_TYPE))
- .calledWithLiteralAt(1, "user_time")
- .calledWithLiteralAt(2,
LocalDateTime.parse("2026-07-01T00:00:00.001"))
- .expectArgumentTypes(TABLE_TYPE, STRING_TYPE,
TIMESTAMP_TYPE),
+ .calledWithLiteralAt(1, ON_TIME)
+ .calledWithLiteralAt(2, "user_time")
+ .calledWithLiteralAt(3,
LocalDateTime.parse("2026-07-01T00:00:00.001"))
+ .expectArgumentTypes(
+ TABLE_TYPE, DESCRIPTOR_TYPE, STRING_TYPE,
TIMESTAMP_TYPE),
//
----------------------------------------------------------------------------
// Valid: full named-arg form with idle timeout and TTL.
@@ -89,17 +108,20 @@ class LateralSnapshotInputTypeStrategyTest extends
InputTypeStrategiesTestBase {
TestSpec.forStrategy("Valid: full args",
LATERAL_SNAPSHOT_INPUT_TYPE_STRATEGY)
.calledWithArgumentTypes(
TABLE_TYPE,
+ DESCRIPTOR_TYPE,
STRING_TYPE,
TIMESTAMP_TYPE,
INTERVAL_TYPE,
INTERVAL_TYPE)
.calledWithTableSemanticsAt(0, new
TableSemanticsMock(TABLE_TYPE))
- .calledWithLiteralAt(1, "user_time")
- .calledWithLiteralAt(2,
LocalDateTime.parse("2026-07-01T00:00:00.001"))
- .calledWithLiteralAt(3, Duration.ofSeconds(10))
- .calledWithLiteralAt(4, Duration.ofDays(1))
+ .calledWithLiteralAt(1, ON_TIME)
+ .calledWithLiteralAt(2, "user_time")
+ .calledWithLiteralAt(3,
LocalDateTime.parse("2026-07-01T00:00:00.001"))
+ .calledWithLiteralAt(4, Duration.ofSeconds(10))
+ .calledWithLiteralAt(5, Duration.ofDays(1))
.expectArgumentTypes(
TABLE_TYPE,
+ DESCRIPTOR_TYPE,
STRING_TYPE,
TIMESTAMP_TYPE,
INTERVAL_TYPE,
@@ -123,15 +145,67 @@ class LateralSnapshotInputTypeStrategyTest extends
InputTypeStrategiesTestBase {
// Intentionally no table type registered at position
0.
.expectErrorMessage("Argument 'input' of SNAPSHOT must
be a table."),
+ //
----------------------------------------------------------------------------
+ // Invalid: on_time references an unknown column.
+ //
----------------------------------------------------------------------------
+ TestSpec.forStrategy(
+ "Invalid: on_time references unknown column",
+ LATERAL_SNAPSHOT_INPUT_TYPE_STRATEGY)
+ .calledWithArgumentTypes(TABLE_TYPE, DESCRIPTOR_TYPE)
+ .calledWithTableSemanticsAt(0, new
TableSemanticsMock(TABLE_TYPE))
+ .calledWithLiteralAt(1, ColumnList.of("nonexistent"))
+ .expectErrorMessage(
+ "Argument 'on_time' of SNAPSHOT references
column 'nonexistent' "
+ + "which is not present in the input
table."),
+
+ //
----------------------------------------------------------------------------
+ // Invalid: on_time references a non-timestamp column.
+ //
----------------------------------------------------------------------------
+ TestSpec.forStrategy(
+ "Invalid: on_time references non-timestamp
column",
+ LATERAL_SNAPSHOT_INPUT_TYPE_STRATEGY)
+ .calledWithArgumentTypes(TABLE_TYPE, DESCRIPTOR_TYPE)
+ .calledWithTableSemanticsAt(0, new
TableSemanticsMock(TABLE_TYPE))
+ .calledWithLiteralAt(1, ColumnList.of("v"))
+ .expectErrorMessage(
+ "Argument 'on_time' of SNAPSHOT must reference
a TIMESTAMP or "
+ + "TIMESTAMP_LTZ column (up to
precision 3), but column 'v' "
+ + "has type 'INT'."),
+
+ //
----------------------------------------------------------------------------
+ // Invalid: on_time references no column.
+ //
----------------------------------------------------------------------------
+ TestSpec.forStrategy(
+ "Invalid: on_time references no column",
+ LATERAL_SNAPSHOT_INPUT_TYPE_STRATEGY)
+ .calledWithArgumentTypes(TABLE_TYPE, DESCRIPTOR_TYPE)
+ .calledWithTableSemanticsAt(0, new
TableSemanticsMock(TABLE_TYPE))
+ .calledWithLiteralAt(1, ColumnList.of())
+ .expectErrorMessage(
+ "Argument 'on_time' of SNAPSHOT must reference
exactly one column."),
+
+ //
----------------------------------------------------------------------------
+ // Invalid: on_time references more than one column.
+ //
----------------------------------------------------------------------------
+ TestSpec.forStrategy(
+ "Invalid: on_time references multiple columns",
+ LATERAL_SNAPSHOT_INPUT_TYPE_STRATEGY)
+ .calledWithArgumentTypes(TABLE_TYPE, DESCRIPTOR_TYPE)
+ .calledWithTableSemanticsAt(0, new
TableSemanticsMock(TABLE_TYPE))
+ .calledWithLiteralAt(1, ColumnList.of("ts", "k"))
+ .expectErrorMessage(
+ "Argument 'on_time' of SNAPSHOT must reference
exactly one column."),
+
//
----------------------------------------------------------------------------
// Invalid: 'user_time' condition requires load_completed_time.
//
----------------------------------------------------------------------------
TestSpec.forStrategy(
"Invalid: condition='user_time' without
load_completed_time",
LATERAL_SNAPSHOT_INPUT_TYPE_STRATEGY)
- .calledWithArgumentTypes(TABLE_TYPE, STRING_TYPE)
+ .calledWithArgumentTypes(TABLE_TYPE, DESCRIPTOR_TYPE,
STRING_TYPE)
.calledWithTableSemanticsAt(0, new
TableSemanticsMock(TABLE_TYPE))
- .calledWithLiteralAt(1, "user_time")
+ .calledWithLiteralAt(1, ON_TIME)
+ .calledWithLiteralAt(2, "user_time")
.expectErrorMessage(
"SNAPSHOT requires 'load_completed_time' when "
+ "'load_completed_condition' is
'user_time'."),
@@ -142,10 +216,12 @@ class LateralSnapshotInputTypeStrategyTest extends
InputTypeStrategiesTestBase {
TestSpec.forStrategy(
"Invalid: load_completed_time without explicit
'user_time'",
LATERAL_SNAPSHOT_INPUT_TYPE_STRATEGY)
- .calledWithArgumentTypes(TABLE_TYPE, STRING_TYPE,
TIMESTAMP_TYPE)
+ .calledWithArgumentTypes(
+ TABLE_TYPE, DESCRIPTOR_TYPE, STRING_TYPE,
TIMESTAMP_TYPE)
.calledWithTableSemanticsAt(0, new
TableSemanticsMock(TABLE_TYPE))
- .calledWithLiteralAt(1, "compile_time")
- .calledWithLiteralAt(2,
LocalDateTime.parse("2026-07-01T00:00:00.001"))
+ .calledWithLiteralAt(1, ON_TIME)
+ .calledWithLiteralAt(2, "compile_time")
+ .calledWithLiteralAt(3,
LocalDateTime.parse("2026-07-01T00:00:00.001"))
.expectErrorMessage(
"SNAPSHOT does not accept
'load_completed_time' when "
+ "'load_completed_condition' is not
'user_time'."),
@@ -156,9 +232,10 @@ class LateralSnapshotInputTypeStrategyTest extends
InputTypeStrategiesTestBase {
TestSpec.forStrategy(
"Invalid: unknown condition value",
LATERAL_SNAPSHOT_INPUT_TYPE_STRATEGY)
- .calledWithArgumentTypes(TABLE_TYPE, STRING_TYPE)
+ .calledWithArgumentTypes(TABLE_TYPE, DESCRIPTOR_TYPE,
STRING_TYPE)
.calledWithTableSemanticsAt(0, new
TableSemanticsMock(TABLE_TYPE))
- .calledWithLiteralAt(1, "invalid_condition")
+ .calledWithLiteralAt(1, ON_TIME)
+ .calledWithLiteralAt(2, "invalid_condition")
.expectErrorMessage(
"Argument 'load_completed_condition' of
SNAPSHOT must be one of 'compile_time', 'user_time' but was
'invalid_condition'."),
@@ -168,8 +245,9 @@ class LateralSnapshotInputTypeStrategyTest extends
InputTypeStrategiesTestBase {
TestSpec.forStrategy(
"Invalid: non-literal
load_completed_condition",
LATERAL_SNAPSHOT_INPUT_TYPE_STRATEGY)
- .calledWithArgumentTypes(TABLE_TYPE, STRING_TYPE)
+ .calledWithArgumentTypes(TABLE_TYPE, DESCRIPTOR_TYPE,
STRING_TYPE)
.calledWithTableSemanticsAt(0, new
TableSemanticsMock(TABLE_TYPE))
+ .calledWithLiteralAt(1, ON_TIME)
// Intentionally no literal provided for
load_completed_condition
.expectErrorMessage(
"Argument 'load_completed_condition' of
SNAPSHOT must be a STRING literal."));
diff --git
a/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/calcite/FlinkCalciteSqlValidator.java
b/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/calcite/FlinkCalciteSqlValidator.java
index 0eb925352f0..6f263ad6b1f 100644
---
a/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/calcite/FlinkCalciteSqlValidator.java
+++
b/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/calcite/FlinkCalciteSqlValidator.java
@@ -34,7 +34,9 @@ import
org.apache.flink.table.planner.functions.bridging.BridgingSqlFunction;
import org.apache.flink.table.planner.plan.FlinkCalciteCatalogReader;
import org.apache.flink.table.planner.plan.utils.FlinkRexUtil;
import org.apache.flink.table.planner.utils.ShortcutUtils;
+import org.apache.flink.table.types.inference.StaticArgument;
import org.apache.flink.table.types.inference.SystemTypeInference;
+import org.apache.flink.table.types.inference.TypeInference;
import org.apache.flink.table.types.logical.DecimalType;
import org.apache.calcite.plan.RelOptCluster;
@@ -470,8 +472,11 @@ public final class FlinkCalciteSqlValidator extends
FlinkSqlParsingValidator {
*/
private static void checkDisabledSystemArgs(SqlBasicCall call) {
final SqlOperator operator = call.getOperator();
- if (!(operator instanceof BridgingSqlFunction)
- || !((BridgingSqlFunction)
operator).getTypeInference().disableSystemArguments()) {
+ if (!(operator instanceof BridgingSqlFunction)) {
+ return;
+ }
+ final TypeInference typeInference = ((BridgingSqlFunction)
operator).getTypeInference();
+ if (!typeInference.disableSystemArguments()) {
return;
}
final Set<String> suppliedArgNames = new HashSet<>();
@@ -483,7 +488,14 @@ public final class FlinkCalciteSqlValidator extends
FlinkSqlParsingValidator {
}
}
}
- SystemTypeInference.checkNoSystemArguments(true, suppliedArgNames,
operator.getName());
+ // A function that disabled the automatic system arguments may still
declare an argument
+ // with a reserved name (e.g. SNAPSHOT declares `on_time`); such
declared names are allowed.
+ final Set<String> declaredArgNames =
+ typeInference.getStaticArguments().orElse(List.of()).stream()
+ .map(StaticArgument::getName)
+ .collect(Collectors.toSet());
+ SystemTypeInference.checkNoSystemArguments(
+ true, suppliedArgNames, declaredArgNames, operator.getName());
}
@Override
diff --git
a/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/calcite/RelTimeIndicatorConverter.java
b/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/calcite/RelTimeIndicatorConverter.java
index 86954858c16..5ab9f8ad0cb 100644
---
a/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/calcite/RelTimeIndicatorConverter.java
+++
b/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/calcite/RelTimeIndicatorConverter.java
@@ -401,6 +401,7 @@ public final class RelTimeIndicatorConverter extends
RelHomogeneousShuttle {
newRight,
newCondition,
join.getJoinType(),
+ join.getRightTimeAttributeIndex(),
join.getLoadCompletedCondition(),
join.getLoadCompletedTime(),
join.getLoadCompletedIdleTimeoutMs(),
diff --git
a/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/plan/nodes/logical/FlinkLogicalLateralSnapshotJoin.java
b/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/plan/nodes/logical/FlinkLogicalLateralSnapshotJoin.java
index 6391e66fb1b..7127cfc4ab5 100644
---
a/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/plan/nodes/logical/FlinkLogicalLateralSnapshotJoin.java
+++
b/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/plan/nodes/logical/FlinkLogicalLateralSnapshotJoin.java
@@ -50,6 +50,8 @@ public class FlinkLogicalLateralSnapshotJoin extends Join
implements FlinkLogica
private final Long loadCompletedTime;
private final @Nullable Long loadCompletedIdleTimeoutMs;
private final @Nullable Long stateTtlMs;
+ // Build-side row-time column index (from the on_time argument); -1 in
batch.
+ private final int rightTimeAttributeIndex;
public FlinkLogicalLateralSnapshotJoin(
RelOptCluster cluster,
@@ -58,6 +60,7 @@ public class FlinkLogicalLateralSnapshotJoin extends Join
implements FlinkLogica
RelNode right,
RexNode condition,
JoinRelType joinType,
+ int rightTimeAttributeIndex,
String loadCompletedCondition,
Long loadCompletedTime,
@Nullable Long loadCompletedIdleTimeoutMs,
@@ -72,12 +75,17 @@ public class FlinkLogicalLateralSnapshotJoin extends Join
implements FlinkLogica
Collections.emptySet(),
joinType);
Preconditions.checkNotNull(loadCompletedTime, "loadCompletedTime must
not be null.");
+ this.rightTimeAttributeIndex = rightTimeAttributeIndex;
this.loadCompletedCondition = loadCompletedCondition;
this.loadCompletedTime = loadCompletedTime;
this.loadCompletedIdleTimeoutMs = loadCompletedIdleTimeoutMs;
this.stateTtlMs = stateTtlMs;
}
+ public int getRightTimeAttributeIndex() {
+ return rightTimeAttributeIndex;
+ }
+
public String getLoadCompletedCondition() {
return loadCompletedCondition;
}
@@ -109,6 +117,7 @@ public class FlinkLogicalLateralSnapshotJoin extends Join
implements FlinkLogica
right,
conditionExpr,
joinType,
+ rightTimeAttributeIndex,
loadCompletedCondition,
loadCompletedTime,
loadCompletedIdleTimeoutMs,
@@ -154,6 +163,7 @@ public class FlinkLogicalLateralSnapshotJoin extends Join
implements FlinkLogica
RelNode right,
RexNode condition,
JoinRelType joinType,
+ int rightTimeAttributeIndex,
String loadCompletedCondition,
Long loadCompletedTime,
@Nullable Long loadCompletedIdleTimeoutMs,
@@ -167,6 +177,7 @@ public class FlinkLogicalLateralSnapshotJoin extends Join
implements FlinkLogica
right,
condition,
joinType,
+ rightTimeAttributeIndex,
loadCompletedCondition,
loadCompletedTime,
loadCompletedIdleTimeoutMs,
diff --git
a/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/plan/nodes/physical/stream/StreamPhysicalLateralSnapshotJoin.java
b/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/plan/nodes/physical/stream/StreamPhysicalLateralSnapshotJoin.java
index 2b1e8b9ebd6..9304544c7a8 100644
---
a/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/plan/nodes/physical/stream/StreamPhysicalLateralSnapshotJoin.java
+++
b/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/plan/nodes/physical/stream/StreamPhysicalLateralSnapshotJoin.java
@@ -34,13 +34,11 @@ import org.apache.calcite.rel.RelWriter;
import org.apache.calcite.rel.core.Join;
import org.apache.calcite.rel.core.JoinRelType;
import org.apache.calcite.rel.type.RelDataType;
-import org.apache.calcite.rel.type.RelDataTypeField;
import org.apache.calcite.rex.RexNode;
import javax.annotation.Nullable;
import java.util.Collections;
-import java.util.List;
import static
org.apache.flink.table.planner.utils.ShortcutUtils.unwrapTableConfig;
@@ -53,6 +51,7 @@ import static
org.apache.flink.table.planner.utils.ShortcutUtils.unwrapTableConf
public class StreamPhysicalLateralSnapshotJoin extends CommonPhysicalJoin
implements StreamPhysicalRel {
+ private final int rightTimeAttributeIndex;
private final String loadCompletedCondition;
private final Long loadCompletedTime;
private final @Nullable Long loadCompletedIdleTimeoutMs;
@@ -65,11 +64,13 @@ public class StreamPhysicalLateralSnapshotJoin extends
CommonPhysicalJoin
RelNode rightRel,
RexNode condition,
JoinRelType joinType,
+ int rightTimeAttributeIndex,
String loadCompletedCondition,
Long loadCompletedTime,
@Nullable Long loadCompletedIdleTimeoutMs,
@Nullable Long stateTtlMs) {
super(cluster, traitSet, leftRel, rightRel, condition, joinType,
Collections.emptyList());
+ this.rightTimeAttributeIndex = rightTimeAttributeIndex;
Preconditions.checkNotNull(loadCompletedTime, "loadCompletedTime must
not be null.");
this.loadCompletedCondition = loadCompletedCondition;
this.loadCompletedTime = loadCompletedTime;
@@ -107,6 +108,7 @@ public class StreamPhysicalLateralSnapshotJoin extends
CommonPhysicalJoin
right,
conditionExpr,
joinType,
+ rightTimeAttributeIndex,
loadCompletedCondition,
loadCompletedTime,
loadCompletedIdleTimeoutMs,
@@ -129,17 +131,14 @@ public class StreamPhysicalLateralSnapshotJoin extends
CommonPhysicalJoin
@Override
public ExecNode<?> translateToExecNode() {
- // The build (right) side carries a watermark, so it must expose a
row-time attribute whose
- // field index drives the event-time-ordered application of buffered
build-side changes.
- final List<RelDataTypeField> rightFields =
getRight().getRowType().getFieldList();
- int rightTimeAttributeIndex = -1;
- for (int i = 0; i < rightFields.size(); i++) {
- if
(FlinkTypeFactory.isRowtimeIndicatorType(rightFields.get(i).getType())) {
- rightTimeAttributeIndex = i;
- break;
- }
- }
- if (rightTimeAttributeIndex < 0) {
+ // The build-side row-time column index (from the on_time argument)
drives the
+ // event-time-ordered application of buffered build-side changes.
Verify it points at an
+ // in-bounds row-time attribute.
+ final RelDataType rightRowType = getRight().getRowType();
+ if (rightTimeAttributeIndex < 0
+ || rightTimeAttributeIndex >= rightRowType.getFieldCount()
+ || !FlinkTypeFactory.isRowtimeIndicatorType(
+
rightRowType.getFieldList().get(rightTimeAttributeIndex).getType())) {
throw new TableException(
"The build (right) side of a LATERAL SNAPSHOT join must
have a row-time "
+ "attribute. This is a bug, please file an
issue.");
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 c4b1012df97..b50ff8e69b7 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
@@ -89,7 +89,7 @@ public class LogicalJoinToLateralSnapshotJoinRule
@Override
public void onMatch(RelOptRuleCall call) {
final FlinkLogicalJoin join = call.rel(0);
- final RelNode leftNode = join.getLeft();
+ final RelNode probeInputNode = join.getLeft();
final FlinkLogicalTableFunctionScan scan =
findSnapshotScan(join.getRight());
if (scan == null) {
// matches() guarantees a SNAPSHOT scan on the right, so this
cannot happen.
@@ -116,47 +116,23 @@ public class LogicalJoinToLateralSnapshotJoinRule
final RexCall snapshotCall = (RexCall) scan.getCall();
- // Resolve the raw build-side TABLE input the operator reads. A null
result means the
- // SNAPSHOT call is malformed, which cannot happen for a plan that
reached this rule.
- final RelNode rawTableInput = getSnapshotInputTable(scan);
- if (rawTableInput == null) {
- throw new TableException(
- "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 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
- // nodes that the optimizer placed above the scan.
- final RelNode rightNode = replaceSnapshotScan(join.getRight());
- if (rightNode == null) {
+ // Replace the SNAPSHOT TableFunctionScan with its TABLE input,
preserving any
+ // FlinkLogicalCalc nodes that the optimizer placed above the scan.
+ final RelNode buildInputNode = replaceSnapshotScan(join.getRight());
+ if (buildInputNode == null) {
throw new TableException(
"Could not rewrite the build side of a LATERAL SNAPSHOT
join by replacing the "
+ "SNAPSHOT scan with its TABLE input. This is a
bug, please file an "
+ "issue.");
}
+ // Resolve the build-side rowtime column named by the on_time
argument. Streaming
+ // requires it; in batch the join degrades to a regular join (see
+ // BatchPhysicalLateralSnapshotJoinRule) and no watermark is needed.
+ final boolean isBatch =
ShortcutUtils.unwrapContext(join).isBatchMode();
+ final int rightTimeAttributeIndex =
+ resolveOnTimeIndex(snapshotCall, buildInputNode, isBatch);
+
final List<RexNode> operands = snapshotCall.getOperands();
final RexBuilder rexBuilder = join.getCluster().getRexBuilder();
final RexExecutor executor =
join.getCluster().getPlanner().getExecutor();
@@ -232,12 +208,12 @@ public class LogicalJoinToLateralSnapshotJoinRule
intervalMillis(stateTtlLiteral,
LateralSnapshotTypeStrategy.STATE_TTL_ARG_NAME);
// The original join condition's field types were resolved against the
SNAPSHOT scan's
- // materialized output, but rightNode (its raw TABLE input) still
exposes the build-side
- // row-time attribute as an indicator (see replaceSnapshotScan).
Retype the condition to
- // the actual left+right input types.
+ // materialized output, but buildInputNode (its raw TABLE input) still
exposes the
+ // build-side rowtime attribute as an indicator (see
replaceSnapshotScan). Retype the
+ // condition to the actual left+right input types.
final List<RelDataTypeField> leftRightFields = new ArrayList<>();
- leftRightFields.addAll(leftNode.getRowType().getFieldList());
- leftRightFields.addAll(rightNode.getRowType().getFieldList());
+ leftRightFields.addAll(probeInputNode.getRowType().getFieldList());
+ leftRightFields.addAll(buildInputNode.getRowType().getFieldList());
final RexNode rebasedCondition =
join.getCondition()
.accept(
@@ -253,24 +229,23 @@ public class LogicalJoinToLateralSnapshotJoinRule
// build-side input.
final RelNode node =
FlinkLogicalLateralSnapshotJoin.create(
- leftNode,
- rightNode,
+ probeInputNode,
+ buildInputNode,
rebasedCondition,
joinType,
+ rightTimeAttributeIndex,
loadCompletedCondition,
loadCompletedTime,
loadCompletedIdleTimeoutMs,
stateTtlMs);
final int origRightCount =
unwrap(join.getRight()).getRowType().getFieldCount();
- final int newRightCount = rightNode.getRowType().getFieldCount();
+ final int newRightCount = buildInputNode.getRowType().getFieldCount();
final boolean isRowtimeFieldAdded = newRightCount > origRightCount;
if (isRowtimeFieldAdded) {
- // If the build-side projection stripped the row-time attribute,
replaceSnapshotScan
- // re-appended it as a trailing column so it reaches the operator.
In that case the node
- // has extra trailing column(s) that a wrapper Calc projects away
to restore the
- // original join's output type. Otherwise, the node's output type
already matches the
- // original join.
+ // If a build-side projection stripped the rowtime attribute,
rebaseCalc re-appended it
+ // as a trailing column so it reaches the operator. Project that
column away with a
+ // wrapper Calc to restore the original join's output type.
final RelDataType originalOutputType = join.getRowType();
final List<RexNode> wrapperProjects = new ArrayList<>();
for (int i = 0; i < originalOutputType.getFieldCount(); i++) {
@@ -351,18 +326,26 @@ public class LogicalJoinToLateralSnapshotJoinRule
* Walks the right subtree replacing the {@link
FlinkLogicalTableFunctionScan} (the SNAPSHOT
* scan) with the scan's TABLE input, while preserving any {@link
FlinkLogicalCalc} nodes
* stacked above the scan. The SNAPSHOT type strategy materializes the
build-side time
- * attributes, so the scan's output type differs from its input's (the
build-side row-time
- * attribute is a plain timestamp on the scan output but a row-time
indicator on the raw input).
+ * attributes, so the scan's output type differs from its input's (the
build-side rowtime
+ * attribute is a plain timestamp on the scan output but a rowtime
indicator on the raw input).
* Each preserved Calc was built against the materialized scan output, so
its {@link RexProgram}
- * is rebased onto the raw (row-time-bearing) input type, which lets the
row-time attribute flow
+ * is rebased onto the raw (rowtime-bearing) input type, which lets the
rowtime attribute flow
* through to the operator.
*/
@Nullable
private static RelNode replaceSnapshotScan(RelNode node) {
final RelNode current = unwrap(node);
if (current instanceof FlinkLogicalTableFunctionScan) {
- // the top node is the TableFunctionScan, return its table input
argument
- return getSnapshotInputTable((FlinkLogicalTableFunctionScan)
current);
+ // Resolve the raw build-side TABLE input the operator reads. A
null result means the
+ // SNAPSHOT call is malformed, which cannot happen for a plan that
reached this rule.
+ final RelNode tableInput =
+ getSnapshotInputTable((FlinkLogicalTableFunctionScan)
current);
+ if (tableInput == null) {
+ throw new TableException(
+ "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.");
+ }
+ return tableInput;
}
if (current instanceof FlinkLogicalCalc) {
// the top node is a calc that needs to be rebased
@@ -376,15 +359,55 @@ public class LogicalJoinToLateralSnapshotJoinRule
return null;
}
+ /**
+ * Resolves the build-side rowtime column named by the {@code on_time}
argument to its field
+ * index in {@code buildInputNode}. Streaming requires the argument and
the referenced column to
+ * be a rowtime attribute; batch does not use a rowtime attribute and
returns {@code -1} when
+ * the argument is absent.
+ */
+ private static int resolveOnTimeIndex(
+ RexCall snapshotCall, RelNode buildInputNode, boolean isBatch) {
+ final List<RexNode> operands = snapshotCall.getOperands();
+ final int argIndex = LateralSnapshotTypeStrategy.ON_TIME_ARG_INDEX;
+ final RexNode timeColumnArg = argIndex < operands.size() ?
operands.get(argIndex) : null;
+ if (timeColumnArg == null || timeColumnArg.isA(SqlKind.DEFAULT)) {
+ if (!isBatch) {
+ throw new ValidationException(
+ "LATERAL SNAPSHOT requires the 'on_time' argument to
identify the "
+ + "build-side rowtime attribute.");
+ }
+ return -1;
+ }
+ // The type strategy (LateralSnapshotTypeStrategy#validateOnTime)
already validated that
+ // on_time is a single-column DESCRIPTOR referencing an existing
TIMESTAMP/TIMESTAMP_LTZ
+ // column, so the operand structure is safe to read here.
+ final String timeColName =
+ RexLiteral.stringValue((RexLiteral) ((RexCall)
timeColumnArg).getOperands().get(0));
+ final int timeColIdx =
buildInputNode.getRowType().getFieldNames().indexOf(timeColName);
+ if (isBatch) {
+ // Batch degrades to a regular join and does not use the rowtime
attribute.
+ return timeColIdx;
+ }
+ // A rowtime column named by on_time is always retained in the build
input, so a missing
+ // index means the referenced column is not a rowtime attribute.
+ if (timeColIdx < 0
+ || !FlinkTypeFactory.isRowtimeIndicatorType(
+
buildInputNode.getRowType().getFieldList().get(timeColIdx).getType())) {
+ throw new ValidationException(
+ String.format(
+ "Argument 'on_time' of SNAPSHOT must reference a
rowtime attribute "
+ + "(a column with a watermark), but column
'%s' is not one.",
+ timeColName));
+ }
+ return timeColIdx;
+ }
+
/**
* Rebuilds {@code calc}'s {@link RexProgram} so it reads from {@code
newInput} (whose
- * build-side time attributes are still row-time indicators) instead of
the materialized
- * SNAPSHOT scan output it was originally built against. Input references
are retyped to the new
- * input's field types; the projection/condition expressions and output
field names are
- * otherwise preserved.
- *
- * <p>If the projection dropped the build-side row-time attribute, it is
re-appended as a
- * trailing column so it is available for the snapshot join operator.
+ * build-side time attributes are still rowtime indicators) instead of the
materialized SNAPSHOT
+ * scan output it was originally built against. Input references are
retyped to the new input's
+ * field types; the projection/condition expressions and output field
names are otherwise
+ * preserved.
*/
private static RelNode rebaseCalc(FlinkLogicalCalc calc, RelNode newInput)
{
final RexProgram program = calc.getProgram();
@@ -410,7 +433,8 @@ public class LogicalJoinToLateralSnapshotJoinRule
:
program.expandLocalRef(program.getCondition()).accept(retyper);
final List<String> fieldNames = new
ArrayList<>(program.getOutputRowType().getFieldNames());
- // Re-append the build-side row-time attribute if this projection
dropped it.
+ // Re-append the build-side rowtime attribute if this projection
dropped it, so it reaches
+ // the operator even when the outer query does not select it.
final boolean exposesRowtime =
newProjects.stream()
.anyMatch(p ->
FlinkTypeFactory.isRowtimeIndicatorType(p.getType()));
diff --git
a/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/plan/rules/physical/stream/StreamPhysicalLateralSnapshotJoinRule.java
b/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/plan/rules/physical/stream/StreamPhysicalLateralSnapshotJoinRule.java
index cde7e68e81a..ec8010deed1 100644
---
a/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/plan/rules/physical/stream/StreamPhysicalLateralSnapshotJoinRule.java
+++
b/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/plan/rules/physical/stream/StreamPhysicalLateralSnapshotJoinRule.java
@@ -68,6 +68,7 @@ public class StreamPhysicalLateralSnapshotJoinRule extends
ConverterRule {
newRight,
join.getCondition(),
join.getJoinType(),
+ join.getRightTimeAttributeIndex(),
join.getLoadCompletedCondition(),
join.getLoadCompletedTime(),
join.getLoadCompletedIdleTimeoutMs(),
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 bb45bde761c..f8864578acf 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
@@ -92,7 +92,7 @@ public class LateralSnapshotJoinSemanticTestPrograms {
+ "SELECT probe.pk, probe.pv, s.bk, s.bv "
+ "FROM probe "
+ " JOIN LATERAL TABLE(SNAPSHOT("
- + " input => TABLE b, "
+ + " input => TABLE b, on_time =>
DESCRIPTOR(bts), "
+ " load_completed_condition =>
'user_time', "
+ " load_completed_time =>
CAST(TIMESTAMP '2020-01-01 00:00:10' AS TIMESTAMP_LTZ(3)))) AS s "
+ " ON probe.pk = s.bk")
@@ -135,7 +135,7 @@ public class LateralSnapshotJoinSemanticTestPrograms {
.build())
.runSql(
"INSERT INTO sink SELECT * FROM probe JOIN LATERAL
SNAPSHOT("
- + "input => TABLE b, "
+ + "input => TABLE b, on_time =>
DESCRIPTOR(bts), "
+ MID_FLIP
+ ") AS s ON probe.pk = s.bk")
.build();
@@ -262,7 +262,7 @@ public class LateralSnapshotJoinSemanticTestPrograms {
.runSql(
"INSERT INTO sink SELECT probe.pk, probe.pv, s.bk,
s.bv "
+ "FROM probe JOIN LATERAL SNAPSHOT("
- + "input => TABLE b"
+ + "input => TABLE b, on_time =>
DESCRIPTOR(bts)"
+ ") AS s ON probe.pk = s.bk")
.build();
@@ -354,7 +354,7 @@ public class LateralSnapshotJoinSemanticTestPrograms {
+ " FROM probe "
+ joinType
+ " LATERAL SNAPSHOT("
- + "input => TABLE b, "
+ + "input => TABLE b, on_time => DESCRIPTOR(bts), "
+ flip
+ ") AS s ON "
+ condition;
diff --git
a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/nodes/exec/stream/LateralSnapshotJoinTestPrograms.java
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/nodes/exec/stream/LateralSnapshotJoinTestPrograms.java
index fcae8c89c62..b1fae558aab 100644
---
a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/nodes/exec/stream/LateralSnapshotJoinTestPrograms.java
+++
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/nodes/exec/stream/LateralSnapshotJoinTestPrograms.java
@@ -96,7 +96,7 @@ public class LateralSnapshotJoinTestPrograms {
private static final String SNAPSHOT_BUILD =
"LATERAL SNAPSHOT("
- + "input => TABLE b, "
+ + "input => TABLE b, on_time => DESCRIPTOR(bts), "
+ "load_completed_condition => 'user_time', "
+ "load_completed_time => CAST(TIMESTAMP '2020-01-01
00:00:03' AS TIMESTAMP_LTZ(3))"
+ ") AS s ON probe.pk = s.bk";
diff --git
a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/stream/sql/ColumnExpansionTest.java
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/stream/sql/ColumnExpansionTest.java
index 79d1c3eacc2..1d96ad811e8 100644
---
a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/stream/sql/ColumnExpansionTest.java
+++
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/stream/sql/ColumnExpansionTest.java
@@ -452,6 +452,96 @@ class ColumnExpansionTest {
+ "argument 'r2' has data type 'INT'.");
}
+ @Test
+ void testLateralSnapshotJoinWithOnTimeOnHiddenMetadataColumn() {
+ tableEnv.getConfig()
+ .set(
+ TABLE_COLUMN_EXPANSION_STRATEGY,
+ List.of(EXCLUDE_DEFAULT_VIRTUAL_METADATA_COLUMNS));
+
+ tableEnv.executeSql(
+ "CREATE TABLE snapshot_probe (\n"
+ + " pk STRING,\n"
+ + " pts TIMESTAMP(3),\n"
+ + " WATERMARK FOR pts AS pts\n"
+ + ") WITH ('connector' = 'values', 'bounded' =
'false')");
+
+ // The build-side watermark is declared on a virtual metadata column
that
+ // EXCLUDE_DEFAULT_VIRTUAL_METADATA_COLUMNS hides from SELECT *.
+ tableEnv.executeSql(
+ "CREATE TABLE snapshot_build_hidden (\n"
+ + " bk STRING,\n"
+ + " bv INT,\n"
+ + " rt TIMESTAMP_LTZ(3) METADATA VIRTUAL,\n"
+ + " WATERMARK FOR rt AS rt\n"
+ + ") WITH (\n"
+ + " 'connector' = 'values',\n"
+ + " 'bounded' = 'false',\n"
+ + " 'readable-metadata' = 'rt:TIMESTAMP_LTZ(3)'\n"
+ + ")");
+
+ final String sql =
+ "SELECT * FROM snapshot_probe JOIN LATERAL SNAPSHOT("
+ + "input => TABLE snapshot_build_hidden, on_time =>
DESCRIPTOR(rt), "
+ + "load_completed_condition => 'user_time', "
+ + "load_completed_time => CAST(TIMESTAMP '2026-07-01
00:00:00' AS TIMESTAMP_LTZ(3))"
+ + ") AS s ON snapshot_probe.pk = s.bk";
+
+ // rt is selected due to the on_time descriptor, even though it is a
hidden metadata column.
+ assertColumnNames(sql, "pk", "pts", "bk", "bv", "rt");
+ // The row-time attribute named by on_time is recognized, so the join
plans as a LATERAL
+ // SNAPSHOT join instead of being rejected for a missing build-side
watermark.
+ assertThat(tableEnv.explainSql(sql)).contains("LateralSnapshotJoin");
+ }
+
+ @Test
+ void testLateralSnapshotJoinWithOnTimeOnPushedDownHiddenMetadataColumn() {
+ tableEnv.getConfig()
+ .set(
+ TABLE_COLUMN_EXPANSION_STRATEGY,
+ List.of(EXCLUDE_DEFAULT_VIRTUAL_METADATA_COLUMNS));
+
+ tableEnv.executeSql(
+ "CREATE TABLE snapshot_probe (\n"
+ + " pk STRING,\n"
+ + " pts TIMESTAMP(3),\n"
+ + " WATERMARK FOR pts AS pts\n"
+ + ") WITH ('connector' = 'values', 'bounded' =
'false')");
+
+ // Same hidden-metadata watermark, but enable-watermark-push-down
folds it into the source;
+ // disable-lookup keeps the values source a pure scan source so the
watermark is pushed into
+ // the scan.
+ tableEnv.executeSql(
+ "CREATE TABLE snapshot_build_pushed (\n"
+ + " bk STRING,\n"
+ + " bv INT,\n"
+ + " rt TIMESTAMP_LTZ(3) METADATA VIRTUAL,\n"
+ + " WATERMARK FOR rt AS rt\n"
+ + ") WITH (\n"
+ + " 'connector' = 'values',\n"
+ + " 'bounded' = 'false',\n"
+ + " 'disable-lookup' = 'true',\n"
+ + " 'enable-watermark-push-down' = 'true',\n"
+ + " 'scan.watermark.emit.strategy' = 'on-event',\n"
+ + " 'readable-metadata' = 'rt:TIMESTAMP_LTZ(3)'\n"
+ + ")");
+
+ final String sql =
+ "SELECT * FROM snapshot_probe JOIN LATERAL SNAPSHOT("
+ + "input => TABLE snapshot_build_pushed, on_time =>
DESCRIPTOR(rt), "
+ + "load_completed_condition => 'user_time', "
+ + "load_completed_time => CAST(TIMESTAMP '2026-07-01
00:00:00' AS TIMESTAMP_LTZ(3))"
+ + ") AS s ON snapshot_probe.pk = s.bk";
+
+ // rt is selected due to the on_time descriptor, even though it is a
hidden metadata column.
+ assertColumnNames(sql, "pk", "pts", "bk", "bv", "rt");
+ final String plan = tableEnv.explainSql(sql);
+ assertThat(plan).contains("LateralSnapshotJoin");
+ // watermarkEmitStrategy only appears on the TableSourceScan when the
watermark is folded
+ // into the source, confirming the pushed-down row-time attribute
reaches the operator.
+ assertThat(plan).contains("watermarkEmitStrategy=[on-event]");
+ }
+
@DataTypeHint("ROW<out STRING>")
public static class PassThroughPtf extends ProcessTableFunction<Row> {
@SuppressWarnings("unused")
diff --git
a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/stream/sql/SnapshotTableFunctionTest.java
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/stream/sql/SnapshotTableFunctionTest.java
index 722e96d0abb..3f4b636b4bd 100644
---
a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/stream/sql/SnapshotTableFunctionTest.java
+++
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/stream/sql/SnapshotTableFunctionTest.java
@@ -77,7 +77,7 @@ public class SnapshotTableFunctionTest extends TableTestBase {
.explainSql(
"SELECT o.order_id, o.amount, r.rate "
+ "FROM Orders AS o, LATERAL SNAPSHOT("
- + "input => TABLE Rates, "
+ + "input => TABLE Rates, on_time =>
DESCRIPTOR(rate_time), "
+ "load_completed_condition =>
'user_time', "
+ "load_completed_time =>
CAST(TIMESTAMP '2026-07-01 00:00:00' AS TIMESTAMP_LTZ(3))"
+ ") AS r "
@@ -94,7 +94,7 @@ public class SnapshotTableFunctionTest extends TableTestBase {
"CREATE VIEW OrdersWithRate AS "
+ "SELECT o.order_id, o.amount, r.rate "
+ "FROM Orders AS o, LATERAL SNAPSHOT("
- + "input => TABLE Rates, "
+ + "input => TABLE Rates, on_time =>
DESCRIPTOR(rate_time), "
+ "load_completed_condition => 'user_time', "
+ "load_completed_time => CAST(TIMESTAMP
'2026-07-01 00:00:00' AS TIMESTAMP_LTZ(3))"
+ ") AS r "
@@ -111,7 +111,7 @@ public class SnapshotTableFunctionTest extends
TableTestBase {
.explainSql(
"SELECT o.order_id, o.amount, r.rate "
+ "FROM Orders AS o, LATERAL SNAPSHOT("
- + "input => TABLE RatesView, "
+ + "input => TABLE RatesView, on_time
=> DESCRIPTOR(rate_time), "
+ "load_completed_condition =>
'user_time', "
+ "load_completed_time =>
CAST(TIMESTAMP '2026-07-01 00:00:00' AS TIMESTAMP_LTZ(3))"
+ ") AS r "
@@ -121,20 +121,21 @@ public class SnapshotTableFunctionTest extends
TableTestBase {
@Test
void testSystemArgumentsNotAllowed() {
- // SNAPSHOT disables the implicit system arguments (e.g. `on_time`).
Passing one in a
- // LATERAL context must be rejected because the argument is not part
of the function
- // signature.
+ // SNAPSHOT disables the implicit system arguments. It declares its
own `on_time` argument
+ // (so that name is accepted), but the other system arguments such as
`uid` remain
+ // unsupported and must be rejected.
assertThatThrownBy(
() ->
util.verifyRelPlan(
"SELECT o.order_id "
+ "FROM Orders AS o, LATERAL
SNAPSHOT("
+ "input => TABLE Rates, "
- + "on_time =>
DESCRIPTOR(rate_time)) AS r "
+ + "on_time =>
DESCRIPTOR(rate_time), "
+ + "uid => 'my_uid') AS r "
+ "WHERE o.currency =
r.currency"))
.satisfies(
anyCauseMatches(
- "The 'on_time' argument is not supported
because function "
+ "The 'uid' argument is not supported because
function "
+ "'SNAPSHOT' does not use system
arguments."));
}
diff --git
a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/stream/sql/join/LateralSnapshotJoinTest.java
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/stream/sql/join/LateralSnapshotJoinTest.java
index 323ed9beb91..4d389ecd1c5 100644
---
a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/stream/sql/join/LateralSnapshotJoinTest.java
+++
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/stream/sql/join/LateralSnapshotJoinTest.java
@@ -91,7 +91,7 @@ public class LateralSnapshotJoinTest extends TableTestBase {
void testInnerJoin() {
util.verifyRelPlan(
"SELECT * FROM probe JOIN LATERAL TABLE(SNAPSHOT("
- + "input => TABLE b, "
+ + "input => TABLE b, on_time => DESCRIPTOR(bts), "
+ "load_completed_condition => 'user_time', "
+ "load_completed_time => CAST(TIMESTAMP '2026-07-01
00:00:00' AS TIMESTAMP_LTZ(3))"
+ ")) AS s "
@@ -102,7 +102,7 @@ public class LateralSnapshotJoinTest extends TableTestBase {
void testLeftJoin() {
util.verifyRelPlan(
"SELECT * FROM probe LEFT JOIN LATERAL SNAPSHOT("
- + "input => TABLE b, "
+ + "input => TABLE b, on_time => DESCRIPTOR(bts), "
+ "load_completed_condition => 'user_time', "
+ "load_completed_time => CAST(TIMESTAMP '2026-07-01
00:00:00' AS TIMESTAMP_LTZ(3))"
+ ") AS s "
@@ -113,7 +113,7 @@ public class LateralSnapshotJoinTest extends TableTestBase {
void testInnerJoinWithIdleTimeoutAndStateTtl() {
util.verifyRelPlan(
"SELECT * FROM probe JOIN LATERAL SNAPSHOT("
- + "input => TABLE b, "
+ + "input => TABLE b, on_time => DESCRIPTOR(bts), "
+ "load_completed_condition => 'user_time', "
+ "load_completed_time => CAST(TIMESTAMP '2026-07-01
00:00:00' AS TIMESTAMP_LTZ(3)), "
+ "load_completed_idle_timeout => INTERVAL '10'
SECOND, "
@@ -126,7 +126,7 @@ public class LateralSnapshotJoinTest extends TableTestBase {
void testInnerJoinWithNonEquiCondition() {
util.verifyRelPlan(
"SELECT * FROM probe JOIN LATERAL SNAPSHOT("
- + "input => TABLE b, "
+ + "input => TABLE b, on_time => DESCRIPTOR(bts), "
+ "load_completed_condition => 'user_time', "
+ "load_completed_time => CAST(TIMESTAMP '2026-07-01
00:00:00' AS TIMESTAMP_LTZ(3))"
+ ") AS s "
@@ -137,7 +137,7 @@ public class LateralSnapshotJoinTest extends TableTestBase {
void testInnerJoinWithCompositeKeys() {
util.verifyRelPlan(
"SELECT * FROM probe JOIN LATERAL SNAPSHOT("
- + "input => TABLE b, "
+ + "input => TABLE b, on_time => DESCRIPTOR(bts), "
+ "load_completed_condition => 'user_time', "
+ "load_completed_time => CAST(TIMESTAMP '2026-07-01
00:00:00' AS TIMESTAMP_LTZ(3))"
+ ") AS s "
@@ -148,7 +148,7 @@ public class LateralSnapshotJoinTest extends TableTestBase {
void testInnerJoinWithTimeAttributeInCondition() {
util.verifyRelPlan(
"SELECT * FROM probe JOIN LATERAL SNAPSHOT("
- + "input => TABLE b, "
+ + "input => TABLE b, on_time => DESCRIPTOR(bts), "
+ "load_completed_condition => 'user_time', "
+ "load_completed_time => CAST(TIMESTAMP '2026-07-01
00:00:00' AS TIMESTAMP_LTZ(3))"
+ ") AS s "
@@ -160,7 +160,7 @@ public class LateralSnapshotJoinTest extends TableTestBase {
util.verifyRelPlan(
"WITH cte AS (SELECT bk, bv + 1 AS bv, bts FROM b) "
+ "SELECT * FROM probe JOIN LATERAL SNAPSHOT("
- + "input => TABLE cte, "
+ + "input => TABLE cte, on_time => DESCRIPTOR(bts), "
+ "load_completed_condition => 'user_time', "
+ "load_completed_time => CAST(TIMESTAMP '2026-07-01
00:00:00' AS TIMESTAMP_LTZ(3))"
+ ") AS s "
@@ -171,7 +171,7 @@ public class LateralSnapshotJoinTest extends TableTestBase {
void testInnerJoinWithoutBuildTimeColumn() {
util.verifyRelPlan(
"SELECT probe.pk, probe.pv, s.bv FROM probe JOIN LATERAL
SNAPSHOT("
- + "input => TABLE b, "
+ + "input => TABLE b, on_time => DESCRIPTOR(bts), "
+ "load_completed_condition => 'user_time', "
+ "load_completed_time => CAST(TIMESTAMP '2026-07-01
00:00:00' AS TIMESTAMP_LTZ(3))"
+ ") AS s "
@@ -182,7 +182,7 @@ public class LateralSnapshotJoinTest extends TableTestBase {
void testLeftJoinWithoutBuildTimeColumn() {
util.verifyRelPlan(
"SELECT probe.pk, probe.pv, s.bv FROM probe LEFT JOIN LATERAL
SNAPSHOT("
- + "input => TABLE b, "
+ + "input => TABLE b, on_time => DESCRIPTOR(bts), "
+ "load_completed_condition => 'user_time', "
+ "load_completed_time => CAST(TIMESTAMP '2026-07-01
00:00:00' AS TIMESTAMP_LTZ(3))"
+ ") AS s "
@@ -202,7 +202,7 @@ public class LateralSnapshotJoinTest extends TableTestBase {
+ ") WITH ('connector' = 'values', 'bounded' =
'false')");
util.verifyRelPlan(
"SELECT probe.pk, s.bk, s.bv, s.pt FROM probe JOIN LATERAL
SNAPSHOT("
- + "input => TABLE b_proctime, "
+ + "input => TABLE b_proctime, on_time =>
DESCRIPTOR(bts), "
+ "load_completed_condition => 'user_time', "
+ "load_completed_time => CAST(TIMESTAMP '2026-07-01
00:00:00' AS TIMESTAMP_LTZ(3))"
+ ") AS s "
@@ -219,7 +219,7 @@ public class LateralSnapshotJoinTest extends TableTestBase {
// materialized) build time attribute as `bts`.
final String derived =
"(SELECT * FROM probe JOIN LATERAL SNAPSHOT("
- + "input => TABLE b, "
+ + "input => TABLE b, on_time => DESCRIPTOR(bts), "
+ "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)";
@@ -259,7 +259,7 @@ public class LateralSnapshotJoinTest extends TableTestBase {
util.tableEnv()
.explainSql(
"SELECT * FROM probe JOIN LATERAL SNAPSHOT("
- + "input => TABLE b_upsert, "
+ + "input => TABLE b_upsert, on_time =>
DESCRIPTOR(bts), "
+ "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");
@@ -271,7 +271,7 @@ public class LateralSnapshotJoinTest extends TableTestBase {
void testNonEquiConditionCompilesEndToEnd() {
final String sql =
"SELECT * FROM probe JOIN LATERAL SNAPSHOT("
- + "input => TABLE b, "
+ + "input => TABLE b, on_time => DESCRIPTOR(bts), "
+ "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 AND
probe.pts >= s.bts";
@@ -284,7 +284,7 @@ public class LateralSnapshotJoinTest extends TableTestBase {
util.tableEnv()
.explainSql(
"SELECT * FROM probe JOIN LATERAL SNAPSHOT("
- + "input => TABLE b, "
+ + "input => TABLE b, on_time =>
DESCRIPTOR(bts), "
+ "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");
@@ -294,7 +294,7 @@ public class LateralSnapshotJoinTest extends TableTestBase {
@Test
void testInnerJoinWithDefaultCompileTimeCompilesEndToEnd() {
final String sql =
- "SELECT * FROM probe JOIN LATERAL SNAPSHOT(input => TABLE b)
AS s "
+ "SELECT * FROM probe JOIN LATERAL SNAPSHOT(input => TABLE b,
on_time => DESCRIPTOR(bts)) AS s "
+ "ON probe.pk = s.bk";
// Compile via the table environment without verifying the plan XML
(since
// load_completed_time embeds wall-clock millis at planning).
@@ -309,7 +309,7 @@ public class LateralSnapshotJoinTest extends TableTestBase {
void testInnerJoinWithExplicitCompileTimeCompilesEndToEnd() {
final String sql =
"SELECT * FROM probe JOIN LATERAL SNAPSHOT("
- + "input => TABLE b, "
+ + "input => TABLE b, on_time => DESCRIPTOR(bts), "
+ "load_completed_condition => 'compile_time'"
+ ") AS s ON probe.pk = s.bk";
assertThat(util.tableEnv().explainSql(sql))
@@ -324,7 +324,7 @@ public class LateralSnapshotJoinTest extends TableTestBase {
//
------------------------------------------------------------------------------------------
@Test
- void testRejectBuildSideWithoutWatermark() {
+ void testRejectOnTimeWithoutWatermark() {
util.tableEnv()
.executeSql(
"CREATE TABLE b_no_wm ("
@@ -334,7 +334,29 @@ public class LateralSnapshotJoinTest extends TableTestBase
{
+ ") WITH ('connector' = 'values', 'bounded' =
'false')");
final String sql =
"SELECT * FROM probe JOIN LATERAL SNAPSHOT("
- + "input => TABLE b_no_wm, "
+ + "input => TABLE b_no_wm, on_time => DESCRIPTOR(bts),
"
+ + "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";
+ assertThatThrownBy(() -> util.verifyRelPlan(sql))
+ .isInstanceOf(ValidationException.class)
+ .hasMessageContaining(
+ "Argument 'on_time' of SNAPSHOT must reference a
rowtime attribute");
+ }
+
+ @Test
+ void testRejectOnTimeWithoutWatermarkWhenBuildTimeColumnPruned() {
+ util.tableEnv()
+ .executeSql(
+ "CREATE TABLE b_no_wm ("
+ + " bk STRING,"
+ + " bv INT,"
+ + " bts TIMESTAMP(3)"
+ + ") WITH ('connector' = 'values', 'bounded' =
'false')");
+ final String sql =
+ "SELECT probe.pk, probe.pv, s.bv FROM probe JOIN LATERAL
SNAPSHOT("
+ + "input => TABLE b_no_wm, on_time => DESCRIPTOR(bts),
"
+ "load_completed_condition => 'user_time', "
+ "load_completed_time => CAST(TIMESTAMP '2026-07-01
00:00:00' AS TIMESTAMP_LTZ(3))"
+ ") AS s "
@@ -342,7 +364,79 @@ public class LateralSnapshotJoinTest extends TableTestBase
{
assertThatThrownBy(() -> util.verifyRelPlan(sql))
.isInstanceOf(ValidationException.class)
.hasMessageContaining(
- "LATERAL SNAPSHOT requires a watermark on the
build-side input.");
+ "Argument 'on_time' of SNAPSHOT must reference a
rowtime attribute");
+ }
+
+ @Test
+ void testRejectMissingOnTime() {
+ final String sql =
+ "SELECT * FROM probe JOIN LATERAL 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";
+ assertThatThrownBy(() -> util.verifyRelPlan(sql))
+ .isInstanceOf(ValidationException.class)
+ .hasMessageContaining("LATERAL SNAPSHOT requires the 'on_time'
argument");
+ }
+
+ @Test
+ void testRejectUnknownOnTimeColumn() {
+ final String sql =
+ "SELECT * FROM probe JOIN LATERAL SNAPSHOT("
+ + "input => TABLE b, on_time =>
DESCRIPTOR(nonexistent), "
+ + "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";
+ assertThatThrownBy(() -> util.verifyRelPlan(sql))
+ .isInstanceOf(ValidationException.class)
+ .hasStackTraceContaining(
+ "Argument 'on_time' of SNAPSHOT references column
'nonexistent'");
+ }
+
+ @Test
+ void testRejectNonTimestampOnTimeColumn() {
+ final String sql =
+ "SELECT * FROM probe JOIN LATERAL SNAPSHOT("
+ + "input => TABLE b, on_time => DESCRIPTOR(bv), "
+ + "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";
+ assertThatThrownBy(() -> util.verifyRelPlan(sql))
+ .isInstanceOf(ValidationException.class)
+ .hasStackTraceContaining("must reference a TIMESTAMP or
TIMESTAMP_LTZ column");
+ }
+
+ @Test
+ void testRejectProctimeOnTimeColumn() {
+ // A proctime column is a TIMESTAMP_LTZ(3), so it passes the
type-strategy check, but it is
+ // a proctime (not a rowtime) attribute and cannot drive the load
phase, so the rule
+ // rejects it.
+ util.tableEnv()
+ .executeSql(
+ "CREATE TABLE b_proctime_wm ("
+ + " bk STRING,"
+ + " bv INT,"
+ + " bts TIMESTAMP(3),"
+ + " pt AS PROCTIME(),"
+ + " WATERMARK FOR bts AS bts"
+ + ") WITH ('connector' = 'values', 'bounded' =
'false')");
+ // Select s.pt so the proctime column is forced into the build-side
expansion and reaches
+ // the rule as a proctime (not rowtime) indicator.
+ final String sql =
+ "SELECT probe.pk, s.bk, s.pt FROM probe JOIN LATERAL SNAPSHOT("
+ + "input => TABLE b_proctime_wm, on_time =>
DESCRIPTOR(pt), "
+ + "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";
+ assertThatThrownBy(() -> util.verifyRelPlan(sql))
+ .isInstanceOf(ValidationException.class)
+ .hasStackTraceContaining(
+ "Argument 'on_time' of SNAPSHOT must reference a
rowtime attribute");
}
@Test
@@ -362,7 +456,7 @@ public class LateralSnapshotJoinTest extends TableTestBase {
+ ")");
final String sql =
"SELECT * FROM probe_updates JOIN LATERAL SNAPSHOT("
- + "input => TABLE b, "
+ + "input => TABLE b, on_time => DESCRIPTOR(bts), "
+ "load_completed_condition => 'user_time', "
+ "load_completed_time => CAST(TIMESTAMP '2026-07-01
00:00:00' AS TIMESTAMP_LTZ(3))"
+ ") AS s "
@@ -377,7 +471,7 @@ public class LateralSnapshotJoinTest extends TableTestBase {
void testRejectMissingEqualityPredicate() {
final String sql =
"SELECT * FROM probe JOIN LATERAL SNAPSHOT("
- + "input => TABLE b, "
+ + "input => TABLE b, on_time => DESCRIPTOR(bts), "
+ "load_completed_condition => 'user_time', "
+ "load_completed_time => CAST(TIMESTAMP '2026-07-01
00:00:00' AS TIMESTAMP_LTZ(3))"
+ ") AS s "
@@ -392,7 +486,7 @@ public class LateralSnapshotJoinTest extends TableTestBase {
void testRejectNonConstantCondition() {
final String sql =
"SELECT * FROM probe JOIN LATERAL SNAPSHOT("
- + "input => TABLE b, "
+ + "input => TABLE b, on_time => DESCRIPTOR(bts), "
+ "load_completed_condition => CAST(CURRENT_TIMESTAMP
AS STRING)"
+ ") AS s ON probe.pk = s.bk";
assertThatThrownBy(() -> util.verifyRelPlan(sql))
@@ -405,7 +499,7 @@ public class LateralSnapshotJoinTest extends TableTestBase {
void testRejectNonConstantLoadCompletedTime() {
final String sql =
"SELECT * FROM probe JOIN LATERAL SNAPSHOT("
- + "input => TABLE b, "
+ + "input => TABLE b, on_time => DESCRIPTOR(bts), "
+ "load_completed_condition => 'user_time', "
+ "load_completed_time => CURRENT_TIMESTAMP"
+ ") AS s ON probe.pk = s.bk";
@@ -419,7 +513,7 @@ public class LateralSnapshotJoinTest extends TableTestBase {
void testRejectNonConstantIdleTimeout() {
final String sql =
"SELECT * FROM probe JOIN LATERAL SNAPSHOT("
- + "input => TABLE b, "
+ + "input => TABLE b, on_time => DESCRIPTOR(bts), "
+ "load_completed_condition => 'user_time', "
+ "load_completed_time => CAST(TIMESTAMP '2026-07-01
00:00:00' AS TIMESTAMP_LTZ(3)), "
+ "load_completed_idle_timeout => "
@@ -435,7 +529,7 @@ public class LateralSnapshotJoinTest extends TableTestBase {
void testRejectNonConstantStateTtl() {
final String sql =
"SELECT * FROM probe JOIN LATERAL SNAPSHOT("
- + "input => TABLE b, "
+ + "input => TABLE b, on_time => DESCRIPTOR(bts), "
+ "load_completed_condition => 'user_time', "
+ "load_completed_time => CAST(TIMESTAMP '2026-07-01
00:00:00' AS TIMESTAMP_LTZ(3)), "
+ "state_ttl => "
@@ -451,7 +545,7 @@ public class LateralSnapshotJoinTest extends TableTestBase {
void testRejectYearMonthIntervalStateTtl() {
final String sql =
"SELECT * FROM probe JOIN LATERAL SNAPSHOT("
- + "input => TABLE b, "
+ + "input => TABLE b, on_time => DESCRIPTOR(bts), "
+ "load_completed_condition => 'user_time', "
+ "load_completed_time => CAST(TIMESTAMP '2026-07-01
00:00:00' AS TIMESTAMP_LTZ(3)), "
+ "state_ttl => INTERVAL '1' YEAR"
@@ -465,7 +559,7 @@ public class LateralSnapshotJoinTest extends TableTestBase {
void testRejectNegativeIdleTimeout() {
final String sql =
"SELECT * FROM probe JOIN LATERAL SNAPSHOT("
- + "input => TABLE b, "
+ + "input => TABLE b, on_time => DESCRIPTOR(bts), "
+ "load_completed_condition => 'user_time', "
+ "load_completed_time => CAST(TIMESTAMP '2026-07-01
00:00:00' AS TIMESTAMP_LTZ(3)), "
+ "load_completed_idle_timeout => INTERVAL -'10'
SECOND"
@@ -480,7 +574,7 @@ public class LateralSnapshotJoinTest extends TableTestBase {
void testRejectNegativeStateTtl() {
final String sql =
"SELECT * FROM probe JOIN LATERAL SNAPSHOT("
- + "input => TABLE b, "
+ + "input => TABLE b, on_time => DESCRIPTOR(bts), "
+ "load_completed_condition => 'user_time', "
+ "load_completed_time => CAST(TIMESTAMP '2026-07-01
00:00:00' AS TIMESTAMP_LTZ(3)), "
+ "state_ttl => INTERVAL -'10' MINUTE"
@@ -499,7 +593,7 @@ public class LateralSnapshotJoinTest extends TableTestBase {
void testInnerJoinJsonPlan() {
util.verifyJsonPlan(
"INSERT INTO sink SELECT * FROM probe JOIN LATERAL SNAPSHOT("
- + "input => TABLE b, "
+ + "input => TABLE b, on_time => DESCRIPTOR(bts), "
+ "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");
@@ -509,7 +603,7 @@ public class LateralSnapshotJoinTest extends TableTestBase {
void testLeftJoinJsonPlan() {
util.verifyJsonPlan(
"INSERT INTO sink SELECT * FROM probe LEFT JOIN LATERAL
SNAPSHOT("
- + "input => TABLE b, "
+ + "input => TABLE b, on_time => DESCRIPTOR(bts), "
+ "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");
@@ -519,7 +613,7 @@ public class LateralSnapshotJoinTest extends TableTestBase {
void testInnerJoinWithIdleTimeoutAndStateTtlJsonPlan() {
util.verifyJsonPlan(
"INSERT INTO sink SELECT * FROM probe JOIN LATERAL SNAPSHOT("
- + "input => TABLE b, "
+ + "input => TABLE b, on_time => DESCRIPTOR(bts), "
+ "load_completed_condition => 'user_time', "
+ "load_completed_time => CAST(TIMESTAMP '2026-07-01
00:00:00' AS TIMESTAMP_LTZ(3)), "
+ "load_completed_idle_timeout => INTERVAL '10'
SECOND, "
@@ -531,7 +625,7 @@ public class LateralSnapshotJoinTest extends TableTestBase {
void testInnerJoinWithCompositeKeysJsonPlan() {
util.verifyJsonPlan(
"INSERT INTO sink SELECT * FROM probe JOIN LATERAL SNAPSHOT("
- + "input => TABLE b, "
+ + "input => TABLE b, on_time => DESCRIPTOR(bts), "
+ "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");
@@ -541,7 +635,7 @@ public class LateralSnapshotJoinTest extends TableTestBase {
void testInnerJoinWithNonEquiConditionJsonPlan() {
util.verifyJsonPlan(
"INSERT INTO sink SELECT * FROM probe JOIN LATERAL SNAPSHOT("
- + "input => TABLE b, "
+ + "input => TABLE b, on_time => DESCRIPTOR(bts), "
+ "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");
@@ -558,7 +652,7 @@ public class LateralSnapshotJoinTest extends TableTestBase {
.getConfig()
.set(ExecutionConfigOptions.IDLE_STATE_RETENTION,
Duration.ofHours(12));
- assertThat(resolveStateTtlMs("SNAPSHOT(input => TABLE b)"))
+ assertThat(resolveStateTtlMs("SNAPSHOT(input => TABLE b, on_time =>
DESCRIPTOR(bts))"))
.isEqualTo(Duration.ofHours(12).toMillis());
}
@@ -568,7 +662,9 @@ public class LateralSnapshotJoinTest extends TableTestBase {
.getConfig()
.set(ExecutionConfigOptions.IDLE_STATE_RETENTION,
Duration.ofHours(12));
- assertThat(resolveStateTtlMs("SNAPSHOT(input => TABLE b, state_ttl =>
INTERVAL '1' DAY)"))
+ assertThat(
+ resolveStateTtlMs(
+ "SNAPSHOT(input => TABLE b, on_time =>
DESCRIPTOR(bts), state_ttl => INTERVAL '1' DAY)"))
.isEqualTo(Duration.ofDays(1).toMillis());
}
@@ -576,7 +672,8 @@ public class LateralSnapshotJoinTest extends TableTestBase {
void testStateTtlDisabledWhenNeitherArgNorPipelineTtlSet() {
util.tableEnv().getConfig().set(ExecutionConfigOptions.IDLE_STATE_RETENTION,
Duration.ZERO);
- assertThat(resolveStateTtlMs("SNAPSHOT(input => TABLE b)")).isZero();
+ assertThat(resolveStateTtlMs("SNAPSHOT(input => TABLE b, on_time =>
DESCRIPTOR(bts))"))
+ .isZero();
}
/**
diff --git
a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/runtime/stream/sql/join/LateralSnapshotJoinITCase.java
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/runtime/stream/sql/join/LateralSnapshotJoinITCase.java
index 7715e6e1d41..36f32230f0f 100644
---
a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/runtime/stream/sql/join/LateralSnapshotJoinITCase.java
+++
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/runtime/stream/sql/join/LateralSnapshotJoinITCase.java
@@ -110,7 +110,7 @@ public class LateralSnapshotJoinITCase extends
StreamingWithStateTestBase {
final List<Row> actual =
collect(
"SELECT probe.pk, s.bv FROM probe JOIN LATERAL
SNAPSHOT("
- + "input => TABLE b, "
+ + "input => TABLE b, on_time =>
DESCRIPTOR(bts), "
+ MID_FLIP
+ ") AS s ON probe.pk = s.bk");
@@ -132,7 +132,7 @@ public class LateralSnapshotJoinITCase extends
StreamingWithStateTestBase {
final List<Row> actual =
collect(
"SELECT probe.pk, s.bv FROM probe JOIN LATERAL
SNAPSHOT("
- + "input => TABLE b, "
+ + "input => TABLE b, on_time =>
DESCRIPTOR(bts), "
+ MID_FLIP
+ ") AS s ON probe.pk = s.bk");
@@ -168,7 +168,7 @@ public class LateralSnapshotJoinITCase extends
StreamingWithStateTestBase {
sortedByProbeId(
collect(
"SELECT probe.pv, s.bv FROM probe JOIN LATERAL
SNAPSHOT("
- + "input => TABLE b, "
+ + "input => TABLE b, on_time =>
DESCRIPTOR(bts), "
+ MID_FLIP
+ ") AS s ON probe.pk = s.bk"));
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
index d69cb1baf0e..8f6ec484a13 100644
---
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
@@ -25,7 +25,7 @@ limitations under the License.
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)])
+ +- LogicalTableFunctionScan(invocation=[SNAPSHOT(TABLE(#0), DEFAULT(),
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]])
@@ -51,7 +51,7 @@ HashJoin(joinType=[InnerJoin], where=[=(pk, bk)], select=[pk,
bk, bv, pt], build
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)])
+ +- LogicalTableFunctionScan(invocation=[SNAPSHOT(TABLE(#0), DEFAULT(),
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]])
]]>
@@ -75,7 +75,7 @@ HashJoin(joinType=[InnerJoin], where=[=(pk, bk)], select=[pk,
pv, pts, bk, bv, b
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)])
+ +- LogicalTableFunctionScan(invocation=[SNAPSHOT(TABLE(#0), DEFAULT(),
_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]])
]]>
@@ -99,7 +99,7 @@ HashJoin(joinType=[InnerJoin], where=[=(pk, bk)], select=[pk,
pv, pts, bk, bv, b
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)])
+ +- LogicalTableFunctionScan(invocation=[SNAPSHOT(TABLE(#0), DEFAULT(),
_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]])
]]>
@@ -123,7 +123,7 @@ HashJoin(joinType=[InnerJoin], where=[AND(=(pk, bk), =(pv,
bv))], select=[pk, pv
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)])
+ +- LogicalTableFunctionScan(invocation=[SNAPSHOT(TABLE(#0), DEFAULT(),
_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]])
]]>
@@ -147,7 +147,7 @@ HashJoin(joinType=[InnerJoin], where=[AND(=(pk, bk), >(pv,
bv))], select=[pk, pv
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)])
+ +- LogicalTableFunctionScan(invocation=[SNAPSHOT(TABLE(#0), DEFAULT(),
_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]])
]]>
@@ -173,7 +173,7 @@ Calc(select=[pk, pv, bv])
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)])
+ +- LogicalTableFunctionScan(invocation=[SNAPSHOT(TABLE(#0), DEFAULT(),
_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]])
]]>
diff --git
a/flink-table/flink-table-planner/src/test/resources/org/apache/flink/table/planner/plan/stream/sql/join/LateralSnapshotJoinTest.xml
b/flink-table/flink-table-planner/src/test/resources/org/apache/flink/table/planner/plan/stream/sql/join/LateralSnapshotJoinTest.xml
index f75754f8a09..885d12b4b1a 100644
---
a/flink-table/flink-table-planner/src/test/resources/org/apache/flink/table/planner/plan/stream/sql/join/LateralSnapshotJoinTest.xml
+++
b/flink-table/flink-table-planner/src/test/resources/org/apache/flink/table/planner/plan/stream/sql/join/LateralSnapshotJoinTest.xml
@@ -18,7 +18,7 @@ limitations under the License.
<Root>
<TestCase name="testBuildSideProctimeIsMaterialized">
<Resource name="sql">
- <![CDATA[SELECT probe.pk, s.bk, s.bv, s.pt FROM probe JOIN LATERAL
SNAPSHOT(input => TABLE b_proctime, 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]]>
+ <![CDATA[SELECT probe.pk, s.bk, s.bv, s.pt FROM probe JOIN LATERAL
SNAPSHOT(input => TABLE b_proctime, on_time => DESCRIPTOR(bts),
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[
@@ -26,7 +26,7 @@ LogicalProject(pk=[$0], bk=[$3], bv=[$4], pt=[$6])
+- LogicalJoin(condition=[=($0, $3)], joinType=[inner])
:- LogicalWatermarkAssigner(rowtime=[pts], watermark=[$2])
: +- 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,
TIMESTAMP_WITH_LOCAL_TIME_ZONE(3) pt)])
+ +- LogicalTableFunctionScan(invocation=[SNAPSHOT(TABLE(#0),
DESCRIPTOR(_UTF-16LE'bts'), _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,
TIMESTAMP_WITH_LOCAL_TIME_ZONE(3) pt)])
+- LogicalProject(bk=[$0], bv=[$1], bts=[$2], pt=[$3])
+- LogicalWatermarkAssigner(rowtime=[bts], watermark=[$2])
+- LogicalProject(bk=[$0], bv=[$1], bts=[$2], pt=[PROCTIME()])
@@ -51,7 +51,7 @@ Calc(select=[pk, bk, bv, pt])
</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]]>
+ <![CDATA[SELECT * FROM probe JOIN LATERAL TABLE(SNAPSHOT(input => TABLE
b, on_time => DESCRIPTOR(bts), 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[
@@ -59,7 +59,7 @@ LogicalProject(pk=[$0], pv=[$1], pts=[$2], bk=[$3], bv=[$4],
bts=[$5])
+- LogicalJoin(condition=[=($0, $3)], joinType=[inner])
:- LogicalWatermarkAssigner(rowtime=[pts], watermark=[$2])
: +- 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)])
+ +- LogicalTableFunctionScan(invocation=[SNAPSHOT(TABLE(#0),
DESCRIPTOR(_UTF-16LE'bts'), _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])
+- LogicalWatermarkAssigner(rowtime=[bts], watermark=[$2])
+- LogicalTableScan(table=[[default_catalog, default_database, b]])
@@ -79,7 +79,7 @@ LateralSnapshotJoin(joinType=[InnerJoin], where=[=(pk, bk)],
select=[pk, pv, pts
</TestCase>
<TestCase name="testInnerJoinWithCompositeKeys">
<Resource name="sql">
- <![CDATA[SELECT * FROM probe JOIN LATERAL 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]]>
+ <![CDATA[SELECT * FROM probe JOIN LATERAL SNAPSHOT(input => TABLE b,
on_time => DESCRIPTOR(bts), 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[
@@ -87,7 +87,7 @@ LogicalProject(pk=[$0], pv=[$1], pts=[$2], bk=[$3], bv=[$4],
bts=[$5])
+- LogicalJoin(condition=[AND(=($0, $3), =($1, $4))], joinType=[inner])
:- LogicalWatermarkAssigner(rowtime=[pts], watermark=[$2])
: +- 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)])
+ +- LogicalTableFunctionScan(invocation=[SNAPSHOT(TABLE(#0),
DESCRIPTOR(_UTF-16LE'bts'), _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])
+- LogicalWatermarkAssigner(rowtime=[bts], watermark=[$2])
+- LogicalTableScan(table=[[default_catalog, default_database, b]])
@@ -107,7 +107,7 @@ LateralSnapshotJoin(joinType=[InnerJoin], where=[AND(=(pk,
bk), =(pv, bv))], sel
</TestCase>
<TestCase name="testInnerJoinWithCteBuildSide">
<Resource name="sql">
- <![CDATA[WITH cte AS (SELECT bk, bv + 1 AS bv, bts FROM b) SELECT * FROM
probe JOIN LATERAL SNAPSHOT(input => TABLE cte, 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]]>
+ <![CDATA[WITH cte AS (SELECT bk, bv + 1 AS bv, bts FROM b) SELECT * FROM
probe JOIN LATERAL SNAPSHOT(input => TABLE cte, on_time => DESCRIPTOR(bts),
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[
@@ -115,7 +115,7 @@ LogicalProject(pk=[$0], pv=[$1], pts=[$2], bk=[$3],
bv=[$4], bts=[$5])
+- LogicalJoin(condition=[=($0, $3)], joinType=[inner])
:- LogicalWatermarkAssigner(rowtime=[pts], watermark=[$2])
: +- 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)])
+ +- LogicalTableFunctionScan(invocation=[SNAPSHOT(TABLE(#0),
DESCRIPTOR(_UTF-16LE'bts'), _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])
+- LogicalProject(bk=[$0], bv=[+($1, 1)], bts=[$2])
+- LogicalWatermarkAssigner(rowtime=[bts], watermark=[$2])
@@ -137,7 +137,7 @@ LateralSnapshotJoin(joinType=[InnerJoin], where=[=(pk,
bk)], select=[pk, pv, pts
</TestCase>
<TestCase name="testInnerJoinWithIdleTimeoutAndStateTtl">
<Resource name="sql">
- <![CDATA[SELECT * FROM probe JOIN LATERAL SNAPSHOT(input => TABLE b,
load_completed_condition => 'user_time', load_completed_time => CAST(TIMESTAMP
'2026-07-01 00:00:00' AS TIMESTAMP_LTZ(3)), load_completed_idle_timeout =>
INTERVAL '10' SECOND, state_ttl => INTERVAL '1' DAY) AS s ON probe.pk = s.bk]]>
+ <![CDATA[SELECT * FROM probe JOIN LATERAL SNAPSHOT(input => TABLE b,
on_time => DESCRIPTOR(bts), load_completed_condition => 'user_time',
load_completed_time => CAST(TIMESTAMP '2026-07-01 00:00:00' AS
TIMESTAMP_LTZ(3)), load_completed_idle_timeout => INTERVAL '10' SECOND,
state_ttl => INTERVAL '1' DAY) AS s ON probe.pk = s.bk]]>
</Resource>
<Resource name="ast">
<![CDATA[
@@ -145,7 +145,7 @@ LogicalProject(pk=[$0], pv=[$1], pts=[$2], bk=[$3],
bv=[$4], bts=[$5])
+- LogicalJoin(condition=[=($0, $3)], joinType=[inner])
:- LogicalWatermarkAssigner(rowtime=[pts], watermark=[$2])
: +- 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, 10000:INTERVAL SECOND,
86400000:INTERVAL DAY)], rowType=[RecordType(VARCHAR(2147483647) bk, INTEGER
bv, TIMESTAMP(3) bts)])
+ +- LogicalTableFunctionScan(invocation=[SNAPSHOT(TABLE(#0),
DESCRIPTOR(_UTF-16LE'bts'), _UTF-16LE'user_time', CAST(2026-07-01
00:00:00):TIMESTAMP_WITH_LOCAL_TIME_ZONE(3) NOT NULL, 10000:INTERVAL SECOND,
86400000:INTERVAL DAY)], rowType=[RecordType(VARCHAR(2147483647) bk, INTEGER
bv, TIMESTAMP(3) bts)])
+- LogicalProject(bk=[$0], bv=[$1], bts=[$2])
+- LogicalWatermarkAssigner(rowtime=[bts], watermark=[$2])
+- LogicalTableScan(table=[[default_catalog, default_database, b]])
@@ -165,7 +165,7 @@ LateralSnapshotJoin(joinType=[InnerJoin], where=[=(pk,
bk)], select=[pk, pv, pts
</TestCase>
<TestCase name="testInnerJoinWithNonEquiCondition">
<Resource name="sql">
- <![CDATA[SELECT * FROM probe JOIN LATERAL 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]]>
+ <![CDATA[SELECT * FROM probe JOIN LATERAL SNAPSHOT(input => TABLE b,
on_time => DESCRIPTOR(bts), 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[
@@ -173,7 +173,7 @@ LogicalProject(pk=[$0], pv=[$1], pts=[$2], bk=[$3],
bv=[$4], bts=[$5])
+- LogicalJoin(condition=[AND(=($0, $3), >($1, $4))], joinType=[inner])
:- LogicalWatermarkAssigner(rowtime=[pts], watermark=[$2])
: +- 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)])
+ +- LogicalTableFunctionScan(invocation=[SNAPSHOT(TABLE(#0),
DESCRIPTOR(_UTF-16LE'bts'), _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])
+- LogicalWatermarkAssigner(rowtime=[bts], watermark=[$2])
+- LogicalTableScan(table=[[default_catalog, default_database, b]])
@@ -193,7 +193,7 @@ LateralSnapshotJoin(joinType=[InnerJoin], where=[AND(=(pk,
bk), >(pv, bv))], sel
</TestCase>
<TestCase name="testInnerJoinWithTimeAttributeInCondition">
<Resource name="sql">
- <![CDATA[SELECT * FROM probe JOIN LATERAL 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.pts >= s.bts]]>
+ <![CDATA[SELECT * FROM probe JOIN LATERAL SNAPSHOT(input => TABLE b,
on_time => DESCRIPTOR(bts), 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.pts >= s.bts]]>
</Resource>
<Resource name="ast">
<![CDATA[
@@ -201,7 +201,7 @@ LogicalProject(pk=[$0], pv=[$1], pts=[$2], bk=[$3],
bv=[$4], bts=[$5])
+- LogicalJoin(condition=[AND(=($0, $3), >=($2, $5))], joinType=[inner])
:- LogicalWatermarkAssigner(rowtime=[pts], watermark=[$2])
: +- 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)])
+ +- LogicalTableFunctionScan(invocation=[SNAPSHOT(TABLE(#0),
DESCRIPTOR(_UTF-16LE'bts'), _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])
+- LogicalWatermarkAssigner(rowtime=[bts], watermark=[$2])
+- LogicalTableScan(table=[[default_catalog, default_database, b]])
@@ -221,7 +221,7 @@ LateralSnapshotJoin(joinType=[InnerJoin], where=[AND(=(pk,
bk), >=(pts, bts))],
</TestCase>
<TestCase name="testInnerJoinWithoutBuildTimeColumn">
<Resource name="sql">
- <![CDATA[SELECT probe.pk, probe.pv, s.bv FROM probe JOIN LATERAL
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]]>
+ <![CDATA[SELECT probe.pk, probe.pv, s.bv FROM probe JOIN LATERAL
SNAPSHOT(input => TABLE b, on_time => DESCRIPTOR(bts), 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[
@@ -229,7 +229,7 @@ LogicalProject(pk=[$0], pv=[$1], bv=[$4])
+- LogicalJoin(condition=[=($0, $3)], joinType=[inner])
:- LogicalWatermarkAssigner(rowtime=[pts], watermark=[$2])
: +- 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)])
+ +- LogicalTableFunctionScan(invocation=[SNAPSHOT(TABLE(#0),
DESCRIPTOR(_UTF-16LE'bts'), _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])
+- LogicalWatermarkAssigner(rowtime=[bts], watermark=[$2])
+- LogicalTableScan(table=[[default_catalog, default_database, b]])
@@ -251,7 +251,7 @@ Calc(select=[pk, pv, bv])
</TestCase>
<TestCase name="testLeftJoin">
<Resource name="sql">
- <![CDATA[SELECT * FROM probe LEFT JOIN LATERAL 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]]>
+ <![CDATA[SELECT * FROM probe LEFT JOIN LATERAL SNAPSHOT(input => TABLE
b, on_time => DESCRIPTOR(bts), 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[
@@ -259,7 +259,7 @@ LogicalProject(pk=[$0], pv=[$1], pts=[$2], bk=[$3],
bv=[$4], bts=[$5])
+- LogicalJoin(condition=[=($0, $3)], joinType=[left])
:- LogicalWatermarkAssigner(rowtime=[pts], watermark=[$2])
: +- 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)])
+ +- LogicalTableFunctionScan(invocation=[SNAPSHOT(TABLE(#0),
DESCRIPTOR(_UTF-16LE'bts'), _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])
+- LogicalWatermarkAssigner(rowtime=[bts], watermark=[$2])
+- LogicalTableScan(table=[[default_catalog, default_database, b]])
@@ -279,7 +279,7 @@ LateralSnapshotJoin(joinType=[LeftOuterJoin], where=[=(pk,
bk)], select=[pk, pv,
</TestCase>
<TestCase name="testLeftJoinWithoutBuildTimeColumn">
<Resource name="sql">
- <![CDATA[SELECT probe.pk, probe.pv, s.bv FROM probe LEFT JOIN LATERAL
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]]>
+ <![CDATA[SELECT probe.pk, probe.pv, s.bv FROM probe LEFT JOIN LATERAL
SNAPSHOT(input => TABLE b, on_time => DESCRIPTOR(bts), 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[
@@ -287,7 +287,7 @@ LogicalProject(pk=[$0], pv=[$1], bv=[$4])
+- LogicalJoin(condition=[=($0, $3)], joinType=[left])
:- LogicalWatermarkAssigner(rowtime=[pts], watermark=[$2])
: +- 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)])
+ +- LogicalTableFunctionScan(invocation=[SNAPSHOT(TABLE(#0),
DESCRIPTOR(_UTF-16LE'bts'), _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])
+- LogicalWatermarkAssigner(rowtime=[bts], watermark=[$2])
+- LogicalTableScan(table=[[default_catalog, default_database, b]])