This is an automated email from the ASF dual-hosted git repository.
dianfu pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/flink.git
The following commit(s) were added to refs/heads/master by this push:
new dd8eeed0d02 [FLINK-40424][table][python] Support windowing TVFs in the
DataFrame API (#29070)
dd8eeed0d02 is described below
commit dd8eeed0d0216d79ee19218ef256e9869fa19d31
Author: Timo Theusner <[email protected]>
AuthorDate: Fri Sep 11 16:03:03 2026 +0200
[FLINK-40424][table][python] Support windowing TVFs in the DataFrame API
(#29070)
---
.../docs/reference/pyflink.dataframe/dataframe.rst | 13 +
flink-python/pyflink/dataframe/dataframe.py | 243 ++++++++++++++-
.../pyflink/dataframe/tests/test_dataframe.py | 293 +++++++++++++++++-
.../table/operations/QueryOperationVisitor.java | 2 +
.../WindowTableFunctionQueryOperation.java | 222 ++++++++++++++
.../operations/utils/OperationTreeBuilder.java | 70 +++++
.../utils/QueryOperationDefaultVisitor.java | 6 +
.../WindowTableFunctionQueryOperationTest.java | 193 ++++++++++++
.../test/program/FailingTableApiTestStep.java | 13 +
.../flink/table/test/program/TableApiTestStep.java | 19 ++
.../planner/plan/QueryOperationConverter.java | 74 +++++
.../table/api/QueryOperationSqlSemanticTest.java | 5 +
.../api/QueryOperationSqlSerializationTest.java | 5 +
.../table/api/QueryOperationTestPrograms.java | 333 +++++++++++++++++++++
.../plan/WindowTableFunctionRelNodeTest.java | 179 +++++++++++
.../plan/WindowTableFunctionRelNodeTest.xml | 101 +++++++
16 files changed, 1762 insertions(+), 9 deletions(-)
diff --git a/flink-python/docs/reference/pyflink.dataframe/dataframe.rst
b/flink-python/docs/reference/pyflink.dataframe/dataframe.rst
index ca7cecc499f..c4f36fb8ae6 100644
--- a/flink-python/docs/reference/pyflink.dataframe/dataframe.rst
+++ b/flink-python/docs/reference/pyflink.dataframe/dataframe.rst
@@ -116,6 +116,19 @@ Results
DataFrame.to_table
DataFrame.to_pandas
+Windowing
+---------
+
+.. currentmodule:: pyflink.dataframe
+
+.. autosummary::
+ :toctree: api/
+
+ DataFrame.tumble
+ DataFrame.hop
+ DataFrame.cumulate
+ DataFrame.session
+
Expressions
-----------
diff --git a/flink-python/pyflink/dataframe/dataframe.py
b/flink-python/pyflink/dataframe/dataframe.py
index ed2cf3dab1c..426e3a5640d 100644
--- a/flink-python/pyflink/dataframe/dataframe.py
+++ b/flink-python/pyflink/dataframe/dataframe.py
@@ -16,6 +16,7 @@
# limitations under the License.
################################################################################
+import datetime
from typing import (
TYPE_CHECKING,
Any,
@@ -609,7 +610,7 @@ class DataFrame:
if partition_by is None or (isinstance(partition_by, (list, tuple))
and not partition_by):
partition_keys = []
else:
- partition_keys = _normalize_subset(partition_by)
+ partition_keys = _normalize_subset(partition_by, "partition_by")
descending_flags = _normalize_descending(descending, len(order_keys))
nulls = _normalize_nulls_first(nulls_first, len(order_keys))
@@ -695,6 +696,160 @@ class DataFrame:
order_expressions.append(expression.desc if is_descending else
expression.asc)
return DataFrame(self._table.order_by(*order_expressions))
+ # ======================== Windowing ========================
+
+ @PublicEvolving()
+ def tumble(
+ self,
+ *,
+ on: Union[str, Expression],
+ size: Union["datetime.timedelta", Expression],
+ ) -> "DataFrame":
+ """
+ Assign rows to fixed-size, non-overlapping (tumbling) windows.
+
+ Appends ``window_start``, ``window_end`` and ``window_time`` and
returns an ordinary
+ DataFrame.
+
+ :param on: An existing event-time or processing-time column.
+ :param size: Window length.
+ :return: A new DataFrame with the window columns appended.
+ :raises TypeError: If ``on`` or ``size`` has an unsupported type.
+
+ Example::
+
+ >>> import pyflink.dataframe as pf
+ >>> from datetime import timedelta
+ >>> windowed = df.tumble(on="event_time",
size=timedelta(minutes=10))
+
+ .. versionadded:: 2.4.0
+ """
+ time_col = _resolve_window_time_column(on)
+ return _window_dataframe(
+ self._table, "TUMBLE", time_col, _to_interval_expression(size)
+ )
+
+ @PublicEvolving()
+ def hop(
+ self,
+ *,
+ on: Union[str, Expression],
+ slide: Union["datetime.timedelta", Expression],
+ size: Union["datetime.timedelta", Expression],
+ ) -> "DataFrame":
+ """
+ Assign rows to overlapping fixed-size (hopping/sliding) windows of
length ``size`` starting
+ every ``slide``. Appends
``window_start``/``window_end``/``window_time``.
+
+ :param on: An existing event-time or processing-time column.
+ :param slide: Interval between successive window starts.
+ :param size: Window length.
+ :return: A new DataFrame with the window columns appended.
+
+ Example::
+
+ >>> import pyflink.dataframe as pf
+ >>> from datetime import timedelta
+ >>> windowed = df.hop(
+ ... on="event_time",
+ ... slide=timedelta(minutes=5),
+ ... size=timedelta(minutes=10),
+ ... )
+
+ .. versionadded:: 2.4.0
+ """
+ time_col = _resolve_window_time_column(on)
+ return _window_dataframe(
+ self._table,
+ "HOP",
+ time_col,
+ _to_interval_expression(slide),
+ _to_interval_expression(size),
+ )
+
+ @PublicEvolving()
+ def cumulate(
+ self,
+ *,
+ on: Union[str, Expression],
+ step: Union["datetime.timedelta", Expression],
+ size: Union["datetime.timedelta", Expression],
+ ) -> "DataFrame":
+ """
+ Assign rows to cumulating windows that share a start and grow by
``step`` up to ``size``.
+ Appends ``window_start``/``window_end``/``window_time``.
+
+ :param on: An existing event-time or processing-time column.
+ :param step: Interval by which each window grows.
+ :param size: Maximum window length (a whole multiple of ``step``).
+ :return: A new DataFrame with the window columns appended.
+
+ Example::
+
+ >>> import pyflink.dataframe as pf
+ >>> from datetime import timedelta
+ >>> windowed = df.cumulate(
+ ... on="event_time",
+ ... step=timedelta(minutes=5),
+ ... size=timedelta(minutes=10),
+ ... )
+
+ .. versionadded:: 2.4.0
+ """
+ time_col = _resolve_window_time_column(on)
+ return _window_dataframe(
+ self._table,
+ "CUMULATE",
+ time_col,
+ _to_interval_expression(step),
+ _to_interval_expression(size),
+ )
+
+ @PublicEvolving()
+ def session(
+ self,
+ *,
+ on: Union[str, Expression],
+ gap: Union["datetime.timedelta", Expression],
+ partition_by: Optional[
+ Union[str, Expression, List[Union[str, Expression]]]
+ ] = None,
+ ) -> "DataFrame":
+ """
+ Assign rows to activity-based (session) windows that close after
``gap`` of inactivity.
+ Appends ``window_start``/``window_end``/``window_time``.
+ When ``partition_by`` is given, sessions are computed independently
per key, so a gap in
+ one key's activity does not close another key's session.
+
+ :param on: An existing event-time or processing-time column.
+ :param gap: Inactivity gap that closes a session.
+ :param partition_by: Optional column name or expression or list of
column names or
+ expressions to compute per-key sessions. ``None`` means a global
session.
+ :return: A new DataFrame with the window columns appended.
+ :raises TypeError: If ``partition_by`` contains an unsupported element
type.
+
+ Example::
+
+ >>> import pyflink.dataframe as pf
+ >>> from datetime import timedelta
+ >>> windowed = df.session(
+ ... on="event_time",
+ ... gap=timedelta(minutes=10),
+ ... partition_by="id",
+ ... )
+
+ .. versionadded:: 2.4.0
+ """
+ time_col = _resolve_window_time_column(on)
+ partition_cols = _resolve_partition_columns(partition_by)
+ return _window_dataframe(
+ self._table,
+ "SESSION",
+ time_col,
+ _to_interval_expression(gap),
+ partition_cols=partition_cols,
+ )
+
# ======================== Slicing ========================
@PublicEvolving()
@@ -1148,19 +1303,23 @@ class GroupedDataFrame:
# ======================== Internal Helpers ========================
-def _normalize_subset(subset: Union[str, List[str], None]) ->
Optional[List[str]]:
+def _normalize_subset(
+ subset: Union[str, List[str], None], parameter_name: str = "subset"
+) -> Optional[List[str]]:
if subset is None:
return None
if isinstance(subset, str):
return [subset]
if isinstance(subset, (list, tuple)):
if not subset:
- raise ValueError("subset must not be empty")
+ raise ValueError("%s must not be empty" % parameter_name)
for name in subset:
if not isinstance(name, str):
- raise TypeError("subset must be a string or a list of strings")
+ raise TypeError(
+ "%s must be a string or a list of strings" % parameter_name
+ )
return list(subset)
- raise TypeError("subset must be a string or a list of strings")
+ raise TypeError("%s must be a string or a list of strings" %
parameter_name)
def _normalize_order_by(
@@ -1324,6 +1483,80 @@ def _quote_identifier(name: str) -> str:
return "`" + name.replace("`", "``") + "`"
+def _resolve_window_time_column(on: Union[str, Expression]) -> Expression:
+ if isinstance(on, str):
+ return table_col(on)
+ if isinstance(on, Expression):
+ return on
+ raise TypeError("on must be a column name or expression")
+
+
+def _resolve_partition_columns(
+ partition_by: Optional[Union[str, Expression, List[Union[str,
Expression]]]]
+) -> List[Expression]:
+ if partition_by is None:
+ return []
+ if isinstance(partition_by, (str, Expression)):
+ candidates: List[Union[str, Expression]] = [partition_by]
+ else:
+ candidates = partition_by
+ columns: List[Expression] = []
+ for candidate in candidates:
+ if isinstance(candidate, str):
+ columns.append(table_col(candidate))
+ elif isinstance(candidate, Expression):
+ columns.append(candidate)
+ else:
+ raise TypeError(
+ "partition_by must be a column name, expression, or a list of
them"
+ )
+ return columns
+
+
+def _window_dataframe(
+ table: Table,
+ window_kind: str,
+ time_col: Expression,
+ *interval_exprs: Any,
+ partition_cols: List[Expression] = [],
+) -> "DataFrame":
+ jvm = get_gateway().jvm
+ kind = getattr(
+
jvm.org.apache.flink.table.operations.WindowTableFunctionQueryOperation.WindowKind,
+ window_kind,
+ )
+ intervals = jvm.java.util.ArrayList()
+ for interval in interval_exprs:
+ intervals.add(interval)
+ partition_list = jvm.java.util.ArrayList()
+ for partition_col in partition_cols:
+ partition_list.add(partition_col._j_expr)
+ operation_tree_builder = table._t_env._j_tenv.getOperationTreeBuilder()
+ window_op = operation_tree_builder.windowTableFunction(
+ kind,
+ time_col._j_expr,
+ intervals,
+ partition_list,
+ table._j_table.getQueryOperation(),
+ )
+ j_table = table._t_env._j_tenv.createTable(window_op)
+ return DataFrame(Table(j_table, table._t_env))
+
+
+def _to_interval_expression(value: Union["datetime.timedelta", Expression]) ->
Any:
+ if isinstance(value, datetime.timedelta):
+ millis = value // datetime.timedelta(milliseconds=1)
+ return (
+ get_gateway()
+
.jvm.org.apache.flink.table.expressions.ApiExpressionUtils.intervalOfMillis(
+ millis
+ )
+ )
+ if isinstance(value, Expression):
+ return value._j_expr
+ raise TypeError("interval must be a datetime.timedelta or Expression")
+
+
def _normalize_aggregations(
aggs: Tuple[Expression, ...], named_aggs: Dict[str, Expression]
) -> List[Expression]:
diff --git a/flink-python/pyflink/dataframe/tests/test_dataframe.py
b/flink-python/pyflink/dataframe/tests/test_dataframe.py
index d51d82c857d..f104db9242f 100644
--- a/flink-python/pyflink/dataframe/tests/test_dataframe.py
+++ b/flink-python/pyflink/dataframe/tests/test_dataframe.py
@@ -16,19 +16,25 @@
# limitations under the License.
################################################################################
-import inspect
import array
import decimal
+import inspect
+import os
+import pandas as pd
+import pyarrow as pa
import unittest
-from py4j.protocol import Py4JJavaError
from datetime import date, datetime, time, timedelta, timezone
+from py4j.protocol import Py4JJavaError
from typing import NamedTuple
from unittest.mock import Mock, patch
-import pandas as pd
-import pyarrow as pa
import pyflink.dataframe as pf
from pyflink.common import Row
+from pyflink.dataframe.dataframe import (
+ _resolve_window_time_column,
+ _to_interval_expression,
+)
+from pyflink.dataframe.datatype import DataType
from pyflink.table import (
DataTypes as TableDataTypes,
EnvironmentSettings,
@@ -1567,6 +1573,147 @@ class DataFrameUniqueTests(PyFlinkDataFrameUTTestCase):
)
+class DataFrameWindowUnitTests(PyFlinkDataFrameUTTestCase):
+ _WINDOW_SCHEMA_COLUMN_NAMES = [
+ "id",
+ "amount",
+ "ts",
+ "window_start",
+ "window_end",
+ "window_time",
+ ]
+
+ def _window_schema_column_data_types(self):
+ source_types = list(
+ self.dataframe._table.get_resolved_schema().get_column_data_types()
+ )
+ time_column_type = source_types[2]
+ return source_types + [
+ TableDataTypes.TIMESTAMP(3).not_null(),
+ TableDataTypes.TIMESTAMP(3).not_null(),
+ time_column_type.not_null(),
+ ]
+
+ def setUp(self):
+ super().setUp()
+ self.dataframe = pf.from_records(
+ [{"id": 1, "amount": 10, "ts": datetime(2020, 1, 1)}],
+ watermark=("ts", "ts - INTERVAL '1' SECOND"),
+ )
+
+ def test_timedelta_becomes_java_value_literal(self):
+ serialized = _to_interval_expression(
+ timedelta(minutes=10)
+ ).asSerializableString()
+
+ self.assertIn("INTERVAL", serialized)
+ self.assertIn("00:10:00", serialized)
+
+ def test_sub_millisecond_precision_is_truncated_not_rounded(self):
+ truncated = _to_interval_expression(
+ timedelta(milliseconds=1, microseconds=500)
+ ).asSerializableString()
+ whole = _to_interval_expression(
+ timedelta(milliseconds=1)
+ ).asSerializableString()
+
+ self.assertEqual(truncated, whole)
+
+ def test_sub_millisecond_timedelta_truncates_to_zero(self):
+ serialized = _to_interval_expression(
+ timedelta(microseconds=500)
+ ).asSerializableString()
+
+ zero = _to_interval_expression(timedelta(0)).asSerializableString()
+
+ self.assertEqual(serialized, zero)
+
+ def test_expression_is_unwrapped_to_j_expr(self):
+ expr = pf.lit(10).minutes
+
+ self.assertIs(_to_interval_expression(expr), expr._j_expr)
+
+ def test_rejects_unsupported_type(self):
+ with self.assertRaisesRegex(TypeError, "must be a datetime.timedelta
or Expression"):
+ _to_interval_expression("INTERVAL '10' MINUTE")
+
+ def test_accepts_string_column(self):
+ resolved = _resolve_window_time_column("ts")
+
+ self.assertIsInstance(resolved, Expression)
+ self.assertEqual(str(resolved), "ts")
+
+ def test_accepts_column_expression(self):
+ column = pf.col("ts")
+
+ self.assertIs(_resolve_window_time_column(column), column)
+
+ def test_rejects_wrong_type(self):
+ with self.assertRaisesRegex(TypeError, "on must be a column name or
expression"):
+ _resolve_window_time_column(10)
+
+ def test_tumble_appends_window_columns_timedelta(self):
+ self.assert_dataframe_schema(
+ self.dataframe.tumble(on="ts", size=timedelta(minutes=10)),
+ self._WINDOW_SCHEMA_COLUMN_NAMES,
+ expected_column_data_types=self._window_schema_column_data_types(),
+ )
+
+ def test_tumble_appends_window_columns_expression(self):
+ self.assert_dataframe_schema(
+ self.dataframe.tumble(on="ts", size=pf.lit(10).minutes),
+ self._WINDOW_SCHEMA_COLUMN_NAMES,
+ )
+
+ def test_hop_appends_window_columns(self):
+ self.assert_dataframe_schema(
+ self.dataframe.hop(
+ on="ts",
+ slide=timedelta(minutes=5),
+ size=timedelta(minutes=10),
+ ),
+ self._WINDOW_SCHEMA_COLUMN_NAMES,
+ expected_column_data_types=self._window_schema_column_data_types(),
+ )
+
+ def test_cumulate_appends_window_columns(self):
+ self.assert_dataframe_schema(
+ self.dataframe.cumulate(
+ on="ts",
+ step=timedelta(minutes=2),
+ size=timedelta(minutes=10),
+ ),
+ self._WINDOW_SCHEMA_COLUMN_NAMES,
+ expected_column_data_types=self._window_schema_column_data_types(),
+ )
+
+ def test_session_appends_window_columns(self):
+ self.assert_dataframe_schema(
+ self.dataframe.session(on="ts", gap=timedelta(minutes=10)),
+ self._WINDOW_SCHEMA_COLUMN_NAMES,
+ expected_column_data_types=self._window_schema_column_data_types(),
+ )
+
+ def test_session_with_partition_by_appends_window_columns(self):
+ self.assert_dataframe_schema(
+ self.dataframe.session(
+ on="ts",
+ gap=timedelta(minutes=10),
+ partition_by=["id", pf.col("amount")],
+ ),
+ self._WINDOW_SCHEMA_COLUMN_NAMES,
+ )
+
+ def test_session_partition_by_rejects_unsupported_type(self):
+ with self.assertRaisesRegex(
+ TypeError,
+ "^partition_by must be a column name, expression, or a list of
them$",
+ ):
+ self.dataframe.session(
+ on="ts", gap=timedelta(minutes=10), partition_by=[10]
+ )
+
+
class DataFrameTopNTests(PyFlinkDataFrameUTTestCase):
def setUp(self):
super().setUp()
@@ -1598,6 +1745,13 @@ class DataFrameTopNTests(PyFlinkDataFrameUTTestCase):
with self.assertRaisesRegex(ValueError, "partition_by column 'nope'
does not exist"):
self.df.top_n(2, partition_by="nope", order_by="amount")
+ def test_rejects_non_string_partition_by(self):
+ with self.assertRaises(TypeError) as error:
+ self.df.top_n(2, partition_by=[1], order_by="amount")
+ self.assertEqual(
+ str(error.exception), "partition_by must be a string or a list of
strings"
+ )
+
def test_sql_global_has_no_partition_by(self):
df = pf.from_records([{"id": 1, "name": "a", "score": 10}])
@@ -2176,5 +2330,136 @@ class DataFrameBatchITTests(PyFlinkITTestCase):
)
+class DataFrameWindowITTests(PyFlinkStreamDataFrameTestCase):
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.t_env.get_config().set("table.exec.resource.default-parallelism",
"1")
+
+ def _rowtime_source(self):
+ return self._rowtime_source_from_rows(
+ "events.csv", ["1,10,0", "1,20,60000", "1,40,600000"]
+ )
+
+ def _multi_key_rowtime_source(self):
+ return self._rowtime_source_from_rows(
+ "multi_key_events.csv", ["1,10,0", "2,20,60000", "1,40,600000"]
+ )
+
+ def _rowtime_source_from_rows(self, filename, rows):
+ input_path = os.path.join(self.tempdir, filename)
+ with open(input_path, "w", encoding="utf-8") as events:
+ events.write("\n".join(rows) + "\n")
+ return pf.read_generic(
+ "filesystem",
+ schema={
+ "id": DataType.int64(),
+ "amount": DataType.int64(),
+ "ts_millis": DataType.int64(),
+ },
+ options={"path": input_path, "format": "csv"},
+ computed_columns={"event_time": "TO_TIMESTAMP_LTZ(ts_millis, 3)"},
+ watermark=("event_time", "event_time - INTERVAL '1' SECOND"),
+ )
+
+ def _proctime_source(self):
+ input_path = os.path.join(self.tempdir, "proctime_events.csv")
+ with open(input_path, "w", encoding="utf-8") as events:
+ events.write("1,10\n")
+ events.write("1,20\n")
+ events.write("1,40\n")
+ return pf.read_generic(
+ "filesystem",
+ schema={
+ "id": DataType.int64(),
+ "amount": DataType.int64(),
+ },
+ options={"path": input_path, "format": "csv"},
+ computed_columns={"proc_time": "PROCTIME()"},
+ )
+
+ def test_tumble_window_aggregation(self):
+ windowed = (
+ self._rowtime_source()
+ .tumble(on="event_time", size=timedelta(minutes=10))
+ .group_by("window_start", "window_end", "id")
+ .agg(pf.col("amount").sum.alias("total"))
+ )
+
+ self.assertEqual(sorted(row[-1] for row in windowed.collect()), [30,
40])
+
+ def test_hop_window_aggregation(self):
+ windowed = (
+ self._rowtime_source()
+ .hop(
+ on="event_time",
+ slide=timedelta(minutes=5),
+ size=timedelta(minutes=10),
+ )
+ .group_by("window_start", "window_end", "id")
+ .agg(pf.col("amount").sum.alias("total"))
+ )
+
+ self.assertEqual(sorted(row[-1] for row in windowed.collect()), [30,
30, 40, 40])
+
+ def test_cumulate_window_aggregation(self):
+ windowed = (
+ self._rowtime_source()
+ .cumulate(
+ on="event_time",
+ step=timedelta(minutes=5),
+ size=timedelta(minutes=10),
+ )
+ .group_by("window_start", "window_end", "id")
+ .agg(pf.col("amount").sum.alias("total"))
+ )
+
+ self.assertEqual(sorted(row[-1] for row in windowed.collect()), [30,
30, 40, 40])
+
+ def test_session_window_aggregation(self):
+ windowed = (
+ self._rowtime_source()
+ .session(on="event_time", gap=timedelta(minutes=5))
+ .group_by("window_start", "window_end", "id")
+ .agg(pf.col("amount").sum.alias("total"))
+ )
+ rows = self._materialize(windowed, key=["window_start", "window_end",
"id"])
+
+ self.assertEqual(sorted(row[-1] for row in rows), [30, 40])
+
+ def test_session_partition_by_computes_per_key_sessions(self):
+ partitioned = (
+ self._multi_key_rowtime_source()
+ .session(
+ on="event_time",
+ gap=timedelta(seconds=90),
+ partition_by="id",
+ )
+ .group_by("window_start", "window_end", "id")
+ .agg(pf.col("amount").sum.alias("total"))
+ )
+
+ partitioned_rows = self._materialize(
+ partitioned, key=["window_start", "window_end", "id"]
+ )
+
+ self.assertEqual(sorted(row[-1] for row in partitioned_rows), [10, 20,
40])
+
+ def test_tumble_processing_time_assigns_aligned_windows(self):
+ rows = (
+ self._proctime_source()
+ .tumble(on="proc_time", size=timedelta(minutes=10))
+ .select("window_start", "window_end")
+ .collect()
+ )
+
+ self.assertEqual(len(rows), 3)
+ for start, end in rows:
+ self.assertEqual(end - start, timedelta(minutes=10))
+ self.assertEqual(
+ (start.minute % 10, start.second, start.microsecond), (0, 0, 0)
+ )
+
+
if __name__ == "__main__":
unittest.main()
diff --git
a/flink-table/flink-table-api-java/src/main/java/org/apache/flink/table/operations/QueryOperationVisitor.java
b/flink-table/flink-table-api-java/src/main/java/org/apache/flink/table/operations/QueryOperationVisitor.java
index 01fd08cf362..02eb183b63b 100644
---
a/flink-table/flink-table-api-java/src/main/java/org/apache/flink/table/operations/QueryOperationVisitor.java
+++
b/flink-table/flink-table-api-java/src/main/java/org/apache/flink/table/operations/QueryOperationVisitor.java
@@ -33,6 +33,8 @@ public interface QueryOperationVisitor<T> {
T visit(WindowAggregateQueryOperation windowAggregate);
+ T visit(WindowTableFunctionQueryOperation windowTableFunction);
+
T visit(JoinQueryOperation join);
T visit(SetQueryOperation setOperation);
diff --git
a/flink-table/flink-table-api-java/src/main/java/org/apache/flink/table/operations/WindowTableFunctionQueryOperation.java
b/flink-table/flink-table-api-java/src/main/java/org/apache/flink/table/operations/WindowTableFunctionQueryOperation.java
new file mode 100644
index 00000000000..ae736e996b8
--- /dev/null
+++
b/flink-table/flink-table-api-java/src/main/java/org/apache/flink/table/operations/WindowTableFunctionQueryOperation.java
@@ -0,0 +1,222 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.flink.table.operations;
+
+import org.apache.flink.annotation.Internal;
+import org.apache.flink.table.api.DataTypes;
+import org.apache.flink.table.api.ValidationException;
+import org.apache.flink.table.catalog.Column;
+import org.apache.flink.table.catalog.ResolvedSchema;
+import org.apache.flink.table.expressions.ApiExpressionUtils;
+import org.apache.flink.table.expressions.SqlFactory;
+import org.apache.flink.table.types.DataType;
+import org.apache.flink.table.utils.EncodingUtils;
+
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+import static
org.apache.flink.table.types.logical.utils.LogicalTypeChecks.canBeTimeAttributeType;
+
+/**
+ * Relational operation that assigns rows to windows using a windowing
table-valued function
+ * (TUMBLE/HOP/CUMULATE/SESSION). Appends {@code window_start}, {@code
window_end} and {@code
+ * window_time} to the input and returns the enriched relation.
+ */
+@Internal
+public class WindowTableFunctionQueryOperation implements QueryOperation {
+
+ private static final String INPUT_ALIAS = "$$T_WIN";
+
+ /** Window kind; the name maps to the SQL window operator. */
+ @Internal
+ public enum WindowKind {
+ TUMBLE(1),
+ HOP(2),
+ CUMULATE(2),
+ SESSION(1);
+
+ private final int expectedIntervalCount;
+
+ WindowKind(int expectedIntervalCount) {
+ this.expectedIntervalCount = expectedIntervalCount;
+ }
+
+ /** Number of interval operands this window kind requires. */
+ public int expectedIntervalCount() {
+ return expectedIntervalCount;
+ }
+ }
+
+ private final WindowKind windowKind;
+ private final String timeColumn;
+ private final List<Duration> intervals;
+ private final List<String> partitionKeys;
+ private final QueryOperation child;
+ private final ResolvedSchema resolvedSchema;
+
+ public WindowTableFunctionQueryOperation(
+ WindowKind windowKind,
+ String timeColumn,
+ List<Duration> intervals,
+ QueryOperation child) {
+ this(windowKind, timeColumn, intervals, List.of(), child);
+ }
+
+ public WindowTableFunctionQueryOperation(
+ WindowKind windowKind,
+ String timeColumn,
+ List<Duration> intervals,
+ List<String> partitionKeys,
+ QueryOperation child) {
+ if (intervals.size() != windowKind.expectedIntervalCount()) {
+ throw new ValidationException(
+ String.format(
+ "Window kind %s requires %d interval(s), but got
%d.",
+ windowKind, windowKind.expectedIntervalCount(),
intervals.size()));
+ }
+ final ResolvedSchema inputSchema = child.getResolvedSchema();
+ final int timeIndex = inputSchema.getColumnNames().indexOf(timeColumn);
+ if (timeIndex < 0) {
+ throw new ValidationException(
+ String.format(
+ "Window time column '%s' does not exist. Available
columns: %s",
+ timeColumn, inputSchema.getColumnNames()));
+ }
+ for (String partitionKey : partitionKeys) {
+ if (inputSchema.getColumnNames().indexOf(partitionKey) < 0) {
+ throw new ValidationException(
+ String.format(
+ "Window partition column '%s' does not exist. "
+ + "Available columns: %s",
+ partitionKey, inputSchema.getColumnNames()));
+ }
+ }
+ final DataType timeType =
inputSchema.getColumnDataTypes().get(timeIndex);
+ if (!canBeTimeAttributeType(timeType.getLogicalType())) {
+ throw new ValidationException(
+ String.format(
+ "Window time column '%s' must be a TIMESTAMP or
TIMESTAMP_LTZ column, "
+ + "but was %s.",
+ timeColumn, timeType.getLogicalType()));
+ }
+ this.windowKind = windowKind;
+ this.timeColumn = timeColumn;
+ this.intervals = intervals;
+ this.partitionKeys = partitionKeys;
+ this.child = child;
+ this.resolvedSchema = deriveResolvedSchema(inputSchema, timeType);
+ }
+
+ private static ResolvedSchema deriveResolvedSchema(
+ ResolvedSchema inputSchema, DataType timeColumnType) {
+ final List<Column> columns = new ArrayList<>(inputSchema.getColumns());
+ columns.add(Column.physical("window_start",
DataTypes.TIMESTAMP(3).notNull()));
+ columns.add(Column.physical("window_end",
DataTypes.TIMESTAMP(3).notNull()));
+ columns.add(Column.physical("window_time", timeColumnType.notNull()));
+ return ResolvedSchema.of(columns);
+ }
+
+ public WindowKind getWindowKind() {
+ return windowKind;
+ }
+
+ public String getTimeColumn() {
+ return timeColumn;
+ }
+
+ public List<Duration> getIntervals() {
+ return intervals;
+ }
+
+ public List<String> getPartitionKeys() {
+ return partitionKeys;
+ }
+
+ public QueryOperation getChild() {
+ return child;
+ }
+
+ @Override
+ public ResolvedSchema getResolvedSchema() {
+ return resolvedSchema;
+ }
+
+ @Override
+ public String asSummaryString() {
+ final Map<String, Object> args = new LinkedHashMap<>();
+ args.put("window", windowKind);
+ args.put("timeCol", timeColumn);
+ args.put("intervals", intervals);
+ if (!partitionKeys.isEmpty()) {
+ args.put("partitionKeys", partitionKeys);
+ }
+ return OperationUtils.formatWithChildren(
+ "WindowTableFunction", args, getChildren(),
Operation::asSummaryString);
+ }
+
+ @Override
+ public String asSerializableString(SqlFactory sqlFactory) {
+ return String.format(
+ "SELECT %s FROM TABLE(%s\n) %s",
+ OperationUtils.formatSelectColumns(getResolvedSchema(),
INPUT_ALIAS),
+ OperationUtils.indent(serializeWindowCall(sqlFactory)),
+ INPUT_ALIAS);
+ }
+
+ private String serializeWindowCall(SqlFactory sqlFactory) {
+ final String childSql =
OperationUtils.indent(child.asSerializableString(sqlFactory));
+ final String descriptor = "DESCRIPTOR(" +
EncodingUtils.escapeIdentifier(timeColumn) + ")";
+ final String serializedIntervals =
+ intervals.stream()
+ .map(
+ d ->
+
ApiExpressionUtils.intervalOfMillis(d.toMillis())
+
.asSerializableString(sqlFactory))
+ .collect(Collectors.joining(", "));
+ final String partitionClause = serializePartitionClause();
+ return String.format(
+ "%s((%s\n)%s, %s, %s)",
+ windowKind, childSql, partitionClause, descriptor,
serializedIntervals);
+ }
+
+ private String serializePartitionClause() {
+ if (partitionKeys.isEmpty()) {
+ return "";
+ }
+ final String keys =
+ partitionKeys.stream()
+ .map(EncodingUtils::escapeIdentifier)
+ .collect(Collectors.joining(", "));
+ return " PARTITION BY (" + keys + ")";
+ }
+
+ @Override
+ public List<QueryOperation> getChildren() {
+ return List.of(child);
+ }
+
+ @Override
+ public <T> T accept(QueryOperationVisitor<T> visitor) {
+ return visitor.visit(this);
+ }
+}
diff --git
a/flink-table/flink-table-api-java/src/main/java/org/apache/flink/table/operations/utils/OperationTreeBuilder.java
b/flink-table/flink-table-api-java/src/main/java/org/apache/flink/table/operations/utils/OperationTreeBuilder.java
index 6eb66edfb28..290999be8e7 100644
---
a/flink-table/flink-table-api-java/src/main/java/org/apache/flink/table/operations/utils/OperationTreeBuilder.java
+++
b/flink-table/flink-table-api-java/src/main/java/org/apache/flink/table/operations/utils/OperationTreeBuilder.java
@@ -32,8 +32,11 @@ import org.apache.flink.table.catalog.ResolvedSchema;
import org.apache.flink.table.expressions.ApiExpressionUtils;
import org.apache.flink.table.expressions.Expression;
import org.apache.flink.table.expressions.ExpressionUtils;
+import org.apache.flink.table.expressions.FieldReferenceExpression;
import org.apache.flink.table.expressions.ResolvedExpression;
import org.apache.flink.table.expressions.UnresolvedCallExpression;
+import org.apache.flink.table.expressions.UnresolvedReferenceExpression;
+import org.apache.flink.table.expressions.ValueLiteralExpression;
import org.apache.flink.table.expressions.resolver.ExpressionResolver;
import org.apache.flink.table.expressions.resolver.LookupCallResolver;
import org.apache.flink.table.expressions.resolver.SqlExpressionResolver;
@@ -50,7 +53,9 @@ import
org.apache.flink.table.operations.PartitionQueryOperation;
import org.apache.flink.table.operations.QueryOperation;
import org.apache.flink.table.operations.ValuesQueryOperation;
import
org.apache.flink.table.operations.WindowAggregateQueryOperation.ResolvedGroupWindow;
+import org.apache.flink.table.operations.WindowTableFunctionQueryOperation;
import org.apache.flink.table.types.DataType;
+import org.apache.flink.table.types.logical.LogicalTypeRoot;
import org.apache.flink.table.types.utils.DataTypeUtils;
import org.apache.flink.table.types.utils.TypeConversions;
import org.apache.flink.table.typeutils.FieldInfoUtils;
@@ -58,6 +63,7 @@ import org.apache.flink.util.Preconditions;
import javax.annotation.Nullable;
+import java.time.Duration;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
@@ -363,6 +369,70 @@ public final class OperationTreeBuilder {
flattenedProjection, aggregateWithAlias.aliases,
groupingExpressions.size());
}
+ public QueryOperation windowTableFunction(
+ WindowTableFunctionQueryOperation.WindowKind windowKind,
+ Expression timeColumn,
+ List<Expression> intervals,
+ QueryOperation child) {
+ return windowTableFunction(windowKind, timeColumn, intervals,
List.of(), child);
+ }
+
+ public QueryOperation windowTableFunction(
+ WindowTableFunctionQueryOperation.WindowKind windowKind,
+ Expression timeColumn,
+ List<Expression> intervals,
+ List<Expression> partitionColumns,
+ QueryOperation child) {
+ final String timeColumnName = extractColumnName(timeColumn);
+ final List<Duration> durations = new ArrayList<>(intervals.size());
+ for (Expression interval : intervals) {
+ durations.add(toDayTimeDuration(interval));
+ }
+ final List<String> partitionKeys = new
ArrayList<>(partitionColumns.size());
+ for (Expression partitionColumn : partitionColumns) {
+ partitionKeys.add(extractColumnName(partitionColumn));
+ }
+ return new WindowTableFunctionQueryOperation(
+ windowKind, timeColumnName, durations, partitionKeys, child);
+ }
+
+ private static String extractColumnName(Expression column) {
+ final Expression unwrapped = ApiExpressionUtils.unwrapFromApi(column);
+ if (unwrapped instanceof UnresolvedReferenceExpression) {
+ return ((UnresolvedReferenceExpression) unwrapped).getName();
+ }
+ if (unwrapped instanceof FieldReferenceExpression) {
+ return ((FieldReferenceExpression) unwrapped).getName();
+ }
+ throw new ValidationException(
+ "Window column must be a column reference, but was: " +
column.asSummaryString());
+ }
+
+ private static Duration toDayTimeDuration(Expression interval) {
+ final Expression unwrapped =
ApiExpressionUtils.unwrapFromApi(interval);
+ if (!(unwrapped instanceof ValueLiteralExpression)) {
+ throw new ValidationException(
+ "Window interval must be an interval literal, e.g.
lit(10).minutes().");
+ }
+ final ValueLiteralExpression literal = (ValueLiteralExpression)
unwrapped;
+ final LogicalTypeRoot root =
literal.getOutputDataType().getLogicalType().getTypeRoot();
+ if (root != LogicalTypeRoot.INTERVAL_DAY_TIME) {
+ throw new ValidationException(
+ "Window interval must be a day-time interval, but was " +
root + ".");
+ }
+ final Duration duration =
+ literal.getValueAs(Duration.class)
+ .orElseThrow(
+ () ->
+ new ValidationException(
+ "Could not read interval value
from " + literal));
+ if (duration.isZero() || duration.isNegative()) {
+ throw new ValidationException(
+ "Window interval must be positive, but was " +
duration.toMillis() + " ms.");
+ }
+ return duration;
+ }
+
public QueryOperation join(
QueryOperation left,
QueryOperation right,
diff --git
a/flink-table/flink-table-api-java/src/main/java/org/apache/flink/table/operations/utils/QueryOperationDefaultVisitor.java
b/flink-table/flink-table-api-java/src/main/java/org/apache/flink/table/operations/utils/QueryOperationDefaultVisitor.java
index 08029f22f2a..a2782230f0a 100644
---
a/flink-table/flink-table-api-java/src/main/java/org/apache/flink/table/operations/utils/QueryOperationDefaultVisitor.java
+++
b/flink-table/flink-table-api-java/src/main/java/org/apache/flink/table/operations/utils/QueryOperationDefaultVisitor.java
@@ -35,6 +35,7 @@ import org.apache.flink.table.operations.SourceQueryOperation;
import org.apache.flink.table.operations.TableSourceQueryOperation;
import org.apache.flink.table.operations.ValuesQueryOperation;
import org.apache.flink.table.operations.WindowAggregateQueryOperation;
+import org.apache.flink.table.operations.WindowTableFunctionQueryOperation;
/**
* A utility {@link QueryOperationVisitor} that calls {@link
@@ -59,6 +60,11 @@ public abstract class QueryOperationDefaultVisitor<T>
implements QueryOperationV
return defaultMethod(windowAggregate);
}
+ @Override
+ public T visit(WindowTableFunctionQueryOperation windowTableFunction) {
+ return defaultMethod(windowTableFunction);
+ }
+
@Override
public T visit(JoinQueryOperation join) {
return defaultMethod(join);
diff --git
a/flink-table/flink-table-api-java/src/test/java/org/apache/flink/table/operations/WindowTableFunctionQueryOperationTest.java
b/flink-table/flink-table-api-java/src/test/java/org/apache/flink/table/operations/WindowTableFunctionQueryOperationTest.java
new file mode 100644
index 00000000000..bc91b1c70df
--- /dev/null
+++
b/flink-table/flink-table-api-java/src/test/java/org/apache/flink/table/operations/WindowTableFunctionQueryOperationTest.java
@@ -0,0 +1,193 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.flink.table.operations;
+
+import org.apache.flink.table.api.DataTypes;
+import org.apache.flink.table.api.Schema;
+import org.apache.flink.table.api.ValidationException;
+import org.apache.flink.table.catalog.CatalogTable;
+import org.apache.flink.table.catalog.Column;
+import org.apache.flink.table.catalog.ContextResolvedTable;
+import org.apache.flink.table.catalog.ObjectIdentifier;
+import org.apache.flink.table.catalog.ResolvedCatalogTable;
+import org.apache.flink.table.catalog.ResolvedSchema;
+import
org.apache.flink.table.operations.WindowTableFunctionQueryOperation.WindowKind;
+import org.apache.flink.table.types.AtomicDataType;
+import org.apache.flink.table.types.logical.TimestampKind;
+import org.apache.flink.table.types.logical.TimestampType;
+
+import org.junit.jupiter.api.Test;
+
+import java.time.Duration;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** Tests for describing {@link WindowTableFunctionQueryOperation}. */
+class WindowTableFunctionQueryOperationTest {
+
+ private static QueryOperation source() {
+ final ResolvedSchema schema =
+ ResolvedSchema.of(
+ Column.physical("id", DataTypes.INT()),
+ Column.physical("amount", DataTypes.INT()),
+ Column.physical(
+ "ts",
+ new AtomicDataType(
+ new TimestampType(false,
TimestampKind.ROWTIME, 3))));
+ return new SourceQueryOperation(
+ ContextResolvedTable.temporary(
+ ObjectIdentifier.of("cat1", "db1", "src"),
+ new ResolvedCatalogTable(
+ CatalogTable.newBuilder()
+ .schema(Schema.newBuilder().build())
+ .build(),
+ schema)));
+ }
+
+ @Test
+ void testConstructorAppendsWindowColumns() {
+ assertThat(
+ new WindowTableFunctionQueryOperation(
+ WindowKind.TUMBLE,
+ "ts",
+ List.of(Duration.ofMinutes(10)),
+ source())
+ .getResolvedSchema()
+ .getColumnNames())
+ .containsExactly("id", "amount", "ts", "window_start",
"window_end", "window_time");
+ }
+
+ @Test
+ void testWindowColumnsMatchPlannerNullability() {
+ final ResolvedSchema schema =
+ new WindowTableFunctionQueryOperation(
+ WindowKind.TUMBLE, "ts",
List.of(Duration.ofMinutes(10)), source())
+ .getResolvedSchema();
+
+
assertThat(schema.getColumn("window_start").orElseThrow(AssertionError::new).getDataType())
+ .isEqualTo(DataTypes.TIMESTAMP(3).notNull());
+
assertThat(schema.getColumn("window_end").orElseThrow(AssertionError::new).getDataType())
+ .isEqualTo(DataTypes.TIMESTAMP(3).notNull());
+
assertThat(schema.getColumn("window_time").orElseThrow(AssertionError::new).getDataType())
+ .isEqualTo(DataTypes.TIMESTAMP(3).notNull());
+ }
+
+ @Test
+ void testWrongIntervalCountRejected() {
+ assertThatThrownBy(
+ () ->
+ new WindowTableFunctionQueryOperation(
+ WindowKind.HOP,
+ "ts",
+ List.of(Duration.ofMinutes(10)),
+ source()))
+ .isInstanceOf(ValidationException.class)
+ .hasMessageContaining("HOP")
+ .hasMessageContaining("requires 2 interval(s), but got 1");
+ }
+
+ @Test
+ void testIntervalsArePreserved() {
+ assertThat(
+ new WindowTableFunctionQueryOperation(
+ WindowKind.HOP,
+ "ts",
+ List.of(Duration.ofMinutes(5),
Duration.ofMinutes(10)),
+ source())
+ .getIntervals())
+ .containsExactly(Duration.ofMinutes(5),
Duration.ofMinutes(10));
+ }
+
+ @Test
+ void testUnknownTimeColumnRejected() {
+ assertThatThrownBy(
+ () ->
+ new WindowTableFunctionQueryOperation(
+ WindowKind.TUMBLE,
+ "nope",
+ List.of(Duration.ofMinutes(10)),
+ source()))
+ .isInstanceOf(ValidationException.class)
+ .hasMessageContaining("nope");
+ }
+
+ @Test
+ void testNonTimestampTimeColumnRejected() {
+ assertThatThrownBy(
+ () ->
+ new WindowTableFunctionQueryOperation(
+ WindowKind.TUMBLE,
+ "amount",
+ List.of(Duration.ofMinutes(10)),
+ source()))
+ .isInstanceOf(ValidationException.class)
+ .hasMessageContaining("TIMESTAMP or TIMESTAMP_LTZ");
+ }
+
+ @Test
+ void testAsSerializableStringEmitsWindowTvf() {
+ String sql =
+ new WindowTableFunctionQueryOperation(
+ WindowKind.TUMBLE, "ts",
List.of(Duration.ofMinutes(10)), source())
+ .asSerializableString();
+
assertThat(sql).contains("TUMBLE((").contains("DESCRIPTOR(`ts`)").contains("INTERVAL");
+ }
+
+ @Test
+ void testPartitionKeysArePreserved() {
+ assertThat(
+ new WindowTableFunctionQueryOperation(
+ WindowKind.SESSION,
+ "ts",
+ List.of(Duration.ofMinutes(10)),
+ List.of("id"),
+ source())
+ .getPartitionKeys())
+ .containsExactly("id");
+ }
+
+ @Test
+ void testAsSerializableStringEmitsPartitionBy() {
+ String sql =
+ new WindowTableFunctionQueryOperation(
+ WindowKind.SESSION,
+ "ts",
+ List.of(Duration.ofMinutes(10)),
+ List.of("id"),
+ source())
+ .asSerializableString();
+ assertThat(sql).contains("SESSION((").contains(") PARTITION BY
(`id`)");
+ }
+
+ @Test
+ void testUnknownPartitionColumnRejected() {
+ assertThatThrownBy(
+ () ->
+ new WindowTableFunctionQueryOperation(
+ WindowKind.SESSION,
+ "ts",
+ List.of(Duration.ofMinutes(10)),
+ List.of("nope"),
+ source()))
+ .isInstanceOf(ValidationException.class)
+ .hasMessageContaining("nope");
+ }
+}
diff --git
a/flink-table/flink-table-api-java/src/test/java/org/apache/flink/table/test/program/FailingTableApiTestStep.java
b/flink-table/flink-table-api-java/src/test/java/org/apache/flink/table/test/program/FailingTableApiTestStep.java
index ab91f955025..2277b0b37c2 100644
---
a/flink-table/flink-table-api-java/src/test/java/org/apache/flink/table/test/program/FailingTableApiTestStep.java
+++
b/flink-table/flink-table-api-java/src/test/java/org/apache/flink/table/test/program/FailingTableApiTestStep.java
@@ -24,8 +24,11 @@ import org.apache.flink.table.api.Table;
import org.apache.flink.table.api.TableEnvironment;
import org.apache.flink.table.api.TableRuntimeException;
import org.apache.flink.table.api.ValidationException;
+import org.apache.flink.table.api.internal.TableEnvironmentImpl;
import org.apache.flink.table.expressions.DefaultSqlFactory;
import org.apache.flink.table.functions.UserDefinedFunction;
+import org.apache.flink.table.operations.QueryOperation;
+import org.apache.flink.table.operations.utils.OperationTreeBuilder;
import org.apache.flink.table.test.program.TableApiTestStep.TableEnvAccessor;
import org.apache.flink.table.types.AbstractDataType;
import org.apache.flink.util.Preconditions;
@@ -104,6 +107,16 @@ public final class FailingTableApiTestStep implements
TestStep {
return env.sqlQuery(query);
}
+ @Override
+ public Table createTable(QueryOperation operation) {
+ return ((TableEnvironmentImpl)
env).createTable(operation);
+ }
+
+ @Override
+ public OperationTreeBuilder getOperationTreeBuilder() {
+ return ((TableEnvironmentImpl)
env).getOperationTreeBuilder();
+ }
+
@Override
public Model fromModel(String modelPath) {
return env.fromModel(modelPath);
diff --git
a/flink-table/flink-table-api-java/src/test/java/org/apache/flink/table/test/program/TableApiTestStep.java
b/flink-table/flink-table-api-java/src/test/java/org/apache/flink/table/test/program/TableApiTestStep.java
index 176e067addb..9df033a4836 100644
---
a/flink-table/flink-table-api-java/src/test/java/org/apache/flink/table/test/program/TableApiTestStep.java
+++
b/flink-table/flink-table-api-java/src/test/java/org/apache/flink/table/test/program/TableApiTestStep.java
@@ -24,8 +24,11 @@ import org.apache.flink.table.api.ModelDescriptor;
import org.apache.flink.table.api.Table;
import org.apache.flink.table.api.TableEnvironment;
import org.apache.flink.table.api.TableResult;
+import org.apache.flink.table.api.internal.TableEnvironmentImpl;
import org.apache.flink.table.expressions.DefaultSqlFactory;
import org.apache.flink.table.functions.UserDefinedFunction;
+import org.apache.flink.table.operations.QueryOperation;
+import org.apache.flink.table.operations.utils.OperationTreeBuilder;
import org.apache.flink.table.types.AbstractDataType;
import java.util.function.Function;
@@ -84,6 +87,16 @@ public class TableApiTestStep implements TestStep {
return env.sqlQuery(query);
}
+ @Override
+ public Table createTable(QueryOperation operation) {
+ return ((TableEnvironmentImpl)
env).createTable(operation);
+ }
+
+ @Override
+ public OperationTreeBuilder getOperationTreeBuilder() {
+ return ((TableEnvironmentImpl)
env).getOperationTreeBuilder();
+ }
+
@Override
public Model fromModel(String modelPath) {
return env.fromModel(modelPath);
@@ -137,6 +150,12 @@ public class TableApiTestStep implements TestStep {
/** See {@link TableEnvironment#sqlQuery(String)}. */
Table sqlQuery(String query);
+ /** See {@link TableEnvironmentImpl#createTable(QueryOperation)}. */
+ Table createTable(QueryOperation operation);
+
+ /** See {@link TableEnvironmentImpl#getOperationTreeBuilder()}. */
+ OperationTreeBuilder getOperationTreeBuilder();
+
/** See {@link TableEnvironment#fromModel(String)}. */
Model fromModel(String modelPath);
diff --git
a/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/plan/QueryOperationConverter.java
b/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/plan/QueryOperationConverter.java
index 41b5ebe632a..468430dbc7b 100644
---
a/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/plan/QueryOperationConverter.java
+++
b/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/plan/QueryOperationConverter.java
@@ -65,6 +65,7 @@ import
org.apache.flink.table.operations.TableSourceQueryOperation;
import org.apache.flink.table.operations.ValuesQueryOperation;
import org.apache.flink.table.operations.WindowAggregateQueryOperation;
import
org.apache.flink.table.operations.WindowAggregateQueryOperation.ResolvedGroupWindow;
+import org.apache.flink.table.operations.WindowTableFunctionQueryOperation;
import org.apache.flink.table.operations.utils.QueryOperationDefaultVisitor;
import org.apache.flink.table.planner.calcite.FlinkContext;
import org.apache.flink.table.planner.calcite.FlinkRelBuilder;
@@ -76,6 +77,8 @@ import
org.apache.flink.table.planner.expressions.RexNodeExpression;
import org.apache.flink.table.planner.expressions.SqlAggFunctionVisitor;
import
org.apache.flink.table.planner.expressions.converter.ExpressionConverter;
import org.apache.flink.table.planner.functions.bridging.BridgingSqlFunction;
+import org.apache.flink.table.planner.functions.sql.FlinkSqlOperatorTable;
+import org.apache.flink.table.planner.functions.sql.SqlWindowTableFunction;
import org.apache.flink.table.planner.functions.utils.TableSqlFunction;
import
org.apache.flink.table.planner.operations.InternalDataStreamQueryOperation;
import org.apache.flink.table.planner.operations.PlannerQueryOperation;
@@ -104,6 +107,7 @@ import org.apache.flink.table.types.logical.RowType;
import org.apache.flink.table.types.utils.DataTypeUtils;
import org.apache.flink.util.Preconditions;
+import org.apache.calcite.avatica.util.TimeUnit;
import org.apache.calcite.plan.ViewExpanders;
import org.apache.calcite.rel.RelNode;
import org.apache.calcite.rel.core.CorrelationId;
@@ -113,13 +117,19 @@ import
org.apache.calcite.rel.logical.LogicalTableFunctionScan;
import org.apache.calcite.rel.logical.LogicalTableScan;
import org.apache.calcite.rel.logical.LogicalValues;
import org.apache.calcite.rel.type.RelDataType;
+import org.apache.calcite.rex.RexBuilder;
import org.apache.calcite.rex.RexLiteral;
import org.apache.calcite.rex.RexNode;
import org.apache.calcite.sql.SqlAggFunction;
+import org.apache.calcite.sql.SqlFunction;
+import org.apache.calcite.sql.SqlIntervalQualifier;
+import org.apache.calcite.sql.parser.SqlParserPos;
import org.apache.calcite.tools.RelBuilder;
import org.apache.calcite.tools.RelBuilder.AggCall;
import org.apache.calcite.tools.RelBuilder.GroupKey;
+import java.math.BigDecimal;
+import java.time.Duration;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
@@ -227,6 +237,11 @@ public class QueryOperationConverter extends
QueryOperationDefaultVisitor<RelNod
.build();
}
+ @Override
+ public RelNode visit(WindowTableFunctionQueryOperation
windowTableFunction) {
+ return convertWindowTableFunction(windowTableFunction);
+ }
+
private NamedWindowProperty convertToWindowProperty(
Expression expression, WindowReference windowReference) {
Preconditions.checkArgument(
@@ -593,6 +608,65 @@ public class QueryOperationConverter extends
QueryOperationDefaultVisitor<RelNod
throw new TableException("Unknown table operation: " + other);
}
+ private RelNode
convertWindowTableFunction(WindowTableFunctionQueryOperation windowOp) {
+ final RexBuilder rexBuilder = relBuilder.getRexBuilder();
+
+ final RelNode input = relBuilder.build();
+ final RelDataType inputRowType = input.getRowType();
+
+ final String timeColumn = windowOp.getTimeColumn();
+ final RelDataType timeAttributeType =
+ inputRowType.getField(timeColumn, true, false).getType();
+ final RelDataType outputRowType =
+ SqlWindowTableFunction.inferRowType(
+ relBuilder.getTypeFactory(), inputRowType,
timeAttributeType);
+
+ final int[] partitionIndices =
+ windowOp.getPartitionKeys().stream()
+ .mapToInt(name -> inputRowType.getField(name,
true, false).getIndex())
+ .toArray();
+
+ final SqlFunction operator =
windowOperator(windowOp.getWindowKind());
+ final List<RexNode> operands = new ArrayList<>();
+ operands.add(
+ new RexTableArgCall(
+ inputRowType, 0, partitionIndices, new int[0], new
SortOrder[0]));
+ operands.add(
+ rexBuilder.makeCall(
+ FlinkSqlOperatorTable.DESCRIPTOR,
rexBuilder.makeLiteral(timeColumn)));
+ for (Duration interval : windowOp.getIntervals()) {
+ operands.add(
+ rexBuilder.makeIntervalLiteral(
+ BigDecimal.valueOf(interval.toMillis()),
+ new SqlIntervalQualifier(
+ TimeUnit.MILLISECOND, null,
SqlParserPos.ZERO)));
+ }
+
+ final RexNode call = rexBuilder.makeCall(outputRowType, operator,
operands);
+ return LogicalTableFunctionScan.create(
+ relBuilder.getCluster(),
+ new ArrayList<>(Collections.singletonList(input)),
+ call,
+ null,
+ outputRowType,
+ Set.of());
+ }
+
+ private SqlFunction
windowOperator(WindowTableFunctionQueryOperation.WindowKind kind) {
+ switch (kind) {
+ case TUMBLE:
+ return FlinkSqlOperatorTable.TUMBLE;
+ case HOP:
+ return FlinkSqlOperatorTable.HOP;
+ case CUMULATE:
+ return FlinkSqlOperatorTable.CUMULATE;
+ case SESSION:
+ return FlinkSqlOperatorTable.SESSION;
+ default:
+ throw new TableException("Unsupported window kind: " +
kind);
+ }
+ }
+
@Override
public <U> RelNode visit(TableSourceQueryOperation<U>
tableSourceOperation) {
TableSource<?> tableSource = tableSourceOperation.getTableSource();
diff --git
a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/api/QueryOperationSqlSemanticTest.java
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/api/QueryOperationSqlSemanticTest.java
index 2862284602d..46c7122d116 100644
---
a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/api/QueryOperationSqlSemanticTest.java
+++
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/api/QueryOperationSqlSemanticTest.java
@@ -50,6 +50,11 @@ public class QueryOperationSqlSemanticTest extends
SemanticTestBase {
QueryOperationTestPrograms.LIMIT_QUERY_OPERATION,
QueryOperationTestPrograms.WINDOW_AGGREGATE_QUERY_OPERATION,
QueryOperationTestPrograms.WINDOW_AGGREGATE_ROWTIME_QUERY_OPERATION,
+ QueryOperationTestPrograms.WINDOW_TVF_TUMBLE_QUERY_OPERATION,
+ QueryOperationTestPrograms.WINDOW_TVF_HOP_QUERY_OPERATION,
+ QueryOperationTestPrograms.WINDOW_TVF_CUMULATE_QUERY_OPERATION,
+ QueryOperationTestPrograms.WINDOW_TVF_SESSION_QUERY_OPERATION,
+
QueryOperationTestPrograms.WINDOW_TVF_SESSION_PARTITION_QUERY_OPERATION,
QueryOperationTestPrograms.UNION_ALL_QUERY_OPERATION,
QueryOperationTestPrograms.LATERAL_JOIN_QUERY_OPERATION,
QueryOperationTestPrograms.GROUP_HOP_WINDOW_EVENT_TIME,
diff --git
a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/api/QueryOperationSqlSerializationTest.java
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/api/QueryOperationSqlSerializationTest.java
index d3175a773bb..7b5795fe9a7 100644
---
a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/api/QueryOperationSqlSerializationTest.java
+++
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/api/QueryOperationSqlSerializationTest.java
@@ -71,6 +71,11 @@ public class QueryOperationSqlSerializationTest implements
TableTestProgramRunne
QueryOperationTestPrograms.WINDOW_AGGREGATE_QUERY_OPERATION,
QueryOperationTestPrograms.WINDOW_AGGREGATE_ROWTIME_QUERY_OPERATION,
QueryOperationTestPrograms.WINDOW_AGGREGATE_PROCTIME_QUERY_OPERATION,
+ QueryOperationTestPrograms.WINDOW_TVF_TUMBLE_QUERY_OPERATION,
+ QueryOperationTestPrograms.WINDOW_TVF_HOP_QUERY_OPERATION,
+ QueryOperationTestPrograms.WINDOW_TVF_CUMULATE_QUERY_OPERATION,
+ QueryOperationTestPrograms.WINDOW_TVF_SESSION_QUERY_OPERATION,
+
QueryOperationTestPrograms.WINDOW_TVF_SESSION_PARTITION_QUERY_OPERATION,
QueryOperationTestPrograms.UNION_ALL_QUERY_OPERATION,
QueryOperationTestPrograms.LATERAL_JOIN_QUERY_OPERATION,
QueryOperationTestPrograms.SQL_QUERY_OPERATION,
diff --git
a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/api/QueryOperationTestPrograms.java
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/api/QueryOperationTestPrograms.java
index 0035c9154df..6c11367955f 100644
---
a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/api/QueryOperationTestPrograms.java
+++
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/api/QueryOperationTestPrograms.java
@@ -23,6 +23,7 @@ import
org.apache.flink.table.api.config.ExecutionConfigOptions;
import
org.apache.flink.table.api.config.ExecutionConfigOptions.AsyncOutputMode;
import org.apache.flink.table.functions.ScalarFunction;
import org.apache.flink.table.operations.QueryOperation;
+import org.apache.flink.table.operations.WindowTableFunctionQueryOperation;
import org.apache.flink.table.planner.factories.TestValuesModelFactory;
import
org.apache.flink.table.planner.plan.nodes.exec.stream.ProcessTableFunctionTestUtils.ChainedReceivingFunction;
import
org.apache.flink.table.planner.plan.nodes.exec.stream.ProcessTableFunctionTestUtils.ChainedSendingFunction;
@@ -44,6 +45,7 @@ import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.ZoneId;
import java.util.Collections;
+import java.util.List;
import java.util.Map;
import static org.apache.flink.table.api.Expressions.$;
@@ -393,10 +395,341 @@ public class QueryOperationTestPrograms {
+ ") $$T_PROJECT")
.build();
+ static final TableTestProgram WINDOW_TVF_TUMBLE_QUERY_OPERATION =
+ TableTestProgram.of(
+ "window-tvf-tumble-query-operation",
+ "verifies sql serialization of a programmatic
window TVF operation")
+ .setupTableSource(
+ SourceTestStep.newBuilder("s")
+ .addSchema(
+ "a bigint",
+ "b string",
+ "ts TIMESTAMP(3)",
+ "WATERMARK FOR ts AS ts - INTERVAL
'1' SECOND")
+ .producedValues(
+ Row.of(2L, "apple",
localOfSeconds(0)),
+ Row.of(3L, "apple",
localOfSeconds(4)),
+ Row.of(1L, "apple",
localOfSeconds(7)))
+ .build())
+ .setupTableSink(
+ SinkTestStep.newBuilder("sink")
+ .addSchema(
+ "w_start TIMESTAMP(3)",
+ "w_end TIMESTAMP(3)",
+ "a_sum bigint")
+ .consumedValues(
+ Row.of(localOfSeconds(0),
localOfSeconds(5), 5L),
+ Row.of(localOfSeconds(5),
localOfSeconds(10), 1L))
+ .build())
+ .runTableApi(
+ env ->
+ env.createTable(
+
env.getOperationTreeBuilder()
+
.windowTableFunction(
+
WindowTableFunctionQueryOperation
+
.WindowKind.TUMBLE,
+ $("ts"),
+
List.of(lit(5).seconds()),
+
env.from("s")
+
.getQueryOperation()))
+ .groupBy($("window_start"),
$("window_end"))
+ .select(
+ $("window_start"),
+ $("window_end"),
+ $("a").sum()),
+ "sink")
+ .runSql(
+ "SELECT `$$T_PROJECT`.`window_start`,
`$$T_PROJECT`.`window_end`, "
+ + "`$$T_PROJECT`.`EXPR$0` FROM (\n"
+ + " SELECT `$$T_AGG`.`window_start`,
`$$T_AGG`.`window_end`, "
+ + "(SUM(`$$T_AGG`.`a`)) AS `EXPR$0` FROM
(\n"
+ + " SELECT `$$T_WIN`.`a`,
`$$T_WIN`.`b`, `$$T_WIN`.`ts`, "
+ + "`$$T_WIN`.`window_start`,
`$$T_WIN`.`window_end`, "
+ + "`$$T_WIN`.`window_time` FROM TABLE(\n"
+ + " TUMBLE((\n"
+ + " SELECT
`$$T_SOURCE`.`a`, `$$T_SOURCE`.`b`, "
+ + "`$$T_SOURCE`.`ts` FROM
`default_catalog`.`default_database`.`s` $$T_SOURCE\n"
+ + " ), DESCRIPTOR(`ts`),
INTERVAL '0 00:00:05.000' DAY(2) TO SECOND(3))\n"
+ + " ) $$T_WIN\n"
+ + " ) $$T_AGG\n"
+ + " GROUP BY `$$T_AGG`.`window_start`,
`$$T_AGG`.`window_end`\n"
+ + ") $$T_PROJECT")
+ .build();
+
+ static final TableTestProgram WINDOW_TVF_HOP_QUERY_OPERATION =
+ TableTestProgram.of(
+ "window-tvf-hop-query-operation",
+ "verifies sql serialization of a programmatic HOP
window TVF operation")
+ .setupTableSource(
+ SourceTestStep.newBuilder("s")
+ .addSchema(
+ "a bigint",
+ "b string",
+ "ts TIMESTAMP(3)",
+ "WATERMARK FOR ts AS ts - INTERVAL
'1' SECOND")
+ .producedValues(
+ Row.of(2L, "apple",
localOfSeconds(0)),
+ Row.of(3L, "apple",
localOfSeconds(4)),
+ Row.of(1L, "apple",
localOfSeconds(7)))
+ .build())
+ .setupTableSink(
+ SinkTestStep.newBuilder("sink")
+ .addSchema(
+ "w_start TIMESTAMP(3)",
+ "w_end TIMESTAMP(3)",
+ "a_sum bigint")
+ .consumedValues(
+ Row.of(
+ LocalDateTime.of(2023, 12,
31, 23, 59, 55),
+ localOfSeconds(5),
+ 5L),
+ Row.of(localOfSeconds(0),
localOfSeconds(10), 6L),
+ Row.of(localOfSeconds(5),
localOfSeconds(15), 1L))
+ .build())
+ .runTableApi(
+ env ->
+ env.createTable(
+
env.getOperationTreeBuilder()
+
.windowTableFunction(
+
WindowTableFunctionQueryOperation
+
.WindowKind.HOP,
+ $("ts"),
+ List.of(
+
lit(5).seconds(),
+
lit(10).seconds()),
+
env.from("s")
+
.getQueryOperation()))
+ .groupBy($("window_start"),
$("window_end"))
+ .select(
+ $("window_start"),
+ $("window_end"),
+ $("a").sum()),
+ "sink")
+ .runSql(
+ "SELECT `$$T_PROJECT`.`window_start`,
`$$T_PROJECT`.`window_end`, "
+ + "`$$T_PROJECT`.`EXPR$0` FROM (\n"
+ + " SELECT `$$T_AGG`.`window_start`,
`$$T_AGG`.`window_end`, "
+ + "(SUM(`$$T_AGG`.`a`)) AS `EXPR$0` FROM
(\n"
+ + " SELECT `$$T_WIN`.`a`,
`$$T_WIN`.`b`, `$$T_WIN`.`ts`, "
+ + "`$$T_WIN`.`window_start`,
`$$T_WIN`.`window_end`, "
+ + "`$$T_WIN`.`window_time` FROM TABLE(\n"
+ + " HOP((\n"
+ + " SELECT
`$$T_SOURCE`.`a`, `$$T_SOURCE`.`b`, "
+ + "`$$T_SOURCE`.`ts` FROM
`default_catalog`.`default_database`.`s` $$T_SOURCE\n"
+ + " ), DESCRIPTOR(`ts`),
INTERVAL '0 00:00:05.000' DAY(2) TO SECOND(3), "
+ + "INTERVAL '0 00:00:10.000' DAY(2) TO
SECOND(3))\n"
+ + " ) $$T_WIN\n"
+ + " ) $$T_AGG\n"
+ + " GROUP BY `$$T_AGG`.`window_start`,
`$$T_AGG`.`window_end`\n"
+ + ") $$T_PROJECT")
+ .build();
+
+ static final TableTestProgram WINDOW_TVF_CUMULATE_QUERY_OPERATION =
+ TableTestProgram.of(
+ "window-tvf-cumulate-query-operation",
+ "verifies sql serialization of a programmatic
CUMULATE window TVF operation")
+ .setupTableSource(
+ SourceTestStep.newBuilder("s")
+ .addSchema(
+ "a bigint",
+ "b string",
+ "ts TIMESTAMP(3)",
+ "WATERMARK FOR ts AS ts - INTERVAL
'1' SECOND")
+ .producedValues(
+ Row.of(2L, "apple",
localOfSeconds(0)),
+ Row.of(3L, "apple",
localOfSeconds(4)),
+ Row.of(1L, "apple",
localOfSeconds(7)))
+ .build())
+ .setupTableSink(
+ SinkTestStep.newBuilder("sink")
+ .addSchema(
+ "w_start TIMESTAMP(3)",
+ "w_end TIMESTAMP(3)",
+ "a_sum bigint")
+ .consumedValues(
+ Row.of(localOfSeconds(0),
localOfSeconds(5), 5L),
+ Row.of(localOfSeconds(0),
localOfSeconds(10), 6L))
+ .build())
+ .runTableApi(
+ env ->
+ env.createTable(
+
env.getOperationTreeBuilder()
+
.windowTableFunction(
+
WindowTableFunctionQueryOperation
+
.WindowKind.CUMULATE,
+ $("ts"),
+ List.of(
+
lit(5).seconds(),
+
lit(10).seconds()),
+
env.from("s")
+
.getQueryOperation()))
+ .groupBy($("window_start"),
$("window_end"))
+ .select(
+ $("window_start"),
+ $("window_end"),
+ $("a").sum()),
+ "sink")
+ .runSql(
+ "SELECT `$$T_PROJECT`.`window_start`,
`$$T_PROJECT`.`window_end`, "
+ + "`$$T_PROJECT`.`EXPR$0` FROM (\n"
+ + " SELECT `$$T_AGG`.`window_start`,
`$$T_AGG`.`window_end`, "
+ + "(SUM(`$$T_AGG`.`a`)) AS `EXPR$0` FROM
(\n"
+ + " SELECT `$$T_WIN`.`a`,
`$$T_WIN`.`b`, `$$T_WIN`.`ts`, "
+ + "`$$T_WIN`.`window_start`,
`$$T_WIN`.`window_end`, "
+ + "`$$T_WIN`.`window_time` FROM TABLE(\n"
+ + " CUMULATE((\n"
+ + " SELECT
`$$T_SOURCE`.`a`, `$$T_SOURCE`.`b`, "
+ + "`$$T_SOURCE`.`ts` FROM
`default_catalog`.`default_database`.`s` $$T_SOURCE\n"
+ + " ), DESCRIPTOR(`ts`),
INTERVAL '0 00:00:05.000' DAY(2) TO SECOND(3), "
+ + "INTERVAL '0 00:00:10.000' DAY(2) TO
SECOND(3))\n"
+ + " ) $$T_WIN\n"
+ + " ) $$T_AGG\n"
+ + " GROUP BY `$$T_AGG`.`window_start`,
`$$T_AGG`.`window_end`\n"
+ + ") $$T_PROJECT")
+ .build();
+
+ static final TableTestProgram WINDOW_TVF_SESSION_QUERY_OPERATION =
+ TableTestProgram.of(
+ "window-tvf-session-query-operation",
+ "verifies sql serialization of a programmatic
SESSION window TVF operation")
+ .setupTableSource(
+ SourceTestStep.newBuilder("s")
+ .addSchema(
+ "a bigint",
+ "b string",
+ "ts TIMESTAMP(3)",
+ "WATERMARK FOR ts AS ts - INTERVAL
'1' SECOND")
+ .producedValues(
+ Row.of(2L, "apple",
localOfSeconds(0)),
+ Row.of(3L, "apple",
localOfSeconds(4)),
+ Row.of(1L, "apple",
localOfSeconds(7)))
+ .build())
+ .setupTableSink(
+ SinkTestStep.newBuilder("sink")
+ .addSchema(
+ "w_start TIMESTAMP(3)",
+ "w_end TIMESTAMP(3)",
+ "a_sum bigint")
+ .consumedValues(
+ Row.of(localOfSeconds(0),
localOfSeconds(12), 6L))
+ .build())
+ .runTableApi(
+ env ->
+ env.createTable(
+
env.getOperationTreeBuilder()
+
.windowTableFunction(
+
WindowTableFunctionQueryOperation
+
.WindowKind.SESSION,
+ $("ts"),
+
List.of(lit(5).seconds()),
+
env.from("s")
+
.getQueryOperation()))
+ .groupBy($("window_start"),
$("window_end"))
+ .select(
+ $("window_start"),
+ $("window_end"),
+ $("a").sum()),
+ "sink")
+ .runSql(
+ "SELECT `$$T_PROJECT`.`window_start`,
`$$T_PROJECT`.`window_end`, "
+ + "`$$T_PROJECT`.`EXPR$0` FROM (\n"
+ + " SELECT `$$T_AGG`.`window_start`,
`$$T_AGG`.`window_end`, "
+ + "(SUM(`$$T_AGG`.`a`)) AS `EXPR$0` FROM
(\n"
+ + " SELECT `$$T_WIN`.`a`,
`$$T_WIN`.`b`, `$$T_WIN`.`ts`, "
+ + "`$$T_WIN`.`window_start`,
`$$T_WIN`.`window_end`, "
+ + "`$$T_WIN`.`window_time` FROM TABLE(\n"
+ + " SESSION((\n"
+ + " SELECT
`$$T_SOURCE`.`a`, `$$T_SOURCE`.`b`, "
+ + "`$$T_SOURCE`.`ts` FROM
`default_catalog`.`default_database`.`s` $$T_SOURCE\n"
+ + " ), DESCRIPTOR(`ts`),
INTERVAL '0 00:00:05.000' DAY(2) TO SECOND(3))\n"
+ + " ) $$T_WIN\n"
+ + " ) $$T_AGG\n"
+ + " GROUP BY `$$T_AGG`.`window_start`,
`$$T_AGG`.`window_end`\n"
+ + ") $$T_PROJECT")
+ .build();
+
+ static final TableTestProgram WINDOW_TVF_SESSION_PARTITION_QUERY_OPERATION
=
+ TableTestProgram.of(
+ "window-tvf-session-partition-query-operation",
+ "verifies sql serialization of a programmatic
per-key SESSION window TVF operation")
+ .setupTableSource(
+ SourceTestStep.newBuilder("s")
+ .addSchema(
+ "a bigint",
+ "b string",
+ "ts TIMESTAMP(3)",
+ "WATERMARK FOR ts AS ts - INTERVAL
'1' SECOND")
+ .producedValues(
+ Row.of(2L, "apple",
localOfSeconds(0)),
+ Row.of(3L, "apple",
localOfSeconds(4)),
+ Row.of(1L, "banana",
localOfSeconds(7)))
+ .build())
+ .setupTableSink(
+ SinkTestStep.newBuilder("sink")
+ .addSchema(
+ "b string",
+ "w_start TIMESTAMP(3)",
+ "w_end TIMESTAMP(3)",
+ "a_sum bigint")
+ .consumedValues(
+ Row.of(
+ "apple",
+ localOfSeconds(0),
+ localOfSeconds(9),
+ 5L),
+ Row.of(
+ "banana",
+ localOfSeconds(7),
+ localOfSeconds(12),
+ 1L))
+ .build())
+ .runTableApi(
+ env ->
+ env.createTable(
+
env.getOperationTreeBuilder()
+
.windowTableFunction(
+
WindowTableFunctionQueryOperation
+
.WindowKind.SESSION,
+ $("ts"),
+
List.of(lit(5).seconds()),
+
List.of($("b")),
+
env.from("s")
+
.getQueryOperation()))
+ .groupBy($("b"),
$("window_start"), $("window_end"))
+ .select(
+ $("b"),
+ $("window_start"),
+ $("window_end"),
+ $("a").sum()),
+ "sink")
+ .runSql(
+ "SELECT `$$T_PROJECT`.`b`,
`$$T_PROJECT`.`window_start`, "
+ + "`$$T_PROJECT`.`window_end`,
`$$T_PROJECT`.`EXPR$0` FROM (\n"
+ + " SELECT `$$T_AGG`.`b`,
`$$T_AGG`.`window_start`, "
+ + "`$$T_AGG`.`window_end`,
(SUM(`$$T_AGG`.`a`)) AS `EXPR$0` FROM (\n"
+ + " SELECT `$$T_WIN`.`a`,
`$$T_WIN`.`b`, `$$T_WIN`.`ts`, "
+ + "`$$T_WIN`.`window_start`,
`$$T_WIN`.`window_end`, "
+ + "`$$T_WIN`.`window_time` FROM TABLE(\n"
+ + " SESSION((\n"
+ + " SELECT
`$$T_SOURCE`.`a`, `$$T_SOURCE`.`b`, "
+ + "`$$T_SOURCE`.`ts` FROM
`default_catalog`.`default_database`.`s` $$T_SOURCE\n"
+ + " ) PARTITION BY (`b`),
DESCRIPTOR(`ts`), "
+ + "INTERVAL '0 00:00:05.000' DAY(2) TO
SECOND(3))\n"
+ + " ) $$T_WIN\n"
+ + " ) $$T_AGG\n"
+ + " GROUP BY `$$T_AGG`.`b`,
`$$T_AGG`.`window_start`, "
+ + "`$$T_AGG`.`window_end`\n"
+ + ") $$T_PROJECT")
+ .build();
+
private static Instant dayOfSeconds(int second) {
return LocalDateTime.of(2024, 1, 1, 0, 0,
second).atZone(ZoneId.of("UTC")).toInstant();
}
+ private static LocalDateTime localOfSeconds(int second) {
+ return LocalDateTime.of(2024, 1, 1, 0, 0, second);
+ }
+
static final TableTestProgram JOIN_QUERY_OPERATION =
TableTestProgram.of("join-query-operation", "verifies sql
serialization")
.setupTableSource(
diff --git
a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/WindowTableFunctionRelNodeTest.java
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/WindowTableFunctionRelNodeTest.java
new file mode 100644
index 00000000000..9a30c2b4257
--- /dev/null
+++
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/WindowTableFunctionRelNodeTest.java
@@ -0,0 +1,179 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.flink.table.planner.plan;
+
+import org.apache.flink.table.api.Table;
+import org.apache.flink.table.api.TableConfig;
+import org.apache.flink.table.api.internal.TableEnvironmentImpl;
+import org.apache.flink.table.operations.WindowTableFunctionQueryOperation;
+import
org.apache.flink.table.operations.WindowTableFunctionQueryOperation.WindowKind;
+import org.apache.flink.table.planner.utils.StreamTableTestUtil;
+import org.apache.flink.table.planner.utils.TableTestBase;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.apache.flink.table.api.Expressions.$;
+import static org.apache.flink.table.api.Expressions.lit;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Verifies that a programmatically built {@link
WindowTableFunctionQueryOperation} is lowered by
+ * {@code QueryOperationConverter} to the same window-TVF plan as the
equivalent {@code
+ * TABLE(TUMBLE(...))} / {@code HOP} / {@code CUMULATE} / {@code SESSION} SQL.
+ */
+class WindowTableFunctionRelNodeTest extends TableTestBase {
+
+ private StreamTableTestUtil util;
+
+ @BeforeEach
+ void setup() {
+ util = streamTestUtil(TableConfig.getDefault());
+ util.tableEnv()
+ .executeSql(
+ "CREATE TEMPORARY TABLE src (\n"
+ + " id INT, amount INT, ts TIMESTAMP(3),\n"
+ + " WATERMARK FOR ts AS ts - INTERVAL '1'
SECOND\n"
+ + ") WITH ('connector' = 'datagen')");
+ }
+
+ @Test
+ void testTumbleRelPlan() {
+ final Table table =
+ tEnv().createTable(
+ tEnv().getOperationTreeBuilder()
+ .windowTableFunction(
+ WindowKind.TUMBLE,
+ $("ts"),
+ List.of(lit(10).minutes()),
+
tEnv().from("src").getQueryOperation()));
+ util.verifyRelPlan(table);
+ }
+
+ @Test
+ void testHopRelPlan() {
+ final Table table =
+ tEnv().createTable(
+ tEnv().getOperationTreeBuilder()
+ .windowTableFunction(
+ WindowKind.HOP,
+ $("ts"),
+ List.of(lit(5).minutes(),
lit(10).minutes()),
+
tEnv().from("src").getQueryOperation()));
+ util.verifyRelPlan(table);
+ }
+
+ @Test
+ void testCumulateRelPlan() {
+ final Table table =
+ tEnv().createTable(
+ tEnv().getOperationTreeBuilder()
+ .windowTableFunction(
+ WindowKind.CUMULATE,
+ $("ts"),
+ List.of(lit(5).minutes(),
lit(10).minutes()),
+
tEnv().from("src").getQueryOperation()));
+ util.verifyRelPlan(table);
+ }
+
+ @Test
+ void testSessionRelPlan() {
+ final Table table =
+ tEnv().createTable(
+ tEnv().getOperationTreeBuilder()
+ .windowTableFunction(
+ WindowKind.SESSION,
+ $("ts"),
+ List.of(lit(10).minutes()),
+
tEnv().from("src").getQueryOperation()));
+ util.verifyRelPlan(table);
+ }
+
+ @Test
+ void testSessionWithPartitionRelPlan() {
+ final Table table =
+ tEnv().createTable(
+ tEnv().getOperationTreeBuilder()
+ .windowTableFunction(
+ WindowKind.SESSION,
+ $("ts"),
+ List.of(lit(10).minutes()),
+ List.of($("id")),
+
tEnv().from("src").getQueryOperation()));
+ util.verifyRelPlan(table);
+ }
+
+ @Test
+ void testYearMonthIntervalRejected() {
+ assertThatThrownBy(
+ () ->
+ tEnv().getOperationTreeBuilder()
+ .windowTableFunction(
+ WindowKind.TUMBLE,
+ $("ts"),
+ List.of(lit(1).month()),
+
tEnv().from("src").getQueryOperation()))
+ .hasMessageContaining("day-time");
+ }
+
+ @Test
+ void testZeroIntervalRejected() {
+ assertThatThrownBy(
+ () ->
+ tEnv().getOperationTreeBuilder()
+ .windowTableFunction(
+ WindowKind.TUMBLE,
+ $("ts"),
+ List.of(lit(0).seconds()),
+
tEnv().from("src").getQueryOperation()))
+ .hasMessageContaining("positive");
+ }
+
+ @Test
+ void testNegativeIntervalRejected() {
+ assertThatThrownBy(
+ () ->
+ tEnv().getOperationTreeBuilder()
+ .windowTableFunction(
+ WindowKind.TUMBLE,
+ $("ts"),
+ List.of(lit(-5).seconds()),
+
tEnv().from("src").getQueryOperation()))
+ .hasMessageContaining("positive");
+ }
+
+ @Test
+ void testNonLiteralIntervalRejected() {
+ assertThatThrownBy(
+ () ->
+ tEnv().getOperationTreeBuilder()
+ .windowTableFunction(
+ WindowKind.TUMBLE,
+ $("ts"),
+ List.of($("ts")),
+
tEnv().from("src").getQueryOperation()))
+ .hasMessageContaining("interval literal");
+ }
+
+ private TableEnvironmentImpl tEnv() {
+ return (TableEnvironmentImpl) util.tableEnv();
+ }
+}
diff --git
a/flink-table/flink-table-planner/src/test/resources/org/apache/flink/table/planner/plan/WindowTableFunctionRelNodeTest.xml
b/flink-table/flink-table-planner/src/test/resources/org/apache/flink/table/planner/plan/WindowTableFunctionRelNodeTest.xml
new file mode 100644
index 00000000000..ad458449423
--- /dev/null
+++
b/flink-table/flink-table-planner/src/test/resources/org/apache/flink/table/planner/plan/WindowTableFunctionRelNodeTest.xml
@@ -0,0 +1,101 @@
+<?xml version="1.0" ?>
+<!--
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to you under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<Root>
+ <TestCase name="testCumulateRelPlan">
+ <Resource name="ast">
+ <![CDATA[
+LogicalTableFunctionScan(invocation=[CUMULATE(TABLE(#0),
DESCRIPTOR(_UTF-16LE'ts'), 300000:INTERVAL MILLISECOND, 600000:INTERVAL
MILLISECOND)], rowType=[RecordType(INTEGER id, INTEGER amount, TIMESTAMP(3)
*ROWTIME* ts, TIMESTAMP(3) window_start, TIMESTAMP(3) window_end, TIMESTAMP(3)
*ROWTIME* window_time)])
++- LogicalWatermarkAssigner(rowtime=[ts], watermark=[-($2, 1000:INTERVAL
SECOND)])
+ +- LogicalTableScan(table=[[default_catalog, default_database, src]])
+]]>
+ </Resource>
+ <Resource name="optimized rel plan">
+ <![CDATA[
+WindowTableFunction(window=[CUMULATE(time_col=[ts], max_size=[10 min], step=[5
min])])
++- WatermarkAssigner(rowtime=[ts], watermark=[-(ts, 1000:INTERVAL SECOND)])
+ +- TableSourceScan(table=[[default_catalog, default_database, src]],
fields=[id, amount, ts])
+]]>
+ </Resource>
+ </TestCase>
+ <TestCase name="testHopRelPlan">
+ <Resource name="ast">
+ <![CDATA[
+LogicalTableFunctionScan(invocation=[HOP(TABLE(#0), DESCRIPTOR(_UTF-16LE'ts'),
300000:INTERVAL MILLISECOND, 600000:INTERVAL MILLISECOND)],
rowType=[RecordType(INTEGER id, INTEGER amount, TIMESTAMP(3) *ROWTIME* ts,
TIMESTAMP(3) window_start, TIMESTAMP(3) window_end, TIMESTAMP(3) *ROWTIME*
window_time)])
++- LogicalWatermarkAssigner(rowtime=[ts], watermark=[-($2, 1000:INTERVAL
SECOND)])
+ +- LogicalTableScan(table=[[default_catalog, default_database, src]])
+]]>
+ </Resource>
+ <Resource name="optimized rel plan">
+ <![CDATA[
+WindowTableFunction(window=[HOP(time_col=[ts], size=[10 min], slide=[5 min])])
++- WatermarkAssigner(rowtime=[ts], watermark=[-(ts, 1000:INTERVAL SECOND)])
+ +- TableSourceScan(table=[[default_catalog, default_database, src]],
fields=[id, amount, ts])
+]]>
+ </Resource>
+ </TestCase>
+ <TestCase name="testSessionRelPlan">
+ <Resource name="ast">
+ <![CDATA[
+LogicalTableFunctionScan(invocation=[SESSION(TABLE(#0),
DESCRIPTOR(_UTF-16LE'ts'), 600000:INTERVAL MILLISECOND)],
rowType=[RecordType(INTEGER id, INTEGER amount, TIMESTAMP(3) *ROWTIME* ts,
TIMESTAMP(3) window_start, TIMESTAMP(3) window_end, TIMESTAMP(3) *ROWTIME*
window_time)])
++- LogicalWatermarkAssigner(rowtime=[ts], watermark=[-($2, 1000:INTERVAL
SECOND)])
+ +- LogicalTableScan(table=[[default_catalog, default_database, src]])
+]]>
+ </Resource>
+ <Resource name="optimized rel plan">
+ <![CDATA[
+WindowTableFunction(window=[SESSION(time_col=[ts], gap=[10 min])])
++- Exchange(distribution=[single])
+ +- WatermarkAssigner(rowtime=[ts], watermark=[-(ts, 1000:INTERVAL SECOND)])
+ +- TableSourceScan(table=[[default_catalog, default_database, src]],
fields=[id, amount, ts])
+]]>
+ </Resource>
+ </TestCase>
+ <TestCase name="testSessionWithPartitionRelPlan">
+ <Resource name="ast">
+ <![CDATA[
+LogicalTableFunctionScan(invocation=[SESSION(TABLE(#0) PARTITION BY($0),
DESCRIPTOR(_UTF-16LE'ts'), 600000:INTERVAL MILLISECOND)],
rowType=[RecordType(INTEGER id, INTEGER amount, TIMESTAMP(3) *ROWTIME* ts,
TIMESTAMP(3) window_start, TIMESTAMP(3) window_end, TIMESTAMP(3) *ROWTIME*
window_time)])
++- LogicalWatermarkAssigner(rowtime=[ts], watermark=[-($2, 1000:INTERVAL
SECOND)])
+ +- LogicalTableScan(table=[[default_catalog, default_database, src]])
+]]>
+ </Resource>
+ <Resource name="optimized rel plan">
+ <![CDATA[
+WindowTableFunction(window=[SESSION(time_col=[ts], gap=[10 min], partition
keys=[id])])
++- Exchange(distribution=[hash[id]])
+ +- WatermarkAssigner(rowtime=[ts], watermark=[-(ts, 1000:INTERVAL SECOND)])
+ +- TableSourceScan(table=[[default_catalog, default_database, src]],
fields=[id, amount, ts])
+]]>
+ </Resource>
+ </TestCase>
+ <TestCase name="testTumbleRelPlan">
+ <Resource name="ast">
+ <![CDATA[
+LogicalTableFunctionScan(invocation=[TUMBLE(TABLE(#0),
DESCRIPTOR(_UTF-16LE'ts'), 600000:INTERVAL MILLISECOND)],
rowType=[RecordType(INTEGER id, INTEGER amount, TIMESTAMP(3) *ROWTIME* ts,
TIMESTAMP(3) window_start, TIMESTAMP(3) window_end, TIMESTAMP(3) *ROWTIME*
window_time)])
++- LogicalWatermarkAssigner(rowtime=[ts], watermark=[-($2, 1000:INTERVAL
SECOND)])
+ +- LogicalTableScan(table=[[default_catalog, default_database, src]])
+]]>
+ </Resource>
+ <Resource name="optimized rel plan">
+ <![CDATA[
+WindowTableFunction(window=[TUMBLE(time_col=[ts], size=[10 min])])
++- WatermarkAssigner(rowtime=[ts], watermark=[-(ts, 1000:INTERVAL SECOND)])
+ +- TableSourceScan(table=[[default_catalog, default_database, src]],
fields=[id, amount, ts])
+]]>
+ </Resource>
+ </TestCase>
+</Root>