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 f83a73c19a4 [FLINK-40421][python] Add sorting APIs to DataFrame API
f83a73c19a4 is described below
commit f83a73c19a41a066360cb353877b1ca145a18929
Author: Milesian111 <[email protected]>
AuthorDate: Thu Sep 3 17:09:23 2026 +0800
[FLINK-40421][python] Add sorting APIs to DataFrame API
This closes #29078.
Generated-by: Codex GPT-5
---
.../docs/reference/pyflink.dataframe/dataframe.rst | 1 +
flink-python/pyflink/dataframe/dataframe.py | 130 ++++++++++++++++++-
.../pyflink/dataframe/tests/test_dataframe.py | 144 ++++++++++++++++++++-
3 files changed, 270 insertions(+), 5 deletions(-)
diff --git a/flink-python/docs/reference/pyflink.dataframe/dataframe.rst
b/flink-python/docs/reference/pyflink.dataframe/dataframe.rst
index d43dd2a2be4..ca7cecc499f 100644
--- a/flink-python/docs/reference/pyflink.dataframe/dataframe.rst
+++ b/flink-python/docs/reference/pyflink.dataframe/dataframe.rst
@@ -63,6 +63,7 @@ Transformations
DataFrame.drop_duplicates
DataFrame.distinct
DataFrame.unique
+ DataFrame.sort
DataFrame.top_n
DataFrame.limit
DataFrame.offset
diff --git a/flink-python/pyflink/dataframe/dataframe.py
b/flink-python/pyflink/dataframe/dataframe.py
index 38f1971abcf..ed2cf3dab1c 100644
--- a/flink-python/pyflink/dataframe/dataframe.py
+++ b/flink-python/pyflink/dataframe/dataframe.py
@@ -36,6 +36,7 @@ if TYPE_CHECKING:
from pyflink.common import Row
from pyflink.dataframe.datatype import _INT_MAX, DataType
+from pyflink.java_gateway import get_gateway
from pyflink.table.expression import Expression
from pyflink.table.expressions import (
and_,
@@ -620,6 +621,80 @@ class DataFrame:
distinct = drop_duplicates
unique = drop_duplicates
+ # ======================== Filtering & Ordering ========================
+
+ @PublicEvolving()
+ def sort(
+ self,
+ by: Union[str, Expression, List[Union[str, Expression]]],
+ *,
+ descending: Union[bool, List[bool]] = False,
+ nulls_first: Union[bool, List[bool]] = None,
+ ) -> "DataFrame":
+ """
+ Sort rows globally by one or more columns or expressions.
+
+ This method builds a new DataFrame plan without executing a Flink job.
The ``by``
+ expressions must not already specify ``asc`` or ``desc``; use
``descending`` to control
+ their direction. When ``nulls_first`` is omitted, the Table API
default is used: NULLs
+ are ordered last for ascending keys and first for descending keys.
+
+ The result is globally sorted across all parallel partitions. For
unbounded tables, the
+ first sort key must be an ascending time attribute unless the sort is
followed by
+ :meth:`limit`.
+
+ :param by: Column name or expression, or a list of them, used as sort
keys.
+ :param descending: Whether to sort in descending order, either for all
keys or once per
+ key.
+ :param nulls_first: Whether to place NULLs first, either for all keys
or once per key. When
+ omitted, the Table API default applies.
+ :return: A new sorted DataFrame.
+ :raises TypeError: If ``by``, ``descending`` or ``nulls_first`` has an
unsupported type.
+ :raises ValueError: If ``by`` is empty, option lengths do not match, a
column does not
+ exist, or an expression already specifies ``asc`` or ``desc``.
+
+ Example::
+
+ >>> import pyflink.dataframe as pf
+ >>> df = pf.from_records(
+ ... [(2, "b"), (1, "a")], schema=["id", "name"]
+ ... )
+ >>> ascending = df.sort("id")
+ >>> mixed = df.sort(["id", "name"], descending=[False, True])
+
+ .. versionadded:: 2.4.0
+ """
+ order_keys = _normalize_order_by(by, "by")
+ if order_keys is None:
+ raise TypeError("by must be a string, an expression, or a list or
tuple of them")
+ columns = self._table.get_resolved_schema().get_column_names()
+ for key in order_keys:
+ if isinstance(key, str) and key not in columns:
+ raise ValueError(
+ "by column '%s' does not exist, available columns: %s" %
(key, columns)
+ )
+ if isinstance(key, Expression) and
_contains_ordering_expression(key):
+ raise ValueError(
+ "sort() expressions must not specify asc or desc; use
descending instead"
+ )
+
+ descending_values = _normalize_descending(descending, len(order_keys))
+ nulls_values: List[Optional[bool]] = (
+ [None] * len(order_keys)
+ if nulls_first is None
+ else _normalize_nulls_first(nulls_first, len(order_keys))
+ )
+ if any(value is not None for value in nulls_values):
+ return DataFrame(
+ _build_sort_sql(self._table, order_keys, descending_values,
nulls_values)
+ )
+
+ order_expressions = []
+ for key, is_descending in zip(order_keys, descending_values):
+ expression = table_col(key) if isinstance(key, str) else key
+ order_expressions.append(expression.desc if is_descending else
expression.asc)
+ return DataFrame(self._table.order_by(*order_expressions))
+
# ======================== Slicing ========================
@PublicEvolving()
@@ -1090,6 +1165,7 @@ def _normalize_subset(subset: Union[str, List[str],
None]) -> Optional[List[str]
def _normalize_order_by(
order_by: Union[str, Expression, List[Union[str, Expression]], None],
+ parameter_name: str = "order_by",
) -> Optional[List[Union[str, Expression]]]:
if order_by is None:
return None
@@ -1101,15 +1177,30 @@ def _normalize_order_by(
keys.append(value)
else:
raise TypeError(
- "order_by must be a string, an expression, or a list or tuple
of them"
+ "%s must be a string, an expression, or a list or tuple of
them" % parameter_name
)
if not keys:
- raise ValueError("order_by must not be empty")
+ raise ValueError("%s must not be empty" % parameter_name)
return keys
+def _contains_ordering_expression(expression: Expression) -> bool:
+ gateway = get_gateway()
+ api_expression_utils =
gateway.jvm.org.apache.flink.table.expressions.ApiExpressionUtils
+ built_in_functions =
gateway.jvm.org.apache.flink.table.functions.BuiltInFunctionDefinitions
+
+ def contains_ordering(j_expression) -> bool:
+ if api_expression_utils.isFunction(
+ j_expression, built_in_functions.ORDER_ASC
+ ) or api_expression_utils.isFunction(j_expression,
built_in_functions.ORDER_DESC):
+ return True
+ return any(contains_ordering(child) for child in
j_expression.getChildren())
+
+ return contains_ordering(expression._j_expr.toExpr())
+
+
def _normalize_nulls_first(
nulls_first: Union[bool, List[bool], None], order_len: int
) -> Optional[List[bool]]:
@@ -1127,7 +1218,7 @@ def _normalize_nulls_first(
raise TypeError("nulls_first must be a boolean or a list of booleans")
if len(values) != order_len:
- raise ValueError("nulls_first must have the same length as order_by")
+ raise ValueError("nulls_first must have the same length as the sort
keys")
return values
@@ -1137,13 +1228,44 @@ def _normalize_descending(descending, order_len):
return [descending] * order_len
if isinstance(descending, (list, tuple)):
if len(descending) != order_len:
- raise ValueError("descending must have the same length as
order_by")
+ raise ValueError("descending must have the same length as the sort
keys")
if not all(isinstance(v, bool) for v in descending):
raise TypeError("descending must be a boolean or a list of
booleans")
return list(descending)
raise TypeError("descending must be a boolean or a list of booleans")
+def _build_sort_sql(table, order_keys, descending_flags, nulls) -> Table:
+ columns = table.get_resolved_schema().get_column_names()
+ taken = set(columns)
+ order_terms = []
+ for index, key in enumerate(order_keys):
+ direction = "DESC" if descending_flags[index] else "ASC"
+ if isinstance(key, str):
+ if key not in columns:
+ raise ValueError(
+ "by column '%s' does not exist, available columns: %s" %
(key, columns)
+ )
+ expression_sql = _quote_identifier(key)
+ else:
+ name = _unique_name("__pf_order_%d" % index, taken)
+ taken.add(name)
+ table = table.add_columns(key.alias(name))
+ expression_sql = _quote_identifier(name)
+ term = "%s %s" % (expression_sql, direction)
+ if nulls[index] is not None:
+ term += " NULLS FIRST" if nulls[index] else " NULLS LAST"
+ order_terms.append(term)
+
+ select_list = ", ".join(_quote_identifier(name) for name in columns)
+ query = "SELECT %s FROM %s ORDER BY %s" % (
+ select_list,
+ _quote_identifier(str(table)),
+ ", ".join(order_terms),
+ )
+ return table._t_env.sql_query(query)
+
+
def _build_rank_sql(table, partition_keys, order_keys, descending_flags,
nulls, n) -> Table:
columns = table.get_resolved_schema().get_column_names()
for name in partition_keys:
diff --git a/flink-python/pyflink/dataframe/tests/test_dataframe.py
b/flink-python/pyflink/dataframe/tests/test_dataframe.py
index fd8f0dfe048..d51d82c857d 100644
--- a/flink-python/pyflink/dataframe/tests/test_dataframe.py
+++ b/flink-python/pyflink/dataframe/tests/test_dataframe.py
@@ -243,6 +243,104 @@ class DataFrameSlicingTests(unittest.TestCase):
self.table.execute.assert_not_called()
+class DataFrameSortingTests(PyFlinkDataFrameUTTestCase):
+ def setUp(self):
+ super().setUp()
+ self.dataframe = pf.from_records(
+ [(2, "b"), (1, "a")],
+ schema=["id", "name"],
+ )
+
+ def test_sort_is_lazy_and_returns_new_dataframe(self):
+ result = self.dataframe.sort("id")
+
+ self.assertIsInstance(result, pf.DataFrame)
+ self.assertIsNot(result, self.dataframe)
+ self.assertIsNot(result.to_table(), self.dataframe.to_table())
+ self.assertEqual(
+
result.to_table()._j_table.getQueryOperation().getOrder().toString(),
+ "[asc(id)]",
+ )
+
+ def test_sort_supports_descending_and_multiple_keys(self):
+ result = self.dataframe.sort(["id", "name"], descending=[True, False])
+ all_descending = self.dataframe.sort(["id", "name"], descending=True)
+
+ self.assertEqual(
+
result.to_table()._j_table.getQueryOperation().getOrder().toString(),
+ "[desc(id), asc(name)]",
+ )
+ self.assertEqual(
+
all_descending.to_table()._j_table.getQueryOperation().getOrder().toString(),
+ "[desc(id), desc(name)]",
+ )
+
+ def test_sort_supports_expression_keys(self):
+ result = self.dataframe.sort(pf.col("id") + 1, descending=True)
+
+ self.assertEqual(
+
result.to_table()._j_table.getQueryOperation().getOrder().toString(),
+ "[desc(plus(id, 1))]",
+ )
+
+ def test_sort_with_null_ordering_preserves_temporal_sort(self):
+ self.t_env.execute_sql(
+ """
+ CREATE TEMPORARY TABLE sort_source (
+ id INT,
+ ts TIMESTAMP(3),
+ WATERMARK FOR ts AS ts - INTERVAL '5' SECOND
+ ) WITH ('connector' = 'datagen', 'number-of-rows' = '1')
+ """
+ )
+ dataframe = pf.from_table(self.t_env.from_path("sort_source"))
+
+ plan = dataframe.sort("ts", nulls_first=True).to_table().explain()
+
+ self.assertIn("LogicalSort(sort0=[$1], dir0=[ASC-nulls-first])", plan)
+ self.assertIn("TemporalSort(orderBy=[ts ASC])", plan)
+
+ def test_sort_rejects_ordered_expressions(self):
+ for expression in (
+ pf.col("id").asc,
+ pf.col("id").desc,
+ pf.col("id").asc + 1,
+ ):
+ with self.subTest(expression=str(expression)):
+ with self.assertRaisesRegex(
+ ValueError,
+ "expressions must not specify asc or desc",
+ ):
+ self.dataframe.sort(expression)
+
+ def test_sort_rejects_invalid_keys_and_options(self):
+ with self.assertRaisesRegex(ValueError, "by must not be empty"):
+ self.dataframe.sort([])
+ with self.assertRaisesRegex(ValueError, "by column 'missing' does not
exist"):
+ self.dataframe.sort("missing")
+ with self.assertRaisesRegex(TypeError, "by must be a string, an
expression"):
+ self.dataframe.sort(1)
+ with self.assertRaisesRegex(TypeError, "by must be a string, an
expression"):
+ self.dataframe.sort(None)
+
+ with self.assertRaisesRegex(TypeError, "descending must be a boolean"):
+ self.dataframe.sort("id", descending=1)
+ with self.assertRaisesRegex(TypeError, "descending must be a boolean"):
+ self.dataframe.sort(["id", "name"], descending=[True, 1])
+ with self.assertRaisesRegex(
+ ValueError, "descending must have the same length as the sort keys"
+ ):
+ self.dataframe.sort(["id", "name"], descending=[True])
+ with self.assertRaisesRegex(TypeError, "nulls_first must be a
boolean"):
+ self.dataframe.sort("id", nulls_first=1)
+ with self.assertRaisesRegex(TypeError, "nulls_first must be a
boolean"):
+ self.dataframe.sort(["id", "name"], nulls_first=[True, 1])
+ with self.assertRaisesRegex(
+ ValueError, "nulls_first must have the same length as the sort
keys"
+ ):
+ self.dataframe.sort(["id", "name"], nulls_first=[True])
+
+
class DataFrameCreationTests(PyFlinkDataFrameUTTestCase):
def test_from_dict_uses_insertion_order_without_schema(self):
dataframe = pf.from_dict({"name": ["Alice"], "id": [1]})
@@ -1408,7 +1506,7 @@ class
DataFrameDropDuplicatesTests(PyFlinkDataFrameUTTestCase):
"id", order_by="score", nulls_first=[True, False]
)
self.assertEqual(
- str(error.exception), "nulls_first must have the same length as
order_by"
+ str(error.exception), "nulls_first must have the same length as
the sort keys"
)
def test_rejects_nulls_first_wrong_type(self):
@@ -1976,6 +2074,50 @@ class DataFrameBatchITTests(PyFlinkITTestCase):
)
return pf.from_table(table.order_by(table.id))
+ def _unsorted_dataframe(self):
+ table = self.t_env.sql_query(
+ "SELECT * FROM (VALUES (3, 'C'), (1, 'A'), (2, 'B')) AS T(id,
name)"
+ )
+ return pf.from_table(table)
+
+ def _nullable_dataframe(self):
+ table = self.t_env.sql_query(
+ "SELECT * FROM (VALUES (CAST(NULL AS INT), 'NULL'), (2, 'B'), (1,
'A')) "
+ "AS T(id, name)"
+ )
+ return pf.from_table(table)
+
+ def test_sort_returns_rows_in_ascending_order(self):
+ self.assertEqual(
+ self._unsorted_dataframe().sort("id").collect(),
+ [Row(1, "A"), Row(2, "B"), Row(3, "C")],
+ )
+
+ def test_sort_supports_per_key_descending_order(self):
+ dataframe = pf.from_table(
+ self.t_env.sql_query(
+ "SELECT * FROM (VALUES (1, 10, 'A'), (1, 20, 'B'), (2, 5,
'C')) "
+ "AS T(group_id, score, name)"
+ )
+ )
+
+ self.assertEqual(
+ dataframe.sort(["group_id", "score"], descending=[False,
True]).collect(),
+ [Row(1, 20, "B"), Row(1, 10, "A"), Row(2, 5, "C")],
+ )
+
+ def test_sort_supports_explicit_null_ordering(self):
+ dataframe = self._nullable_dataframe()
+
+ self.assertEqual(
+ dataframe.sort("id", descending=True, nulls_first=False).collect(),
+ [Row(2, "B"), Row(1, "A"), Row(None, "NULL")],
+ )
+ self.assertEqual(
+ dataframe.sort(pf.col("id") + 1, nulls_first=True).collect(),
+ [Row(None, "NULL"), Row(1, "A"), Row(2, "B")],
+ )
+
def test_limit_returns_first_rows(self):
self.assertEqual(
self._ordered_dataframe().limit(2).collect(),