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 12b0ecfef57 [FLINK-40425][python] Support OVER windows in DataFrame API
12b0ecfef57 is described below
commit 12b0ecfef57524ccfef8398f03bfef4110b98ce8
Author: bladedragon <[email protected]>
AuthorDate: Sat Sep 26 22:56:57 2026 +0800
[FLINK-40425][python] Support OVER windows in DataFrame API
This closes #29303.
---
.../docs/reference/pyflink.dataframe/dataframe.rst | 46 +++
flink-python/pyflink/dataframe/__init__.py | 3 +
flink-python/pyflink/dataframe/over_window.py | 132 +++++++
.../pyflink/dataframe/tests/test_over_window.py | 415 +++++++++++++++++++++
flink-python/pyflink/table/_over_window.py | 218 +++++++++++
flink-python/pyflink/table/expression.py | 107 +++++-
.../resolver/rules/OverWindowResolverRule.java | 50 ++-
.../utils/OperationExpressionsUtils.java | 2 +
.../operations/utils/OperationTreeBuilder.java | 3 +
.../resolver/OverWindowExpressionResolverTest.java | 237 ++++++++++++
.../utils/OperationExpressionsUtilsTest.java | 64 ++++
11 files changed, 1256 insertions(+), 21 deletions(-)
diff --git a/flink-python/docs/reference/pyflink.dataframe/dataframe.rst
b/flink-python/docs/reference/pyflink.dataframe/dataframe.rst
index 4a443034676..d03d62b27e6 100644
--- a/flink-python/docs/reference/pyflink.dataframe/dataframe.rst
+++ b/flink-python/docs/reference/pyflink.dataframe/dataframe.rst
@@ -203,3 +203,49 @@ Functions for constructing column references and literal
expressions.
col
lit
+
+OVER windows
+------------
+
+An aggregate expression can define an inline OVER window. The ordering column
may be a column
+name or expression. Use the keyword ``order_by=`` to define an inline window;
a single positional
+argument such as ``over(col("w"))`` or ``over(alias=col("w"))`` refers to a
Table API named window.
+If neither ``rows`` nor ``range`` is provided, the default frame is unbounded
RANGE through the
+current range. A scalar ``rows`` bound includes that many preceding rows and
the current row, so
+``rows=10`` covers up to 11 rows. RANGE frames include all peers with the same
ordering value.
+
+``rows`` uses integer row offsets. ``range`` accepts ``datetime.timedelta`` or
a Flink duration
+string such as ``"1 hour"`` or ``"1 h"``; this is not SQL ``INTERVAL '1'
HOUR`` syntax. Duration
+values are converted to milliseconds, truncating sub-millisecond precision.
Use Table API frame
+constants from ``pyflink.table.expressions`` when matched to the frame type:
``*_ROW`` with
+``rows`` and ``*_RANGE`` with ``range``.
+
+The current expression layer requires a single time attribute in ``order_by``.
Streaming OVER
+windows additionally require ascending order, and all OVER aggregates in the
same projection must
+use the same frame. The upper bound must be the current row/range; FOLLOWING
bounds are not
+supported in streaming. Without ``partition_by``, streaming OVER uses a
singleton distribution and
+therefore runs with parallelism one, which can limit throughput. Batch plans
support multiple
+different OVER frames in the same projection.
+
+.. code-block:: python
+
+ import pyflink.dataframe as pf
+
+ running = pf.col("amount").sum.over(
+ order_by="event_time",
+ partition_by="user_id",
+ rows=10,
+ )
+ explicit = pf.col("amount").sum.over(
+ order_by="event_time",
+ rows=(pf.preceding(2), pf.following(1)),
+ )
+
+The public frame-bound descriptor and helpers are:
+
+.. autosummary::
+ :toctree: api/
+
+ over_window.FrameBoundary
+ preceding
+ following
diff --git a/flink-python/pyflink/dataframe/__init__.py
b/flink-python/pyflink/dataframe/__init__.py
index e463bb19d0c..174a3e4df79 100644
--- a/flink-python/pyflink/dataframe/__init__.py
+++ b/flink-python/pyflink/dataframe/__init__.py
@@ -73,6 +73,7 @@ from pyflink.dataframe.io import (
read_parquet,
)
from pyflink.dataframe.iteration import CloseableIterator
+from pyflink.dataframe.over_window import following, preceding
from pyflink.dataframe.sql import sql
from pyflink.dataframe.udf import udf
from pyflink.dataframe.udtf import udtf
@@ -107,6 +108,8 @@ __all__ = [
"list_databases",
"sql",
"config",
+ "preceding",
+ "following",
"set_table_environment",
"get_table_environment",
"get_or_create_table_environment",
diff --git a/flink-python/pyflink/dataframe/over_window.py
b/flink-python/pyflink/dataframe/over_window.py
new file mode 100644
index 00000000000..6565d7d749f
--- /dev/null
+++ b/flink-python/pyflink/dataframe/over_window.py
@@ -0,0 +1,132 @@
+################################################################################
+# 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.
+################################################################################
+
+"""Utilities for constructing DataFrame OVER windows."""
+
+import datetime
+from dataclasses import dataclass
+from typing import Any, Union
+
+from pyflink.table._over_window import _BoundaryDirection, _FrameBoundary
+from pyflink.util.api_stability_decorators import PublicEvolving
+
+__all__ = ["FrameBoundary", "preceding", "following"]
+
+
+@PublicEvolving()
+@dataclass(frozen=True, init=False)
+class FrameBoundary(_FrameBoundary):
+ """
+ An opaque bound for an inline OVER window frame.
+
+ Create instances with :func:`preceding` or :func:`following` instead of
constructing this
+ class directly.
+
+ .. versionadded:: 2.4.0
+ """
+
+ _direction: _BoundaryDirection
+ _value: Any
+
+ def __init__(self) -> None:
+ raise TypeError("FrameBoundary instances must be created with
preceding() or following()")
+
+ @classmethod
+ def _create(cls, direction: _BoundaryDirection, value: Any) ->
"FrameBoundary":
+ boundary = object.__new__(cls)
+ object.__setattr__(boundary, "_direction", direction)
+ object.__setattr__(boundary, "_value", value)
+ return boundary
+
+ def __repr__(self) -> str:
+ return "%s(%r)" % (self._direction.value, self._value)
+
+
+@PublicEvolving()
+def preceding(value: Union[int, str, datetime.timedelta]) -> FrameBoundary:
+ """
+ Create a preceding bound for an inline OVER window frame.
+
+ When used in ``rows``, ``value`` must be a positive integer row count.
When used in
+ ``range``, it must be a non-negative :class:`datetime.timedelta` or a
Flink duration string,
+ such as ``"1 hour"``. The value is validated when the boundary is passed to
+ :meth:`~pyflink.table.expression.Expression.over`.
+
+ :param value: Row count or time interval before the current row or range.
+ :return: A frame-bound descriptor for the lower bound of an explicit OVER
frame.
+
+ Example::
+
+ >>> from datetime import timedelta
+ >>> import pyflink.dataframe as pf
+ >>> from pyflink.table.expressions import CURRENT_RANGE, CURRENT_ROW
+ >>> running = pf.col("amount").sum.over(
+ ... order_by="event_time",
+ ... rows=(pf.preceding(2), CURRENT_ROW),
+ ... )
+ >>> last_hour = pf.col("amount").sum.over(
+ ... order_by="event_time",
+ ... range=(pf.preceding("1 hour"), CURRENT_RANGE),
+ ... )
+ >>> last_five_minutes = pf.col("amount").sum.over(
+ ... order_by="event_time",
+ ... range=(pf.preceding(timedelta(minutes=5)), CURRENT_RANGE),
+ ... )
+
+ .. versionadded:: 2.4.0
+ """
+
+ return FrameBoundary._create(_BoundaryDirection.PRECEDING, value)
+
+
+@PublicEvolving()
+def following(value: Union[int, str, datetime.timedelta]) -> FrameBoundary:
+ """
+ Create a following bound for an inline OVER window frame.
+
+ When used in ``rows``, ``value`` must be a positive integer row count.
When used in
+ ``range``, it must be a non-negative :class:`datetime.timedelta` or a
Flink duration string,
+ such as ``"1 hour"``. The value is validated when the boundary is passed to
+ :meth:`~pyflink.table.expression.Expression.over`. Following upper bounds
are supported only
+ in batch mode.
+
+ :param value: Row count or time interval after the current row or range.
+ :return: A frame-bound descriptor for the upper bound of an explicit OVER
frame.
+
+ Example::
+
+ >>> from datetime import timedelta
+ >>> import pyflink.dataframe as pf
+ >>> from pyflink.table.expressions import CURRENT_RANGE
+ >>> centered = pf.col("amount").sum.over(
+ ... order_by="event_time",
+ ... rows=(pf.preceding(2), pf.following(1)),
+ ... )
+ >>> next_hour = pf.col("amount").sum.over(
+ ... order_by="event_time",
+ ... range=(CURRENT_RANGE, pf.following("1 hour")),
+ ... )
+ >>> next_five_minutes = pf.col("amount").sum.over(
+ ... order_by="event_time",
+ ... range=(CURRENT_RANGE, pf.following(timedelta(minutes=5))),
+ ... )
+
+ .. versionadded:: 2.4.0
+ """
+
+ return FrameBoundary._create(_BoundaryDirection.FOLLOWING, value)
diff --git a/flink-python/pyflink/dataframe/tests/test_over_window.py
b/flink-python/pyflink/dataframe/tests/test_over_window.py
new file mode 100644
index 00000000000..16826bf7b44
--- /dev/null
+++ b/flink-python/pyflink/dataframe/tests/test_over_window.py
@@ -0,0 +1,415 @@
+################################################################################
+# 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.
+################################################################################
+
+import os
+from datetime import datetime, timedelta
+
+import pyflink.dataframe as pf
+from pyflink.common import Row
+from pyflink.datastream import RuntimeExecutionMode, StreamExecutionEnvironment
+from pyflink.java_gateway import get_gateway
+from pyflink.table import StreamTableEnvironment
+from pyflink.table import expressions as table_expressions
+from pyflink.table.expressions import lit, row
+from pyflink.table.table import Table
+from pyflink.table.utils import to_expression_jarray
+from pyflink.testing.test_case_utils import PyFlinkTestCase
+from pyflink.util.exceptions import TableException
+from pyflink.util.java_utils import to_jarray
+
+
+def _register_rowtime_events(table_environment, input_path, rows):
+ with open(input_path, "w", encoding="utf-8") as events:
+ events.write("\n".join(rows) + "\n")
+ table_environment.execute_sql("DROP TEMPORARY TABLE IF EXISTS events")
+ table_environment.execute_sql(
+ "CREATE TEMPORARY TABLE events ("
+ "amount BIGINT, event_time TIMESTAMP(3), "
+ "WATERMARK FOR event_time AS event_time - INTERVAL '1' SECOND"
+ ") WITH ("
+ "'connector' = 'filesystem', "
+ "'path' = 'file://%s', "
+ "'format' = 'csv'"
+ ")" % input_path
+ )
+ return pf.from_table(table_environment.from_path("events"))
+
+
+class OverWindowTests(PyFlinkTestCase):
+
+ def test_dataframe_does_not_export_table_frame_constants(self):
+ for name in (
+ "CURRENT_ROW",
+ "UNBOUNDED",
+ "UNBOUNDED_PRECEDING",
+ "UNBOUNDED_FOLLOWING",
+ ):
+ self.assertFalse(hasattr(pf, name))
+ self.assertTrue(callable(pf.preceding))
+ self.assertTrue(callable(pf.following))
+
+ def test_legacy_over_call_is_preserved(self):
+ self.assertEqual("over(sum(amount), w)",
str(pf.col("amount").sum.over(pf.col("w"))))
+ self.assertEqual(
+ "over(sum(amount), w)",
+ str(pf.col("amount").sum.over(alias=pf.col("w"))),
+ )
+
+ def test_dataframe_over_supports_scalar_and_explicit_frames(self):
+ scalar = pf.col("amount").sum.over(order_by="rowtime", rows=2)
+ explicit = pf.col("amount").sum.over(
+ order_by="rowtime",
+ partition_by="category",
+ rows=(pf.preceding(2), pf.following(1)),
+ )
+
+ self.assertIn("over(sum(amount), rowtime, 2, CURRENT_ROW)",
str(scalar))
+ self.assertIn("over(sum(amount), rowtime, 2, 1, category)",
str(explicit))
+
+ def test_dataframe_over_expression_remains_composable(self):
+ expression = (
+ pf.col("amount").sum.over(order_by="rowtime", rows=2) + 1
+ ).alias("adjusted")
+ self.assertIn("as(plus(over(sum(amount)", str(expression))
+
+ def test_over_rejects_invalid_dispatch(self):
+ with self.assertRaisesRegex(
+ TypeError, "cannot specify both alias and order_by"
+ ):
+ pf.col("amount").sum.over(pf.col("rowtime"),
order_by=pf.col("other"))
+ with self.assertRaisesRegex(TypeError, "cannot combine alias"):
+ pf.col("amount").sum.over(pf.col("rowtime"), rows=2)
+ with self.assertRaisesRegex(TypeError, "unexpected keyword"):
+ pf.col("amount").sum.over(order_by="rowtime", unknown=True)
+
+ def test_frame_validation_through_public_over_api(self):
+ with self.assertRaisesRegex(ValueError, "mutually exclusive"):
+ pf.col("amount").sum.over(order_by="rowtime", rows=1, range="1
second")
+ with self.assertRaisesRegex(ValueError, "larger than 0"):
+ pf.col("amount").sum.over(order_by="rowtime", rows=0)
+ with self.assertRaisesRegex(TypeError, "two-element"):
+ pf.col("amount").sum.over(order_by="rowtime",
rows=(pf.preceding(1),))
+ with self.assertRaisesRegex(ValueError, "lower bound"):
+ pf.col("amount").sum.over(
+ order_by="rowtime",
+ rows=(pf.following(1), table_expressions.CURRENT_ROW),
+ )
+ with self.assertRaisesRegex(TypeError, "bool"):
+ pf.col("amount").sum.over(order_by="rowtime", rows=True)
+
+ def test_partition_validation_through_public_over_api(self):
+ expression = pf.col("amount").sum.over(
+ order_by="rowtime", partition_by=["category", pf.col("user")]
+ )
+ self.assertIn("category", str(expression))
+ self.assertIn("user", str(expression))
+ with self.assertRaisesRegex(TypeError, "must not be empty"):
+ pf.col("amount").sum.over(order_by="rowtime", partition_by=[])
+ with self.assertRaisesRegex(TypeError, "column name or Expression"):
+ pf.col("amount").sum.over(order_by="rowtime", partition_by=[42])
+
+ def
test_flink_duration_strings_are_accepted_and_sql_interval_is_rejected(self):
+ hour_word = pf.col("amount").sum.over(order_by="rowtime", range="1
hour")
+ hour_abbreviation = pf.col("amount").sum.over(
+ order_by="rowtime", range="1 h"
+ )
+
+ self.assertEqual(str(hour_word), str(hour_abbreviation))
+ with self.assertRaisesRegex(ValueError, "duration string"):
+ pf.col("amount").sum.over(
+ order_by="rowtime", range="INTERVAL '1' HOUR"
+ )
+
+ def test_sub_millisecond_range_is_truncated_to_milliseconds(self):
+ truncated = pf.col("amount").sum.over(
+ order_by="rowtime", range=timedelta(milliseconds=1,
microseconds=500)
+ )
+ whole_millisecond = pf.col("amount").sum.over(
+ order_by="rowtime", range=timedelta(milliseconds=1)
+ )
+
+ self.assertEqual(str(truncated), str(whole_millisecond))
+
+ def test_default_and_unbounded_rows_frames_are_constructed(self):
+ default_frame = pf.col("amount").sum.over(order_by="rowtime")
+ unbounded_rows = pf.col("amount").sum.over(
+ order_by="rowtime", rows=table_expressions.UNBOUNDED_ROW
+ )
+
+ self.assertIn("null, null", str(default_frame))
+ self.assertIn("UNBOUNDED_ROW", str(unbounded_rows))
+ self.assertIn("CURRENT_ROW", str(unbounded_rows))
+
+ def test_lead_and_lag_without_explicit_frames_use_omitted_bounds(self):
+ lag = table_expressions.lag(pf.col("amount")).over(order_by="rowtime")
+ lead =
table_expressions.lead(pf.col("amount")).over(order_by="rowtime")
+
+ self.assertIn("over(lag(amount, 1), rowtime, null, null)", str(lag))
+ self.assertIn("over(lead(amount, 1), rowtime, null, null)", str(lead))
+
+ def test_matching_table_frame_constants_are_accepted(self):
+ cases = (
+ (
+ "rows",
+ (table_expressions.UNBOUNDED_ROW,
table_expressions.CURRENT_ROW),
+ "UNBOUNDED_ROW, CURRENT_ROW",
+ ),
+ (
+ "range",
+ (table_expressions.UNBOUNDED_RANGE,
table_expressions.CURRENT_RANGE),
+ "UNBOUNDED_RANGE, CURRENT_RANGE",
+ ),
+ ("rows", table_expressions.CURRENT_ROW, "CURRENT_ROW,
CURRENT_ROW"),
+ ("range", table_expressions.CURRENT_RANGE, "CURRENT_RANGE,
CURRENT_RANGE"),
+ )
+ for kind, frame, expected in cases:
+ with self.subTest(kind=kind, frame=frame):
+ expression = pf.col("amount").sum.over(
+ order_by="rowtime", **{kind: frame}
+ )
+ self.assertIn("rowtime, " + expected, str(expression))
+
+ def test_mismatched_table_frame_constants_are_rejected(self):
+ cases = (
+ ("rows", table_expressions.CURRENT_RANGE, "CURRENT_RANGE"),
+ ("range", table_expressions.CURRENT_ROW, "CURRENT_ROW"),
+ ("rows", table_expressions.UNBOUNDED_RANGE, "UNBOUNDED_RANGE"),
+ ("range", table_expressions.UNBOUNDED_ROW, "UNBOUNDED_ROW"),
+ )
+ for kind, frame, constant in cases:
+ with self.subTest(kind=kind, constant=constant):
+ with self.assertRaisesRegex(TypeError, "%s.*%s" % (constant,
kind)):
+ pf.col("amount").sum.over(order_by="rowtime", **{kind:
frame})
+
+ def test_over_window_module_introspection_does_not_access_gateway(self):
+ import unittest
+ from unittest.mock import patch
+
+ from pyflink.dataframe import over_window
+ from pyflink.table import _over_window
+
+ with patch(
+ "pyflink.table.expression.get_gateway",
+ side_effect=AssertionError("module introspection accessed the
gateway"),
+ ):
+ for module in (over_window, _over_window):
+ for value in vars(module).values():
+ try:
+ issubclass(value, unittest.TestCase)
+ except TypeError:
+ pass
+
+
+class OverWindowBatchITTests(PyFlinkTestCase):
+ def setUp(self):
+ previous_environment = pf.get_table_environment()
+ self.addCleanup(pf.set_table_environment, previous_environment)
+ self.env = StreamExecutionEnvironment.get_execution_environment()
+ self.env.set_runtime_mode(RuntimeExecutionMode.BATCH)
+ self.t_env = StreamTableEnvironment.create(self.env)
+ pf.set_table_environment(self.t_env)
+
+ def _rowtime_dataframe(self, rows):
+ # Build a bounded source whose schema carries the legacy ROWTIME
indicator.
+ jvm = get_gateway().jvm
+ event_time_type = jvm.org.apache.flink.table.types.AtomicDataType(
+ jvm.org.apache.flink.table.types.logical.TimestampType(
+ False,
jvm.org.apache.flink.table.types.logical.TimestampKind.ROWTIME, 3
+ )
+ ).bridgedTo(jvm.java.sql.Timestamp(0).getClass())
+ row_type = jvm.org.apache.flink.table.api.DataTypes.ROW(
+ to_jarray(
+ jvm.org.apache.flink.table.api.DataTypes.Field,
+ [
+ jvm.org.apache.flink.table.api.DataTypes.FIELD(
+ "amount",
jvm.org.apache.flink.table.api.DataTypes.BIGINT()
+ ),
+ jvm.org.apache.flink.table.api.DataTypes.FIELD(
+ "event_time", event_time_type
+ ),
+ ],
+ )
+ )
+ elements = [
+ row(
+ lit(int(value)),
+ lit(datetime.strptime(timestamp, "%Y-%m-%d %H:%M:%S.%f")),
+ )
+ for value, timestamp in (line.split(",") for line in rows)
+ ]
+ j_table = self.t_env._j_tenv.fromValues(
+ row_type, to_expression_jarray(elements)
+ )
+ return pf.from_table(Table(j_table, self.t_env))
+
+ def test_rows_frames_and_global_cumulative_result(self):
+ dataframe = self._rowtime_dataframe(
+ [
+ "10,1970-01-01 00:00:00.000",
+ "20,1970-01-01 00:00:01.000",
+ "5,1970-01-01 00:00:02.000",
+ ],
+ )
+
+ result = dataframe.select(
+ "amount",
+ rows_1=pf.col("amount").sum.over(
+ order_by="event_time", rows=1
+ ),
+ rows_2=pf.col("amount").sum.over(
+ order_by="event_time", rows=2
+ ),
+ global_total=pf.col("amount").sum.over(
+ order_by="event_time", rows=table_expressions.UNBOUNDED_ROW
+ ),
+ )
+
+ self.assertCountEqual(
+ result.collect(),
+ [
+ Row(10, 10, 10, 10),
+ Row(20, 30, 30, 30),
+ Row(5, 25, 35, 35),
+ ],
+ )
+
+ def test_rows_and_range_current_row_handle_peers_differently(self):
+ dataframe = self._rowtime_dataframe(
+ [
+ "10,1970-01-01 00:00:00.000",
+ "20,1970-01-01 00:00:00.000",
+ "5,1970-01-01 00:00:01.000",
+ ],
+ )
+
+ result = dataframe.select(
+ "amount",
+ rows_sum=pf.col("amount").sum.over(
+ order_by="event_time",
+ rows=(table_expressions.CURRENT_ROW,
table_expressions.CURRENT_ROW),
+ ),
+ range_sum=pf.col("amount").sum.over(
+ order_by="event_time",
+ range=(
+ table_expressions.CURRENT_RANGE,
+ table_expressions.CURRENT_RANGE,
+ ),
+ ),
+ )
+
+ self.assertCountEqual(
+ result.collect(),
+ [Row(10, 10, 30), Row(20, 20, 30), Row(5, 5, 5)],
+ )
+
+ def test_range_interval_uses_event_time_distance(self):
+ dataframe = self._rowtime_dataframe(
+ [
+ "10,1970-01-01 00:00:00.000",
+ "20,1970-01-01 00:00:01.000",
+ "5,1970-01-01 00:00:02.000",
+ ],
+ )
+
+ result = dataframe.select(
+ "amount",
+ range_sum=pf.col("amount").sum.over(
+ order_by="event_time", range=timedelta(seconds=1)
+ ),
+ )
+
+ self.assertCountEqual(
+ result.collect(),
+ [Row(10, 10), Row(20, 30), Row(5, 25)],
+ )
+
+
+class OverWindowStreamITTests(PyFlinkTestCase):
+ def setUp(self):
+ previous_environment = pf.get_table_environment()
+ self.addCleanup(pf.set_table_environment, previous_environment)
+ self.env = StreamExecutionEnvironment.get_execution_environment()
+ self.t_env = StreamTableEnvironment.create(self.env)
+ pf.set_table_environment(self.t_env)
+ self.input_path = os.path.join(self.tempdir, "over_events.csv")
+ _register_rowtime_events(
+ self.t_env,
+ self.input_path,
+ [
+ "10,1970-01-01 00:00:00.000",
+ "20,1970-01-01 00:00:01.000",
+ ],
+ )
+
+ def _rowtime_source(self):
+ return pf.from_table(self.t_env.from_path("events"))
+
+ def test_unbounded_rows_over_emits_streaming_results(self):
+ result = self._rowtime_source().select(
+ "amount",
+ running_total=pf.col("amount").sum.over(
+ order_by="event_time", rows=table_expressions.UNBOUNDED_ROW
+ ),
+ )
+
+ self.assertCountEqual(
+ result.collect(),
+ [Row(10, 10), Row(20, 30)],
+ )
+
+ def
test_different_frames_in_one_projection_keep_streaming_restriction(self):
+ dataframe = self._rowtime_source().select(
+ first=pf.col("amount").sum.over(order_by="event_time", rows=1),
+ second=pf.col("amount").sum.over(order_by="event_time", rows=2),
+ )
+
+ with self.assertRaisesRegex(
+ TableException, "All aggregates must be computed on the same
window"
+ ):
+ dataframe.collect()
+
+ def test_following_upper_bound_keeps_streaming_restriction(self):
+ dataframe = self._rowtime_source().select(
+ total=pf.col("amount").sum.over(
+ order_by="event_time",
+ rows=(pf.preceding(1), pf.following(1)),
+ )
+ )
+
+ with self.assertRaisesRegex(
+ TableException, "OVER RANGE FOLLOWING windows are not supported
yet"
+ ):
+ dataframe.collect()
+
+ def test_unpartitioned_over_plan_uses_singleton_distribution(self):
+ dataframe = self._rowtime_source().select(
+ total=pf.col("amount").sum.over(
+ order_by="event_time", rows=table_expressions.UNBOUNDED_ROW
+ )
+ )
+
+ self.assertRegex(
+ dataframe._table.explain().lower(),
+ r"exchange\(distribution=\[single\]\)",
+ )
+
+
+if __name__ == "__main__":
+ import unittest
+
+ unittest.main()
diff --git a/flink-python/pyflink/table/_over_window.py
b/flink-python/pyflink/table/_over_window.py
new file mode 100644
index 00000000000..b30094aaab3
--- /dev/null
+++ b/flink-python/pyflink/table/_over_window.py
@@ -0,0 +1,218 @@
+################################################################################
+# 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.
+################################################################################
+
+"""Internal helpers for constructing expression-level OVER windows."""
+
+import datetime
+from enum import Enum
+from typing import Any, List, Tuple, Union
+
+from pyflink.java_gateway import get_gateway
+from pyflink.table.expression import Expression
+
+
+class _BoundaryDirection(Enum):
+ PRECEDING = "preceding"
+ FOLLOWING = "following"
+
+
+class _FrameBoundary:
+ _direction: _BoundaryDirection
+ _value: Any
+
+
+def _to_column(value: Union[str, Expression], parameter_name: str) ->
Expression:
+ if isinstance(value, str):
+ from pyflink.table.expressions import col
+
+ return col(value)
+ if isinstance(value, Expression):
+ return value
+ raise TypeError("%s must be a column name or Expression" % parameter_name)
+
+
+def _normalize_partition_by(value: Any) -> List[Expression]:
+ if value is None:
+ return []
+ if isinstance(value, (str, Expression)):
+ values = [value]
+ elif isinstance(value, (list, tuple)):
+ values = list(value)
+ else:
+ raise TypeError("partition_by must be a column name or Expression
sequence")
+ if not values:
+ raise TypeError("partition_by must not be empty")
+ return [_to_column(item, "partition_by") for item in values]
+
+
+def _interval_expression(value: Union[str, datetime.timedelta]) -> Expression:
+ if isinstance(value, datetime.timedelta):
+ if value < datetime.timedelta(0):
+ raise ValueError("time interval must not be negative")
+ millis = value // datetime.timedelta(milliseconds=1)
+ elif isinstance(value, str):
+ try:
+ duration =
get_gateway().jvm.org.apache.flink.util.TimeUtils.parseDuration(value)
+ millis = duration.toMillis()
+ except Exception as exc:
+ raise ValueError("range must be a valid duration string") from exc
+ else:
+ raise TypeError("range boundaries must be a datetime.timedelta or
duration string")
+
+ if millis < 0:
+ raise ValueError("time interval must not be negative")
+ j_expr = (
+ get_gateway()
+
.jvm.org.apache.flink.table.expressions.ApiExpressionUtils.intervalOfMillis(millis)
+ )
+ return Expression(j_expr)
+
+
+def _row_expression(value: Any) -> Expression:
+ if isinstance(value, bool):
+ raise TypeError("row interval must not be bool")
+ if not isinstance(value, int):
+ raise TypeError("row boundaries must be integers")
+ if value <= 0:
+ raise ValueError("row interval must be larger than 0")
+ from pyflink.table.expressions import row_interval
+
+ return row_interval(value)
+
+
+def _current_expression(kind: str) -> Expression:
+ # Keep Expression instances out of module globals: introspection can
initialize the gateway.
+ from pyflink.table.expressions import CURRENT_RANGE, CURRENT_ROW
+
+ return CURRENT_ROW if kind == "rows" else CURRENT_RANGE
+
+
+def _value_expression(value: Any, kind: str) -> Expression:
+ return _row_expression(value) if kind == "rows" else
_interval_expression(value)
+
+
+def _normalize_bound(bound: Any, kind: str, position: str) -> Expression:
+ from pyflink.table.expressions import (
+ CURRENT_RANGE,
+ CURRENT_ROW as TABLE_CURRENT_ROW,
+ UNBOUNDED_RANGE,
+ UNBOUNDED_ROW,
+ )
+
+ table_bound_names = (
+ (TABLE_CURRENT_ROW, "CURRENT_ROW", "rows"),
+ (CURRENT_RANGE, "CURRENT_RANGE", "range"),
+ (UNBOUNDED_ROW, "UNBOUNDED_ROW", "rows"),
+ (UNBOUNDED_RANGE, "UNBOUNDED_RANGE", "range"),
+ )
+ for table_bound, table_name, table_kind in table_bound_names:
+ if bound is table_bound:
+ if kind != table_kind:
+ raise TypeError("%s cannot be used with %s frames" %
(table_name, kind))
+ return table_bound
+ if not isinstance(bound, _FrameBoundary):
+ raise TypeError(
+ "%s bound must be preceding(), following(), or a matching Table
API frame constant"
+ % position
+ )
+
+ expected_direction = (
+ _BoundaryDirection.PRECEDING
+ if position == "lower"
+ else _BoundaryDirection.FOLLOWING
+ )
+ if bound._direction is not expected_direction:
+ raise ValueError("invalid %s bound direction" % position)
+ return _value_expression(bound._value, kind)
+
+
+def _validate_explicit_bound_kinds(bounds: Tuple[Any, Any], kind: str) -> None:
+ for bound in bounds:
+ if not isinstance(bound, _FrameBoundary):
+ continue
+ value = bound._value
+ if kind == "rows" and (
+ isinstance(value, bool)
+ or not isinstance(value, int)
+ ):
+ raise ValueError("rows bounds must use the same frame kind")
+ if kind == "range" and isinstance(value, (bool, int)):
+ raise ValueError("range bounds must use the same frame kind")
+
+
+def _normalize_scalar_frame(value: Any, kind: str) -> Tuple[Expression,
Expression]:
+ if isinstance(value, Expression):
+ return _normalize_bound(value, kind, "lower"),
_current_expression(kind)
+ return _value_expression(value, kind), _current_expression(kind)
+
+
+def _normalize_frame(
+ rows: Any, range_: Any
+) -> Tuple[Expression, Expression]:
+ if rows is not None and range_ is not None:
+ raise ValueError("rows and range are mutually exclusive")
+
+ if rows is None and range_ is None:
+ from pyflink.table.expressions import lit
+ from pyflink.table.types import DataTypes
+
+ omitted_bound = lit(None, DataTypes.NULL())
+ return omitted_bound, omitted_bound
+
+ kind = "rows" if rows is not None else "range"
+ value = rows if rows is not None else range_
+ if isinstance(value, tuple):
+ if len(value) != 2:
+ raise TypeError("frame tuple must contain exactly two-element
bounds")
+ lower_bound, upper_bound = value
+ _validate_explicit_bound_kinds((lower_bound, upper_bound), kind)
+ return (
+ _normalize_bound(lower_bound, kind, "lower"),
+ _normalize_bound(upper_bound, kind, "upper"),
+ )
+ return _normalize_scalar_frame(value, kind)
+
+
+def _build_over_expression(
+ aggregate: Expression,
+ order_by: Union[str, Expression],
+ partition_by: Any = None,
+ rows: Any = None,
+ range_: Any = None,
+) -> Expression:
+ if not isinstance(aggregate, Expression):
+ raise TypeError("aggregate must be an Expression")
+ order_expression = _to_column(order_by, "order_by")
+ partitions = _normalize_partition_by(partition_by)
+ preceding_expression, following_expression = _normalize_frame(rows, range_)
+
+ gateway = get_gateway()
+ java_args = [
+ aggregate._j_expr,
+ order_expression._j_expr,
+ preceding_expression._j_expr,
+ following_expression._j_expr,
+ ] + [partition._j_expr for partition in partitions]
+ java_list = gateway.jvm.java.util.ArrayList()
+ for java_arg in java_args:
+ java_list.add(java_arg)
+ unresolved =
gateway.jvm.org.apache.flink.table.expressions.ApiExpressionUtils.unresolvedCall(
+
gateway.jvm.org.apache.flink.table.functions.BuiltInFunctionDefinitions.OVER,
+ java_list,
+ )
+ return
Expression(gateway.jvm.org.apache.flink.table.api.ApiExpression(unresolved))
diff --git a/flink-python/pyflink/table/expression.py
b/flink-python/pyflink/table/expression.py
index 2b2ff232467..2f45ab0408d 100644
--- a/flink-python/pyflink/table/expression.py
+++ b/flink-python/pyflink/table/expression.py
@@ -1597,22 +1597,97 @@ class Expression(Generic[T]):
"""
return _binary_op("repeat")(self, n)
- def over(self, alias) -> 'Expression':
- """
- Defines an aggregation to be used for a previously specified over
window.
-
- Example:
- ::
-
- >>> tab.window(Over
- >>> .partition_by(col('c'))
- >>> .order_by(col('rowtime'))
- >>> .preceding(row_interval(2))
- >>> .following(CURRENT_ROW)
- >>> .alias("w")) \\
- >>> .select(col('c'), col('a'), col('a').count.over(col('w')))
- """
- return _binary_op("over")(self, alias)
+ def over(
+ self,
+ alias=None,
+ *,
+ order_by=None,
+ partition_by=None,
+ rows=None,
+ range=None,
+ ) -> 'Expression':
+ """
+ Defines an aggregation over a named Table API window or an inline
DataFrame window.
+
+ A positional ``alias`` or ``alias=`` refers to a named Table API
window. Inline DataFrame
+ windows require ``order_by=`` and can use ``rows`` or ``range``.
Matching Table API frame
+ constants are also accepted (``*_ROW`` with ``rows``, ``*_RANGE`` with
``range``).
+
+ A scalar ``rows`` value is a positive integer describing the number of
preceding rows. A
+ scalar ``range`` value is a non-negative :class:`datetime.timedelta`
or Flink duration
+ string describing the preceding time interval. Both frames can instead
be specified as a
+ two-element ``(lower, upper)`` tuple. Explicit bounds are created with
+ :func:`~pyflink.dataframe.over_window.preceding` and
+ :func:`~pyflink.dataframe.over_window.following`, or with matching
constants from
+ :mod:`pyflink.table.expressions`.
+
+ :param alias: Expression identifying a previously declared Table API
OVER window. It
+ cannot be combined with inline window arguments.
+ :type alias: Optional[Expression]
+ :param order_by: Column name or expression identifying the single
ordering time attribute
+ of an inline window.
+ :type order_by: Optional[Union[str, Expression]]
+ :param partition_by: Optional column name, expression, or non-empty
list or tuple of column
+ names and expressions used as partition keys.
+ :type partition_by: Optional[Union[str, Expression, List[Union[str,
Expression]],
+ Tuple[Union[str, Expression], ...]]]
+ :param rows: Optional positive row count or two-element ROW-frame
bound tuple.
+ :type rows: Optional[Union[int, Expression, Tuple[Union[FrameBoundary,
Expression],
+ Union[FrameBoundary, Expression]]]]
+ :param range: Optional Flink duration string,
:class:`datetime.timedelta`, or two-element
+ RANGE-frame bound tuple.
+ :type range: Optional[Union[str, datetime.timedelta, Expression,
+ Tuple[Union[FrameBoundary, Expression],
+ Union[FrameBoundary, Expression]]]]
+ :return: An OVER aggregation expression.
+ :raises TypeError: If named- and inline-window arguments are combined,
a required
+ ``order_by`` is missing, or an argument has an
unsupported type.
+ :raises ValueError: If ``rows`` and ``range`` are both provided, or a
frame bound has an
+ invalid value, direction, or frame kind.
+
+ Given a DataFrame ``df`` containing ``key``, ``amount``, and
``rowtime``::
+
+ >>> import pyflink.dataframe as pf
+ >>> from pyflink.table.expressions import CURRENT_ROW,
UNBOUNDED_ROW
+ >>> result = df.select(
+ ... "key",
+ ... running=pf.col("amount").sum.over(
+ ... order_by="rowtime", partition_by="key",
+ ... rows=(pf.preceding(2), CURRENT_ROW)))
+
+ Table API frame constants can also define the bounds::
+
+ >>> cumulative = pf.col("amount").sum.over(
+ ... order_by="rowtime", rows=(UNBOUNDED_ROW, CURRENT_ROW))
+
+ For an existing named Table API window ``w``::
+
+ >>> pf.col("amount").sum.over(alias=pf.col("w"))
+
+ .. versionchanged:: 2.4.0
+ Added inline DataFrame window arguments while preserving named
Table API windows.
+ """
+ if alias is not None and order_by is not None:
+ raise TypeError("over() cannot specify both alias and order_by")
+ if alias is not None and (
+ partition_by is not None or rows is not None or range is not None
+ ):
+ raise TypeError("over() cannot combine alias with inline window
arguments")
+ if alias is not None:
+ return _binary_op("over")(self, alias)
+
+ if order_by is None:
+ raise TypeError("over() missing required argument: order_by")
+
+ from pyflink.table._over_window import _build_over_expression
+
+ return _build_over_expression(
+ self,
+ order_by,
+ partition_by=partition_by,
+ rows=rows,
+ range_=range,
+ )
@property
def reverse(self) -> 'Expression[str]':
diff --git
a/flink-table/flink-table-api-java/src/main/java/org/apache/flink/table/expressions/resolver/rules/OverWindowResolverRule.java
b/flink-table/flink-table-api-java/src/main/java/org/apache/flink/table/expressions/resolver/rules/OverWindowResolverRule.java
index ea7fe0dfe66..5f78a0ec52f 100644
---
a/flink-table/flink-table-api-java/src/main/java/org/apache/flink/table/expressions/resolver/rules/OverWindowResolverRule.java
+++
b/flink-table/flink-table-api-java/src/main/java/org/apache/flink/table/expressions/resolver/rules/OverWindowResolverRule.java
@@ -66,6 +66,34 @@ final class OverWindowResolverRule implements ResolverRule {
if (unresolvedCall.getFunctionDefinition() ==
BuiltInFunctionDefinitions.OVER) {
List<Expression> children = unresolvedCall.getChildren();
+
+ if (children.size() >= 4) {
+ UnresolvedCallExpression agg = (UnresolvedCallExpression)
children.get(0);
+ List<Expression> expandedChildren = new
ArrayList<>(children);
+ Expression preceding = children.get(2);
+ Expression following = children.get(3);
+
+ if (isLeadOrLag(agg)) {
+ if (!isNullLiteral(preceding) ||
!isNullLiteral(following)) {
+ throw leadLagWithBoundsException();
+ }
+ } else if (isNullLiteral(preceding) &&
isNullLiteral(following)) {
+ expandedChildren.set(2,
valueLiteral(OverWindowRange.UNBOUNDED_RANGE));
+ expandedChildren.set(3,
valueLiteral(OverWindowRange.CURRENT_RANGE));
+ }
+
+ return unresolvedCall.replaceArgs(
+ expandedChildren.stream()
+ .map(expr -> expr.accept(this))
+ .collect(Collectors.toList()));
+ }
+
+ if (children.size() != 2) {
+ throw new ValidationException(
+ "An OVER call expects a window alias or an inline
window "
+ + "specification.");
+ }
+
Expression alias = children.get(1);
LocalOverWindow referenceWindow =
@@ -81,13 +109,10 @@ final class OverWindowResolverRule implements ResolverRule
{
final List<Expression> newArgs = new ArrayList<>();
newArgs.add(agg);
newArgs.add(referenceWindow.getOrderBy());
- if (agg.getFunctionDefinition() ==
BuiltInFunctionDefinitions.LAG
- || agg.getFunctionDefinition() ==
BuiltInFunctionDefinitions.LEAD) {
+ if (isLeadOrLag(agg)) {
if (referenceWindow.getPreceding().isPresent()
|| referenceWindow.getFollowing().isPresent()) {
- throw new ValidationException(
- "LEAD/LAG functions do not support "
- + "providing RANGE/ROW bounds.");
+ throw leadLagWithBoundsException();
}
newArgs.add(valueLiteral(null, DataTypes.NULL()));
newArgs.add(valueLiteral(null, DataTypes.NULL()));
@@ -111,6 +136,21 @@ final class OverWindowResolverRule implements ResolverRule
{
}
}
+ private static boolean isLeadOrLag(UnresolvedCallExpression aggregate)
{
+ return aggregate.getFunctionDefinition() ==
BuiltInFunctionDefinitions.LAG
+ || aggregate.getFunctionDefinition() ==
BuiltInFunctionDefinitions.LEAD;
+ }
+
+ private static boolean isNullLiteral(Expression expression) {
+ return expression instanceof ValueLiteralExpression
+ && ((ValueLiteralExpression) expression).isNull();
+ }
+
+ private static ValidationException leadLagWithBoundsException() {
+ return new ValidationException(
+ "LEAD/LAG functions do not support providing RANGE/ROW
bounds.");
+ }
+
private Expression calculateOverWindowFollowing(
LocalOverWindow referenceWindow, Expression preceding) {
return referenceWindow
diff --git
a/flink-table/flink-table-api-java/src/main/java/org/apache/flink/table/operations/utils/OperationExpressionsUtils.java
b/flink-table/flink-table-api-java/src/main/java/org/apache/flink/table/operations/utils/OperationExpressionsUtils.java
index 81b4daf380c..c71205b68f6 100644
---
a/flink-table/flink-table-api-java/src/main/java/org/apache/flink/table/operations/utils/OperationExpressionsUtils.java
+++
b/flink-table/flink-table-api-java/src/main/java/org/apache/flink/table/operations/utils/OperationExpressionsUtils.java
@@ -167,6 +167,8 @@ public class OperationExpressionsUtils {
aggregates.computeIfAbsent(unresolvedCall, expr -> "EXPR$" +
uniqueId++);
} else if (WINDOW_PROPERTIES.contains(functionDefinition)) {
properties.computeIfAbsent(unresolvedCall, expr -> "EXPR$" +
uniqueId++);
+ } else if (functionDefinition == BuiltInFunctionDefinitions.OVER) {
+ // OVER aggregates are evaluated by window processing, not as
regular aggregates.
} else {
unresolvedCall.getChildren().forEach(c -> c.accept(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 290999be8e7..28978469b6e 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
@@ -1018,6 +1018,9 @@ public final class OperationTreeBuilder {
@Override
public Void visit(UnresolvedCallExpression call) {
+ if (call.getFunctionDefinition() ==
BuiltInFunctionDefinitions.OVER) {
+ return null;
+ }
if (isFunctionOfKind(call, FunctionKind.AGGREGATE)) {
throw new ValidationException(exceptionMessage);
}
diff --git
a/flink-table/flink-table-api-java/src/test/java/org/apache/flink/table/expressions/resolver/OverWindowExpressionResolverTest.java
b/flink-table/flink-table-api-java/src/test/java/org/apache/flink/table/expressions/resolver/OverWindowExpressionResolverTest.java
new file mode 100644
index 00000000000..7be0da28cd7
--- /dev/null
+++
b/flink-table/flink-table-api-java/src/test/java/org/apache/flink/table/expressions/resolver/OverWindowExpressionResolverTest.java
@@ -0,0 +1,237 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.flink.table.expressions.resolver;
+
+import org.apache.flink.table.api.DataTypes;
+import org.apache.flink.table.api.Over;
+import org.apache.flink.table.api.OverWindow;
+import org.apache.flink.table.api.OverWindowRange;
+import org.apache.flink.table.api.TableConfig;
+import org.apache.flink.table.api.ValidationException;
+import org.apache.flink.table.catalog.CatalogTable;
+import org.apache.flink.table.catalog.ContextResolvedTable;
+import org.apache.flink.table.catalog.ResolvedCatalogTable;
+import org.apache.flink.table.catalog.ResolvedSchema;
+import org.apache.flink.table.expressions.CallExpression;
+import org.apache.flink.table.expressions.Expression;
+import org.apache.flink.table.expressions.FieldReferenceExpression;
+import org.apache.flink.table.expressions.ValueLiteralExpression;
+import org.apache.flink.table.functions.BuiltInFunctionDefinitions;
+import org.apache.flink.table.functions.FunctionDefinition;
+import org.apache.flink.table.legacy.api.TableSchema;
+import org.apache.flink.table.operations.QueryOperation;
+import org.apache.flink.table.operations.SourceQueryOperation;
+import org.apache.flink.table.types.AtomicDataType;
+import org.apache.flink.table.types.DataType;
+import org.apache.flink.table.types.logical.LocalZonedTimestampType;
+import org.apache.flink.table.types.logical.TimestampKind;
+import org.apache.flink.table.types.utils.DataTypeFactoryMock;
+import org.apache.flink.table.utils.FunctionLookupMock;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Optional;
+
+import static org.apache.flink.table.api.Expressions.$;
+import static org.apache.flink.table.api.Expressions.lit;
+import static
org.apache.flink.table.expressions.ApiExpressionUtils.unresolvedCall;
+import static
org.apache.flink.table.expressions.ApiExpressionUtils.unresolvedRef;
+import static
org.apache.flink.table.expressions.ApiExpressionUtils.valueLiteral;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class OverWindowExpressionResolverTest {
+
+ @Test
+ void resolvesFieldsInExpandedOverExpression() {
+ Expression expandedOver =
+ unresolvedCall(
+ BuiltInFunctionDefinitions.OVER,
+ Arrays.asList(
+ unresolvedCall(
+ BuiltInFunctionDefinitions.SUM,
unresolvedRef("amount")),
+ unresolvedRef("rowtime"),
+ valueLiteral(2L),
+ valueLiteral(OverWindowRange.CURRENT_ROW),
+ unresolvedRef("tenant")));
+
+ CallExpression resolved =
+ (CallExpression)
+
resolver(Collections.emptyList()).resolve(List.of(expandedOver)).get(0);
+ List<Expression> children = resolved.getChildren();
+
+ assertThat(children).hasSize(5);
+ assertThat(((CallExpression) children.get(0)).getChildren().get(0))
+ .isInstanceOf(FieldReferenceExpression.class);
+ assertField(children.get(1), "rowtime");
+ assertThat(children.get(2).asSummaryString()).isEqualTo("2");
+ assertThat(children.get(3).asSummaryString()).contains("CURRENT_ROW");
+ assertField(children.get(4), "tenant");
+ }
+
+ @Test
+ void appliesDefaultRangeFrameToExpandedAggregate() {
+ Expression expandedOver =
+ expandedOver(
+ BuiltInFunctionDefinitions.SUM,
+ valueLiteral(null, DataTypes.NULL()),
+ valueLiteral(null, DataTypes.NULL()));
+
+ CallExpression resolved =
+ (CallExpression)
+
resolver(Collections.emptyList()).resolve(List.of(expandedOver)).get(0);
+ List<Expression> children = resolved.getChildren();
+
+
assertThat(children.get(2).asSummaryString()).contains("UNBOUNDED_RANGE");
+
assertThat(children.get(3).asSummaryString()).contains("CURRENT_RANGE");
+ }
+
+ @Test
+ void preservesOmittedBoundsForExpandedLeadAndLag() {
+ assertOmittedBounds(BuiltInFunctionDefinitions.LAG);
+ assertOmittedBounds(BuiltInFunctionDefinitions.LEAD);
+ }
+
+ @Test
+ void rejectsExplicitBoundsForExpandedLeadAndLag() {
+ assertExplicitBoundsRejected(BuiltInFunctionDefinitions.LAG);
+ assertExplicitBoundsRejected(BuiltInFunctionDefinitions.LEAD);
+ }
+
+ @Test
+ void expandsLegacyNamedOverWindowAlias() {
+ OverWindow window =
+
Over.partitionBy($("tenant")).orderBy($("rowtime")).preceding(lit(2L)).as("w");
+ Expression overAlias =
+ unresolvedCall(
+ BuiltInFunctionDefinitions.OVER,
+ unresolvedCall(BuiltInFunctionDefinitions.SUM,
unresolvedRef("amount")),
+ unresolvedRef("w"));
+
+ CallExpression resolved =
+ (CallExpression)
resolver(List.of(window)).resolve(List.of(overAlias)).get(0);
+ List<Expression> children = resolved.getChildren();
+
+ assertThat(children).hasSize(5);
+ assertThat(((CallExpression) children.get(0)).getChildren().get(0))
+ .isInstanceOf(FieldReferenceExpression.class);
+ assertField(children.get(1), "rowtime");
+ assertThat(children.get(2).asSummaryString()).isEqualTo("2");
+ assertThat(children.get(3).asSummaryString()).contains("CURRENT_ROW");
+ assertField(children.get(4), "tenant");
+ }
+
+ @Test
+ void rejectsMalformedOverExpression() {
+ Expression malformedOver =
+ unresolvedCall(
+ BuiltInFunctionDefinitions.OVER,
+ Arrays.asList(
+ unresolvedCall(
+ BuiltInFunctionDefinitions.SUM,
unresolvedRef("amount")),
+ unresolvedRef("rowtime"),
+ valueLiteral(2L)));
+
+ assertThatThrownBy(() ->
resolver(Collections.emptyList()).resolve(List.of(malformedOver)))
+ .isInstanceOf(ValidationException.class);
+ }
+
+ private static Expression expandedOver(
+ FunctionDefinition aggregate, Expression preceding, Expression
following) {
+ return unresolvedCall(
+ BuiltInFunctionDefinitions.OVER,
+ Arrays.asList(
+ unresolvedCall(aggregate, unresolvedRef("amount")),
+ unresolvedRef("rowtime"),
+ preceding,
+ following));
+ }
+
+ private static void assertOmittedBounds(FunctionDefinition aggregate) {
+ CallExpression resolved =
+ (CallExpression)
+ resolver(Collections.emptyList())
+ .resolve(
+ List.of(
+ expandedOver(
+ aggregate,
+ valueLiteral(null,
DataTypes.NULL()),
+ valueLiteral(null,
DataTypes.NULL()))))
+ .get(0);
+
+ assertThat(resolved.getChildren().subList(2, 4))
+ .allSatisfy(
+ bound -> {
+
assertThat(bound).isInstanceOf(ValueLiteralExpression.class);
+ assertThat(((ValueLiteralExpression)
bound).isNull()).isTrue();
+ });
+ }
+
+ private static void assertExplicitBoundsRejected(FunctionDefinition
aggregate) {
+ Expression expandedOver =
+ expandedOver(
+ aggregate,
+ valueLiteral(OverWindowRange.UNBOUNDED_RANGE),
+ valueLiteral(OverWindowRange.CURRENT_RANGE));
+
+ assertThatThrownBy(() ->
resolver(Collections.emptyList()).resolve(List.of(expandedOver)))
+ .isInstanceOf(ValidationException.class)
+ .hasMessageContaining("LEAD/LAG functions do not support");
+ }
+
+ private static void assertField(Expression expression, String name) {
+ assertThat(expression).isInstanceOf(FieldReferenceExpression.class);
+ assertThat(((FieldReferenceExpression)
expression).getName()).isEqualTo(name);
+ }
+
+ private static ExpressionResolver resolver(List<OverWindow> windows) {
+ DataType rowtime =
+ new AtomicDataType(new LocalZonedTimestampType(false,
TimestampKind.ROWTIME, 3));
+ TableSchema schema =
+ TableSchema.builder()
+ .field("amount", DataTypes.BIGINT())
+ .field("rowtime", rowtime)
+ .field("tenant", DataTypes.STRING())
+ .build();
+ QueryOperation input =
+ new SourceQueryOperation(
+ ContextResolvedTable.anonymous(
+ new ResolvedCatalogTable(
+
CatalogTable.newBuilder().schema(schema.toSchema()).build(),
+ ResolvedSchema.physical(
+ schema.getFieldNames(),
+ schema.getFieldDataTypes()))));
+
+ return ExpressionResolver.resolverFor(
+ TableConfig.getDefault(),
+ Thread.currentThread().getContextClassLoader(),
+ name -> Optional.empty(),
+ new FunctionLookupMock(Collections.emptyMap()),
+ new DataTypeFactoryMock(),
+ (sqlExpression, inputRowType, outputType) -> {
+ throw new UnsupportedOperationException();
+ },
+ input)
+ .withOverWindows(windows)
+ .build();
+ }
+}
diff --git
a/flink-table/flink-table-api-java/src/test/java/org/apache/flink/table/operations/utils/OperationExpressionsUtilsTest.java
b/flink-table/flink-table-api-java/src/test/java/org/apache/flink/table/operations/utils/OperationExpressionsUtilsTest.java
new file mode 100644
index 00000000000..4ddeefdf9f3
--- /dev/null
+++
b/flink-table/flink-table-api-java/src/test/java/org/apache/flink/table/operations/utils/OperationExpressionsUtilsTest.java
@@ -0,0 +1,64 @@
+/*
+ * 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.utils;
+
+import org.apache.flink.table.api.OverWindowRange;
+import org.apache.flink.table.expressions.Expression;
+import org.apache.flink.table.expressions.UnresolvedCallExpression;
+import org.apache.flink.table.functions.BuiltInFunctionDefinitions;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.List;
+
+import static
org.apache.flink.table.expressions.ApiExpressionUtils.unresolvedCall;
+import static
org.apache.flink.table.expressions.ApiExpressionUtils.unresolvedRef;
+import static
org.apache.flink.table.expressions.ApiExpressionUtils.valueLiteral;
+import static org.assertj.core.api.Assertions.assertThat;
+
+class OperationExpressionsUtilsTest {
+
+ @Test
+ void keepsAggregatesInsideExpandedOverExpressionsInProjection() {
+ Expression overExpression =
+ unresolvedCall(
+ BuiltInFunctionDefinitions.OVER,
+ Arrays.asList(
+ unresolvedCall(
+ BuiltInFunctionDefinitions.SUM,
unresolvedRef("amount")),
+ unresolvedRef("event_time"),
+ valueLiteral(1L),
+ valueLiteral(OverWindowRange.CURRENT_ROW)));
+
+ OperationExpressionsUtils.CategorizedExpressions categorized =
+
OperationExpressionsUtils.extractAggregationsAndProperties(List.of(overExpression));
+ UnresolvedCallExpression projectedOver =
+ (UnresolvedCallExpression) categorized.getProjections().get(0);
+
+ assertThat(categorized.getAggregations()).isEmpty();
+ assertThat(projectedOver.getFunctionDefinition())
+ .isEqualTo(BuiltInFunctionDefinitions.OVER);
+
assertThat(projectedOver.getChildren().get(0)).isInstanceOf(UnresolvedCallExpression.class);
+ assertThat(
+ ((UnresolvedCallExpression)
projectedOver.getChildren().get(0))
+ .getFunctionDefinition())
+ .isEqualTo(BuiltInFunctionDefinitions.SUM);
+ }
+}