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 008e440edc9 [FLINK-40422][python] Add slicing APIs to DataFrame API
008e440edc9 is described below
commit 008e440edc9203daa4cf7b43ee3f5a691cd92a0c
Author: Milesian111 <[email protected]>
AuthorDate: Fri Aug 28 14:39:06 2026 +0800
[FLINK-40422][python] Add slicing APIs to DataFrame API
This closes #29033.
---
.../docs/reference/pyflink.dataframe/dataframe.rst | 7 +-
flink-python/pyflink/dataframe/convert.py | 3 +-
flink-python/pyflink/dataframe/dataframe.py | 92 ++++++++++++++-
flink-python/pyflink/dataframe/datatype.py | 4 +
.../pyflink/dataframe/tests/test_dataframe.py | 126 +++++++++++++++++++++
5 files changed, 228 insertions(+), 4 deletions(-)
diff --git a/flink-python/docs/reference/pyflink.dataframe/dataframe.rst
b/flink-python/docs/reference/pyflink.dataframe/dataframe.rst
index dc197a55e9a..4049d3dedd0 100644
--- a/flink-python/docs/reference/pyflink.dataframe/dataframe.rst
+++ b/flink-python/docs/reference/pyflink.dataframe/dataframe.rst
@@ -21,7 +21,9 @@ DataFrame
=========
A DataFrame provides a Pythonic interface for composing data transformations.
-Transformation methods return new DataFrames and support fluent chaining.
+Transformation methods return new DataFrames and support fluent chaining. They
build execution
+plans lazily without starting a Flink job; execution is triggered by an action
such as
+``DataFrame.collect`` or ``DataFrame.to_pandas``.
Example::
@@ -61,6 +63,9 @@ Transformations
DataFrame.drop_duplicates
DataFrame.distinct
DataFrame.unique
+ DataFrame.limit
+ DataFrame.offset
+ DataFrame.head
DataFrame.__getitem__
Aggregations
diff --git a/flink-python/pyflink/dataframe/convert.py
b/flink-python/pyflink/dataframe/convert.py
index a7265466dd1..84b31ba8f0d 100644
--- a/flink-python/pyflink/dataframe/convert.py
+++ b/flink-python/pyflink/dataframe/convert.py
@@ -38,6 +38,7 @@ if TYPE_CHECKING:
from pyflink.dataframe.context import get_or_create_table_environment
from pyflink.dataframe.dataframe import DataFrame
+from pyflink.dataframe.datatype import _BIGINT_MAX, _BIGINT_MIN
from pyflink.table import Schema, Table
from pyflink.table.types import (
_create_converter,
@@ -63,8 +64,6 @@ __all__ = [
]
_SCALAR_SEQUENCE_TYPES = (str, bytes, bytearray, memoryview)
-_BIGINT_MIN = -(1 << 63)
-_BIGINT_MAX = (1 << 63) - 1
class _WatermarkSpec(NamedTuple):
diff --git a/flink-python/pyflink/dataframe/dataframe.py
b/flink-python/pyflink/dataframe/dataframe.py
index 0e542d0ab29..55b5e1610b6 100644
--- a/flink-python/pyflink/dataframe/dataframe.py
+++ b/flink-python/pyflink/dataframe/dataframe.py
@@ -35,7 +35,7 @@ if TYPE_CHECKING:
from pyflink.table.table_schema import TableSchema
from pyflink.common import Row
-from pyflink.dataframe.datatype import DataType
+from pyflink.dataframe.datatype import _INT_MAX, DataType
from pyflink.table.expression import Expression
from pyflink.table.expressions import (
and_,
@@ -51,6 +51,15 @@ __all__ = ["DataFrame", "GroupedDataFrame", "col", "lit"]
T = TypeVar("T")
+def _validate_row_count(n: int) -> None:
+ if isinstance(n, bool) or not isinstance(n, int):
+ raise TypeError("n must be an integer")
+ if n < 0:
+ raise ValueError("n must be non-negative")
+ if n > _INT_MAX:
+ raise ValueError(f"n must be less than or equal to {_INT_MAX}")
+
+
@PublicEvolving()
def col(name: str) -> Expression:
"""
@@ -542,6 +551,87 @@ class DataFrame:
distinct = drop_duplicates
unique = drop_duplicates
+ # ======================== Slicing ========================
+
+ @PublicEvolving()
+ def limit(self, n: int) -> "DataFrame":
+ """
+ Keep at most the first ``n`` rows.
+
+ This method builds a new DataFrame plan without executing a Flink job.
Execution is
+ triggered by an action such as :meth:`collect` or :meth:`to_pandas`.
Without an explicit
+ ordering on the underlying table, the selected rows and their order
are unspecified.
+ Changes to the underlying table content may also change the result.
+
+ :param n: Maximum number of rows to keep.
+ :return: A new DataFrame containing at most ``n`` rows.
+ :raises TypeError: If ``n`` is not an integer.
+ :raises ValueError: If ``n`` is negative.
+
+ Example::
+
+ >>> import pyflink.dataframe as pf
+ >>> df = pf.from_records([{"id": 1}, {"id": 2}, {"id": 3}])
+ >>> first_two = df.limit(2)
+
+ .. versionadded:: 2.4.0
+ """
+ _validate_row_count(n)
+ return DataFrame(self._table.fetch(n))
+
+ @PublicEvolving()
+ def offset(self, n: int) -> "DataFrame":
+ """
+ Skip the first ``n`` rows.
+
+ This method builds a new DataFrame plan without executing a Flink job.
Execution is
+ triggered by an action such as :meth:`collect` or :meth:`to_pandas`.
Without an explicit
+ ordering on the underlying table, the skipped rows and their order are
unspecified.
+ Changes to the underlying table content may also change the result.
Combine this method
+ with :meth:`limit` for pagination.
+
+ :param n: Number of rows to skip.
+ :return: A new DataFrame without the first ``n`` rows.
+ :raises TypeError: If ``n`` is not an integer.
+ :raises ValueError: If ``n`` is negative.
+
+ Example::
+
+ >>> import pyflink.dataframe as pf
+ >>> df = pf.from_records([{"id": 1}, {"id": 2}, {"id": 3}])
+ >>> page = df.offset(1).limit(2)
+
+ .. versionadded:: 2.4.0
+ """
+ _validate_row_count(n)
+ return DataFrame(self._table.offset(n))
+
+ @PublicEvolving()
+ def head(self, n: int) -> "DataFrame":
+ """
+ Keep at most the first ``n`` rows.
+
+ This method builds a new DataFrame plan without executing a Flink job
and delegates to
+ :meth:`limit`. Execution is triggered by an action such as
:meth:`collect` or
+ :meth:`to_pandas`. Without an explicit ordering on the underlying
table, the selected rows
+ and their order are unspecified. Changes to the underlying table
content may also change
+ the result.
+
+ :param n: Maximum number of rows to keep.
+ :return: A new DataFrame containing at most ``n`` rows.
+ :raises TypeError: If ``n`` is not an integer.
+ :raises ValueError: If ``n`` is negative.
+
+ Example::
+
+ >>> import pyflink.dataframe as pf
+ >>> df = pf.from_records([{"id": 1}, {"id": 2}, {"id": 3}])
+ >>> first_two = df.head(2)
+
+ .. versionadded:: 2.4.0
+ """
+ return self.limit(n)
+
# ======================== Aggregation ========================
@PublicEvolving()
diff --git a/flink-python/pyflink/dataframe/datatype.py
b/flink-python/pyflink/dataframe/datatype.py
index 84676885a6f..d604f2c7fda 100644
--- a/flink-python/pyflink/dataframe/datatype.py
+++ b/flink-python/pyflink/dataframe/datatype.py
@@ -27,6 +27,10 @@ from pyflink.util.api_stability_decorators import
PublicEvolving
__all__ = ["DataType"]
+_INT_MIN = -(1 << 31)
+_INT_MAX = (1 << 31) - 1
+_BIGINT_MIN = -(1 << 63)
+_BIGINT_MAX = (1 << 63) - 1
_PEP_604_UNION_TYPE = getattr(types, "UnionType", None)
_BASIC_TYPE_HINT_FACTORIES: Dict[Any, Callable[[], TableDataType]] = {
diff --git a/flink-python/pyflink/dataframe/tests/test_dataframe.py
b/flink-python/pyflink/dataframe/tests/test_dataframe.py
index dc34f28229e..36d5c8efeda 100644
--- a/flink-python/pyflink/dataframe/tests/test_dataframe.py
+++ b/flink-python/pyflink/dataframe/tests/test_dataframe.py
@@ -23,6 +23,7 @@ import unittest
from py4j.protocol import Py4JJavaError
from datetime import date, datetime, time, timedelta, timezone
from typing import NamedTuple
+from unittest.mock import Mock, patch
import pandas as pd
import pyarrow as pa
@@ -151,6 +152,97 @@ class DataFrameCompositionTests(unittest.TestCase):
self.assertIs(pf.DataFrame.rename, pf.DataFrame.rename_columns)
+class DataFrameSlicingTests(unittest.TestCase):
+ def setUp(self):
+ self.table = Mock()
+ self.dataframe = pf.DataFrame(self.table)
+
+ def test_limit_is_lazy_and_returns_new_dataframe(self):
+ limited_table = Mock()
+ self.table.fetch.return_value = limited_table
+
+ result = self.dataframe.limit(3)
+
+ self.assertIsInstance(result, pf.DataFrame)
+ self.assertIs(result.to_table(), limited_table)
+ self.assertIs(self.dataframe.to_table(), self.table)
+ self.table.fetch.assert_called_once_with(3)
+ self.table.execute.assert_not_called()
+
+ def test_offset_is_lazy_and_returns_new_dataframe(self):
+ offset_table = Mock()
+ self.table.offset.return_value = offset_table
+
+ result = self.dataframe.offset(2)
+
+ self.assertIsInstance(result, pf.DataFrame)
+ self.assertIs(result.to_table(), offset_table)
+ self.assertIs(self.dataframe.to_table(), self.table)
+ self.table.offset.assert_called_once_with(2)
+ self.table.execute.assert_not_called()
+
+ def test_offset_and_limit_compose(self):
+ offset_table = Mock()
+ limited_table = Mock()
+ self.table.offset.return_value = offset_table
+ offset_table.fetch.return_value = limited_table
+
+ result = self.dataframe.offset(2).limit(3)
+
+ self.assertIs(result.to_table(), limited_table)
+ self.table.offset.assert_called_once_with(2)
+ offset_table.fetch.assert_called_once_with(3)
+ self.table.execute.assert_not_called()
+
+ def test_head_delegates_to_limit(self):
+ expected = pf.DataFrame(Mock())
+
+ with patch.object(pf.DataFrame, "limit", autospec=True) as limit:
+ limit.return_value = expected
+
+ result = self.dataframe.head(3)
+
+ self.assertIs(result, expected)
+ limit.assert_called_once_with(self.dataframe, 3)
+ self.table.execute.assert_not_called()
+
+ def test_zero_is_supported(self):
+ limited_table = Mock()
+ offset_table = Mock()
+ self.table.fetch.return_value = limited_table
+ self.table.offset.return_value = offset_table
+
+ self.assertIs(self.dataframe.limit(0).to_table(), limited_table)
+ self.assertIs(self.dataframe.head(0).to_table(), limited_table)
+ self.assertIs(self.dataframe.offset(0).to_table(), offset_table)
+
+ self.assertEqual(self.table.fetch.call_count, 2)
+ self.table.fetch.assert_called_with(0)
+ self.table.offset.assert_called_once_with(0)
+ self.table.execute.assert_not_called()
+
+ def test_rejects_negative_values(self):
+ for method_name in ("limit", "offset", "head"):
+ with self.subTest(method=method_name):
+ with self.assertRaisesRegex(ValueError, "n must be
non-negative"):
+ getattr(self.dataframe, method_name)(-1)
+
+ self.table.fetch.assert_not_called()
+ self.table.offset.assert_not_called()
+ self.table.execute.assert_not_called()
+
+ def test_rejects_unsupported_types(self):
+ for method_name in ("limit", "offset", "head"):
+ for value in (True, 1.5, "1", None):
+ with self.subTest(method=method_name, value=value):
+ with self.assertRaisesRegex(TypeError, "n must be an
integer"):
+ getattr(self.dataframe, method_name)(value)
+
+ self.table.fetch.assert_not_called()
+ self.table.offset.assert_not_called()
+ self.table.execute.assert_not_called()
+
+
class DataFrameCreationTests(PyFlinkDataFrameUTTestCase):
def test_from_dict_uses_insertion_order_without_schema(self):
dataframe = pf.from_dict({"name": ["Alice"], "id": [1]})
@@ -1804,6 +1896,40 @@ class DataFrameBatchITTests(PyFlinkITTestCase):
self.addCleanup(pf.set_table_environment, previous_environment)
self.t_env =
TableEnvironment.create(EnvironmentSettings.in_batch_mode())
+ def _ordered_dataframe(self):
+ table = self.t_env.sql_query(
+ "SELECT * FROM (VALUES (3, 'C'), (1, 'A'), (4, 'D'), (2, 'B')) "
+ "AS T(id, name)"
+ )
+ return pf.from_table(table.order_by(table.id))
+
+ def test_limit_returns_first_rows(self):
+ self.assertEqual(
+ self._ordered_dataframe().limit(2).collect(),
+ [Row(1, "A"), Row(2, "B")],
+ )
+
+ def test_offset_and_limit_compose_for_pagination(self):
+ self.assertEqual(
+ self._ordered_dataframe().offset(1).limit(2).collect(),
+ [Row(2, "B"), Row(3, "C")],
+ )
+
+ def test_head_and_limit_are_equivalent(self):
+ dataframe = self._ordered_dataframe()
+
+ self.assertEqual(dataframe.head(3).collect(),
dataframe.limit(3).collect())
+
+ def test_zero_slicing(self):
+ dataframe = self._ordered_dataframe()
+
+ self.assertEqual(dataframe.limit(0).collect(), [])
+ self.assertEqual(dataframe.head(0).collect(), [])
+ self.assertEqual(
+ dataframe.offset(0).collect(),
+ [Row(1, "A"), Row(2, "B"), Row(3, "C"), Row(4, "D")],
+ )
+
def test_from_records_with_batch_table_environment(self):
pf.set_table_environment(self.t_env)