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 52659086b59 [FLINK-40194][python] Add drop_duplicates to the DataFrame 
API (#29022)
52659086b59 is described below

commit 52659086b597bc23705d80c237d3bc0938410e76
Author: Timo Theusner <[email protected]>
AuthorDate: Thu Aug 27 14:11:46 2026 +0200

    [FLINK-40194][python] Add drop_duplicates to the DataFrame API (#29022)
---
 .../docs/reference/pyflink.dataframe/dataframe.rst |   3 +
 flink-python/pyflink/dataframe/dataframe.py        | 204 ++++++++++
 .../pyflink/dataframe/tests/test_dataframe.py      | 431 ++++++++++++++++++++-
 flink-python/pyflink/testing/test_case_utils.py    |  22 +-
 4 files changed, 657 insertions(+), 3 deletions(-)

diff --git a/flink-python/docs/reference/pyflink.dataframe/dataframe.rst 
b/flink-python/docs/reference/pyflink.dataframe/dataframe.rst
index 4c78cb5d857..dc197a55e9a 100644
--- a/flink-python/docs/reference/pyflink.dataframe/dataframe.rst
+++ b/flink-python/docs/reference/pyflink.dataframe/dataframe.rst
@@ -58,6 +58,9 @@ Transformations
     DataFrame.rename
     DataFrame.filter
     DataFrame.where
+    DataFrame.drop_duplicates
+    DataFrame.distinct
+    DataFrame.unique
     DataFrame.__getitem__
 
 Aggregations
diff --git a/flink-python/pyflink/dataframe/dataframe.py 
b/flink-python/pyflink/dataframe/dataframe.py
index ac4c12d24d5..0e542d0ab29 100644
--- a/flink-python/pyflink/dataframe/dataframe.py
+++ b/flink-python/pyflink/dataframe/dataframe.py
@@ -23,6 +23,7 @@ from typing import (
     Dict,
     List,
     Optional,
+    Set,
     Tuple,
     TypeVar,
     Union,
@@ -464,6 +465,83 @@ class DataFrame:
 
         return DataFrame(self._table.select(*expressions))
 
+    @PublicEvolving()
+    def drop_duplicates(
+        self,
+        subset: Union[str, List[str]] = None,
+        *,
+        keep: str = "first",
+        order_by: Union[str, Expression, List[Union[str, Expression]]] = None,
+        nulls_first: Union[bool, List[bool]] = None,
+    ) -> "DataFrame":
+        """
+        Remove duplicate rows.
+
+        When ``subset`` is omitted, fully identical rows are dropped, 
equivalent to a whole-row
+        ``DISTINCT``. When ``subset`` is given, rows are deduplicated by those 
key columns, keeping
+        one row per key; ``order_by`` together with ``keep`` decides which row 
survives. When
+        ``order_by`` is omitted, processing time is used, so ``keep`` keeps 
the first or last row to
+        arrive.
+
+        :param subset: Column name or list of column names that define a 
duplicate. When omitted,
+            all columns are considered.
+        :param keep: ``"first"`` keeps the earliest row, ``"last"`` the 
latest, in ``order_by``
+            order. Ignored when ``subset`` is omitted.
+        :param order_by: Column name or expression (or a list of them) 
defining the order in which
+            ``keep`` selects the surviving row. When omitted, processing time 
is used.
+        :param nulls_first: Where NULLs rank in ``order_by``: a single boolean 
applied to every key,
+            or a list with one boolean per key. When omitted, the engine 
default applies. Requires
+            ``order_by``.
+        :return: A new DataFrame with duplicate rows removed.
+        :raises ValueError: If ``keep`` is not ``"first"`` or ``"last"``, if 
``order_by`` or
+            ``nulls_first`` is combined with an omitted ``subset``, if 
``nulls_first`` is given
+            without ``order_by`` or with a mismatched length, or if a named 
column does not exist.
+        :raises TypeError: If ``subset``, ``order_by`` or ``nulls_first`` has 
an unsupported type.
+
+        Example::
+
+            >>> import pyflink.dataframe as pf
+            >>> df = pf.from_records([{"id": 1, "ts": 1}, {"id": 1, "ts": 2}])
+            >>> unique_rows = df.drop_duplicates()
+            >>> latest_per_id = df.drop_duplicates("id", order_by="ts", 
keep="last")
+
+        .. versionadded:: 2.4.0
+        """
+        if keep not in ("first", "last"):
+            raise ValueError('keep must be "first" or "last"')
+
+        subset_keys = _normalize_subset(subset)
+        order_keys = _normalize_order_by(order_by)
+
+        if nulls_first is not None and order_keys is None:
+            raise ValueError("nulls_first requires order_by")
+        nulls = _normalize_nulls_first(
+            nulls_first, len(order_keys) if order_keys else 0
+        )
+
+        if subset_keys is None:
+            if order_keys is not None:
+                raise ValueError(
+                    "order_by requires subset; whole-row duplicates cannot be 
ordered"
+                )
+            return DataFrame(self._table.distinct())
+
+        columns = self._table.get_resolved_schema().get_column_names()
+        for name in subset_keys:
+            if name not in columns:
+                raise ValueError(
+                    "subset column '%s' does not exist, available columns: %s" 
% (name, columns)
+                )
+
+        return DataFrame(
+            _build_deduplication_query(
+                self._table, columns, subset_keys, order_keys, keep, nulls
+            )
+        )
+
+    distinct = drop_duplicates
+    unique = drop_duplicates
+
     # ======================== Aggregation ========================
 
     @PublicEvolving()
@@ -836,6 +914,132 @@ class GroupedDataFrame:
 # ======================== Internal Helpers ========================
 
 
+def _normalize_subset(subset: Union[str, List[str], None]) -> 
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")
+        for name in subset:
+            if not isinstance(name, str):
+                raise TypeError("subset must be a string or a list of strings")
+        return list(subset)
+    raise TypeError("subset must be a string or a list of strings")
+
+
+def _normalize_order_by(
+    order_by: Union[str, Expression, List[Union[str, Expression]], None],
+) -> Optional[List[Union[str, Expression]]]:
+    if order_by is None:
+        return None
+
+    values = order_by if isinstance(order_by, (list, tuple)) else [order_by]
+    keys: List[Union[str, Expression]] = []
+    for value in values:
+        if isinstance(value, (str, Expression)):
+            keys.append(value)
+        else:
+            raise TypeError(
+                "order_by must be a string, an expression, or a list or tuple 
of them"
+            )
+
+    if not keys:
+        raise ValueError("order_by must not be empty")
+
+    return keys
+
+
+def _normalize_nulls_first(
+    nulls_first: Union[bool, List[bool], None], order_len: int
+) -> Optional[List[bool]]:
+    if nulls_first is None:
+        return None
+
+    if isinstance(nulls_first, bool):
+        values = [nulls_first] * order_len
+    elif isinstance(nulls_first, (list, tuple)):
+        for value in nulls_first:
+            if not isinstance(value, bool):
+                raise TypeError("nulls_first must be a boolean or a list of 
booleans")
+        values = list(nulls_first)
+    else:
+        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")
+
+    return values
+
+
+def _build_deduplication_query(
+    table: Table,
+    columns: List[str],
+    subset_keys: List[str],
+    order_keys: Optional[List[Union[str, Expression]]],
+    keep: str,
+    nulls: Optional[List[bool]],
+) -> Table:
+    direction = "DESC" if keep == "last" else "ASC"
+    taken = set(columns)
+
+    if order_keys is None:
+        # Arrival order (processing time); keep decides its direction.
+        order_terms = ["PROCTIME() " + direction]
+    else:
+        order_terms = []
+        for index, key in enumerate(order_keys):
+            if isinstance(key, str):
+                if key not in columns:
+                    raise ValueError(
+                        "order_by column '%s' does not exist, available 
columns: %s"
+                        % (key, columns)
+                    )
+                name = key
+            else:
+                # An Expression cannot be rendered to SQL text, so materialize 
it as a helper
+                # column and reference it by name.
+                name = _unique_name("__pf_order_%d" % index, taken)
+                taken.add(name)
+                table = table.add_columns(key.alias(name))
+            term = _quote_identifier(name) + " " + direction
+            if nulls is not None:
+                term += " NULLS FIRST" if nulls[index] else " NULLS LAST"
+            order_terms.append(term)
+
+    rank_column = _quote_identifier(_unique_name("__pf_row_number", taken))
+    source = _quote_identifier(str(table))
+    select_list = ", ".join(_quote_identifier(name) for name in columns)
+    partition_by = ", ".join(_quote_identifier(name) for name in subset_keys)
+    query = (
+        "SELECT %s FROM (\n"
+        "  SELECT *, ROW_NUMBER() OVER (PARTITION BY %s ORDER BY %s) AS %s\n"
+        "  FROM %s\n"
+        ") WHERE %s = 1"
+        % (
+            select_list,
+            partition_by,
+            ", ".join(order_terms),
+            rank_column,
+            source,
+            rank_column,
+        )
+    )
+    return table._t_env.sql_query(query)
+
+
+def _unique_name(base: str, taken: Set[str]) -> str:
+    name = base
+    while name in taken:
+        name += "_"
+    return name
+
+
+def _quote_identifier(name: str) -> str:
+    return "`" + name.replace("`", "``") + "`"
+
+
 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 0531b0fc6e9..dc34f28229e 100644
--- a/flink-python/pyflink/dataframe/tests/test_dataframe.py
+++ b/flink-python/pyflink/dataframe/tests/test_dataframe.py
@@ -16,17 +16,18 @@
 # limitations under the License.
 
################################################################################
 
+import inspect
 import array
 import decimal
 import unittest
+from py4j.protocol import Py4JJavaError
 from datetime import date, datetime, time, timedelta, timezone
 from typing import NamedTuple
 
 import pandas as pd
 import pyarrow as pa
 import pyflink.dataframe as pf
-from py4j.protocol import Py4JJavaError
-from pyflink.common import Row
+from pyflink.common import Row, RowKind
 from pyflink.table import (
     DataTypes as TableDataTypes,
     EnvironmentSettings,
@@ -1149,6 +1150,432 @@ class 
DataFrameAggregationTests(PyFlinkDataFrameUTTestCase):
             )
 
 
+class DataFrameDropDuplicatesTests(PyFlinkDataFrameUTTestCase):
+    def setUp(self):
+        super().setUp()
+        self.dataframe = pf.from_records(
+            [
+                {"id": 1, "name": "a", "score": 10},
+                {"id": 1, "name": "b", "score": 20},
+                {"id": 2, "name": "c", "score": 30},
+            ]
+        )
+
+    def test_sql_proctime_default_keep_first(self):
+        self.assert_dataframe_sql(
+            self.dataframe,
+            "SELECT `id`, `name`, `score` FROM (\n"
+            "  SELECT *, ROW_NUMBER() OVER (PARTITION BY `id` ORDER BY 
PROCTIME() ASC)"
+            " AS `__pf_row_number`\n"
+            "  FROM `SRC`\n"
+            ") WHERE `__pf_row_number` = 1",
+            lambda: self.dataframe.drop_duplicates(subset="id"),
+        )
+
+    def test_sql_proctime_default_keep_last(self):
+        self.assert_dataframe_sql(
+            self.dataframe,
+            "SELECT `id`, `name`, `score` FROM (\n"
+            "  SELECT *, ROW_NUMBER() OVER (PARTITION BY `id` ORDER BY 
PROCTIME() DESC)"
+            " AS `__pf_row_number`\n"
+            "  FROM `SRC`\n"
+            ") WHERE `__pf_row_number` = 1",
+            lambda: self.dataframe.drop_duplicates(subset="id", keep="last"),
+        )
+
+    def test_sql_multi_column_subset(self):
+        self.assert_dataframe_sql(
+            self.dataframe,
+            "SELECT `id`, `name`, `score` FROM (\n"
+            "  SELECT *, ROW_NUMBER() OVER (PARTITION BY `id`, `name` ORDER BY 
PROCTIME() ASC)"
+            " AS `__pf_row_number`\n"
+            "  FROM `SRC`\n"
+            ") WHERE `__pf_row_number` = 1",
+            lambda: self.dataframe.drop_duplicates(subset=["id", "name"]),
+        )
+
+    def test_sql_order_by_column_name(self):
+        self.assert_dataframe_sql(
+            self.dataframe,
+            "SELECT `id`, `name`, `score` FROM (\n"
+            "  SELECT *, ROW_NUMBER() OVER (PARTITION BY `id` ORDER BY `score` 
DESC)"
+            " AS `__pf_row_number`\n"
+            "  FROM `SRC`\n"
+            ") WHERE `__pf_row_number` = 1",
+            lambda: self.dataframe.drop_duplicates(subset="id", 
order_by="score", keep="last"),
+        )
+
+    def test_sql_nulls_first_per_key(self):
+        self.assert_dataframe_sql(
+            self.dataframe,
+            "SELECT `id`, `name`, `score` FROM (\n"
+            "  SELECT *, ROW_NUMBER() OVER (PARTITION BY `id`"
+            " ORDER BY `score` ASC NULLS FIRST, `name` ASC NULLS LAST) AS 
`__pf_row_number`\n"
+            "  FROM `SRC`\n"
+            ") WHERE `__pf_row_number` = 1",
+            lambda: self.dataframe.drop_duplicates(
+                subset="id", order_by=["score", "name"], nulls_first=[True, 
False]
+            ),
+        )
+
+    def test_sql_order_by_expression_is_materialized(self):
+        self.assert_dataframe_sql(
+            self.dataframe,
+            "SELECT `id`, `name`, `score` FROM (\n"
+            "  SELECT *, ROW_NUMBER() OVER (PARTITION BY `id` ORDER BY 
`__pf_order_0` ASC)"
+            " AS `__pf_row_number`\n"
+            "  FROM `SRC`\n"
+            ") WHERE `__pf_row_number` = 1",
+            lambda: self.dataframe.drop_duplicates(subset="id", 
order_by=pf.col("score")),
+        )
+
+    def test_sql_after_select_lists_only_source_columns(self):
+        source = self.dataframe.select("id", "score")
+        self.assert_dataframe_sql(
+            source,
+            "SELECT `id`, `score` FROM (\n"
+            "  SELECT *, ROW_NUMBER() OVER (PARTITION BY `id` ORDER BY 
PROCTIME() ASC)"
+            " AS `__pf_row_number`\n"
+            "  FROM `SRC`\n"
+            ") WHERE `__pf_row_number` = 1",
+            lambda: source.drop_duplicates(subset="id"),
+        )
+
+    def test_sql_rank_column_avoids_collision(self):
+        dataframe = pf.from_records([{"__pf_row_number": 1, "value": 2}])
+        self.assert_dataframe_sql(
+            dataframe,
+            "SELECT `__pf_row_number`, `value` FROM (\n"
+            "  SELECT *, ROW_NUMBER() OVER (PARTITION BY `value` ORDER BY 
PROCTIME() ASC)"
+            " AS `__pf_row_number_`\n"
+            "  FROM `SRC`\n"
+            ") WHERE `__pf_row_number_` = 1",
+            lambda: dataframe.drop_duplicates(subset="value"),
+        )
+
+    def test_whole_row_produces_no_sql(self):
+        # Whole-row deduplication uses distinct(), not a generated query.
+        self.assert_dataframe_sql(
+            self.dataframe, None, lambda: self.dataframe.drop_duplicates()
+        )
+
+    def test_schema_preserved(self):
+        self.assert_dataframe_schema(
+            self.dataframe.drop_duplicates("id", order_by="score", 
keep="last"),
+            ["id", "name", "score"],
+        )
+
+    def test_whole_row_schema_preserved(self):
+        self.assert_dataframe_schema(
+            self.dataframe.drop_duplicates(), ["id", "name", "score"]
+        )
+
+    def test_rejects_invalid_keep(self):
+        with self.assertRaises(ValueError) as error:
+            self.dataframe.drop_duplicates("id", keep="middle")
+        self.assertEqual(str(error.exception), 'keep must be "first" or 
"last"')
+
+    def test_rejects_empty_subset_list(self):
+        with self.assertRaises(ValueError) as error:
+            self.dataframe.drop_duplicates([])
+        self.assertEqual(str(error.exception), "subset must not be empty")
+
+    def test_rejects_non_string_subset(self):
+        with self.assertRaises(TypeError) as error:
+            self.dataframe.drop_duplicates([1])
+        self.assertEqual(
+            str(error.exception), "subset must be a string or a list of 
strings"
+        )
+
+    def test_rejects_unknown_subset_column(self):
+        # The message embeds the (py4j) column list, so match only the stable 
prefix.
+        with self.assertRaisesRegex(ValueError, "subset column 'nope' does not 
exist"):
+            self.dataframe.drop_duplicates("nope")
+
+    def test_rejects_unknown_order_column(self):
+        # The message embeds the (py4j) column list, so match only the stable 
prefix.
+        with self.assertRaisesRegex(ValueError, "order_by column 'nope' does 
not exist"):
+            self.dataframe.drop_duplicates("id", order_by="nope")
+
+    def test_rejects_order_by_without_subset(self):
+        with self.assertRaises(ValueError) as error:
+            self.dataframe.drop_duplicates(order_by="score")
+        self.assertEqual(
+            str(error.exception),
+            "order_by requires subset; whole-row duplicates cannot be ordered",
+        )
+
+    def test_rejects_nulls_first_without_order_by(self):
+        with self.assertRaises(ValueError) as error:
+            self.dataframe.drop_duplicates("id", nulls_first=True)
+        self.assertEqual(str(error.exception), "nulls_first requires order_by")
+
+    def test_rejects_nulls_first_length_mismatch(self):
+        with self.assertRaises(ValueError) as error:
+            self.dataframe.drop_duplicates(
+                "id", order_by="score", nulls_first=[True, False]
+            )
+        self.assertEqual(
+            str(error.exception), "nulls_first must have the same length as 
order_by"
+        )
+
+    def test_rejects_nulls_first_wrong_type(self):
+        with self.assertRaises(TypeError) as error:
+            self.dataframe.drop_duplicates("id", order_by="score", 
nulls_first=["x"])
+        self.assertEqual(
+            str(error.exception), "nulls_first must be a boolean or a list of 
booleans"
+        )
+
+
+class DataFrameDistinctTests(PyFlinkDataFrameUTTestCase):
+    def setUp(self):
+        super().setUp()
+        self.dataframe = pf.from_records(
+            [{"id": 1, "score": 10}, {"id": 1, "score": 20}]
+        )
+
+    def test_shares_drop_duplicates_signature(self):
+        self.assertEqual(
+            inspect.signature(pf.DataFrame.distinct),
+            inspect.signature(pf.DataFrame.drop_duplicates),
+        )
+
+    def test_produces_the_same_sql_as_drop_duplicates(self):
+        self.assert_dataframe_sql(
+            self.dataframe,
+            "SELECT `id`, `score` FROM (\n"
+            "  SELECT *, ROW_NUMBER() OVER (PARTITION BY `id` ORDER BY `score` 
ASC)"
+            " AS `__pf_row_number`\n"
+            "  FROM `SRC`\n"
+            ") WHERE `__pf_row_number` = 1",
+            lambda: self.dataframe.distinct(subset="id", order_by="score"),
+        )
+
+
+class DataFrameUniqueTests(PyFlinkDataFrameUTTestCase):
+    def setUp(self):
+        super().setUp()
+        self.dataframe = pf.from_records(
+            [{"id": 1, "score": 10}, {"id": 1, "score": 20}]
+        )
+
+    def test_shares_drop_duplicates_signature(self):
+        self.assertEqual(
+            inspect.signature(pf.DataFrame.unique),
+            inspect.signature(pf.DataFrame.drop_duplicates),
+        )
+
+    def test_produces_the_same_sql_as_drop_duplicates(self):
+        self.assert_dataframe_sql(
+            self.dataframe,
+            "SELECT `id`, `score` FROM (\n"
+            "  SELECT *, ROW_NUMBER() OVER (PARTITION BY `id` ORDER BY `score` 
ASC)"
+            " AS `__pf_row_number`\n"
+            "  FROM `SRC`\n"
+            ") WHERE `__pf_row_number` = 1",
+            lambda: self.dataframe.unique(subset="id", order_by="score"),
+        )
+
+
+class DataFrameDropDuplicatesITTests(PyFlinkStreamDataFrameTestCase):
+    @classmethod
+    def setUpClass(cls):
+        super().setUpClass()
+        cls.t_env.get_config().set("table.exec.resource.default-parallelism", 
"1")
+
+    @staticmethod
+    def _materialize(dataframe, key=None):
+        # Fold the collected changelog into the final table so assertions read 
as the
+        # resulting rows rather than the raw +I/-U/+U events.
+        columns = dataframe._table.get_resolved_schema().get_column_names()
+        if key is None:
+            indices = list(range(len(columns)))
+        else:
+            indices = [columns.index(name) for name in key]
+
+        state = {}
+        for row in dataframe.collect():
+            key_value = tuple(row[index] for index in indices)
+            if row.get_row_kind() in (RowKind.INSERT, RowKind.UPDATE_AFTER):
+                state[key_value] = tuple(row)
+            else:
+                state.pop(key_value, None)
+        return sorted(state.values())
+
+    def test_whole_row_removes_identical_rows(self):
+        dataframe = pf.from_records(
+            [(1, "a"), (1, "a"), (2, "b")],
+            schema=["id", "name"],
+        )
+
+        self.assertEqual(
+            self._materialize(dataframe.drop_duplicates()),
+            [(1, "a"), (2, "b")],
+        )
+
+    def test_whole_row_keeps_rows_differing_in_any_column(self):
+        dataframe = pf.from_records(
+            [(1, "a"), (1, "b"), (1, "a")],
+            schema=["id", "name"],
+        )
+
+        self.assertEqual(
+            self._materialize(dataframe.drop_duplicates()),
+            [(1, "a"), (1, "b")],
+        )
+
+    def test_distinct_alias_removes_identical_rows(self):
+        dataframe = pf.from_records(
+            [(1, "a"), (1, "a"), (2, "b")],
+            schema=["id", "name"],
+        )
+
+        self.assertEqual(
+            self._materialize(dataframe.distinct()),
+            [(1, "a"), (2, "b")],
+        )
+
+    def test_unique_alias_removes_identical_rows(self):
+        dataframe = pf.from_records(
+            [(1, "a"), (1, "a"), (2, "b")],
+            schema=["id", "name"],
+        )
+
+        self.assertEqual(
+            self._materialize(dataframe.unique()),
+            [(1, "a"), (2, "b")],
+        )
+
+    def test_from_dict_input(self):
+        dataframe = pf.from_dict({"id": [1, 1, 2], "name": ["a", "a", "b"]})
+
+        self.assertEqual(
+            self._materialize(dataframe.drop_duplicates()),
+            [(1, "a"), (2, "b")],
+        )
+
+    def test_subset_keep_first_by_order_column(self):
+        dataframe = pf.from_records(
+            [
+                (1, "a", 10),
+                (1, "b", 20),
+                (2, "c", 30),
+                (2, "d", 5),
+                (3, "e", 7),
+            ],
+            schema=["id", "name", "score"],
+        )
+
+        result = dataframe.drop_duplicates("id", order_by="score", 
keep="first")
+
+        self.assertEqual(
+            self._materialize(result, key=["id"]),
+            [(1, "a", 10), (2, "d", 5), (3, "e", 7)],
+        )
+
+    def test_subset_keep_last_by_order_column(self):
+        dataframe = pf.from_records(
+            [
+                (1, "a", 10),
+                (1, "b", 20),
+                (2, "c", 30),
+                (2, "d", 5),
+                (3, "e", 7),
+            ],
+            schema=["id", "name", "score"],
+        )
+
+        result = dataframe.drop_duplicates("id", order_by="score", keep="last")
+
+        self.assertEqual(
+            self._materialize(result, key=["id"]),
+            [(1, "b", 20), (2, "c", 30), (3, "e", 7)],
+        )
+
+    def test_multi_column_subset_keep_first_by_order_column(self):
+        dataframe = pf.from_records(
+            [
+                (1, "a", 10),
+                (1, "a", 20),
+                (1, "b", 30),
+                (2, "a", 40),
+            ],
+            schema=["id", "name", "score"],
+        )
+
+        result = dataframe.drop_duplicates(["id", "name"], order_by="score", 
keep="first")
+
+        self.assertEqual(
+            self._materialize(result, key=["id", "name"]),
+            [(1, "a", 10), (1, "b", 30), (2, "a", 40)],
+        )
+
+    def test_subset_keep_first_by_arrival(self):
+        dataframe = pf.from_records(
+            [(1, "a"), (1, "b"), (2, "c")],
+            schema=["id", "name"],
+        )
+
+        self.assertEqual(
+            self._materialize(dataframe.drop_duplicates("id"), key=["id"]),
+            [(1, "a"), (2, "c")],
+        )
+
+    def test_subset_keep_last_by_arrival(self):
+        dataframe = pf.from_records(
+            [(1, "a"), (1, "b"), (2, "c")],
+            schema=["id", "name"],
+        )
+
+        result = dataframe.drop_duplicates("id", keep="last")
+
+        self.assertEqual(
+            self._materialize(result, key=["id"]),
+            [(1, "b"), (2, "c")],
+        )
+
+    def test_dedup_after_select_and_filter(self):
+        dataframe = pf.from_records(
+            [
+                (1, "a", 10),
+                (1, "b", 20),
+                (2, "c", 30),
+                (3, "d", 40),
+            ],
+            schema=["id", "name", "score"],
+        )
+
+        result = (
+            dataframe.filter(pf.col("id") > 1)
+            .select("id", "score")
+            .drop_duplicates("id", order_by="score", keep="last")
+        )
+
+        self.assertEqual(
+            self._materialize(result, key=["id"]),
+            [(2, 30), (3, 40)],
+        )
+
+    def test_filter_after_dedup(self):
+        dataframe = pf.from_records(
+            [
+                (1, "a", 10),
+                (1, "b", 20),
+                (2, "c", 30),
+            ],
+            schema=["id", "name", "score"],
+        )
+
+        result = dataframe.drop_duplicates("id", order_by="score", 
keep="last").filter(
+            pf.col("id") > 1
+        )
+
+        self.assertEqual(
+            self._materialize(result, key=["id"]),
+            [(2, "c", 30)],
+        )
+
+
 class DataFrameITTests(PyFlinkStreamDataFrameTestCase):
     def test_from_records(self):
         dataframe = pf.from_records(
diff --git a/flink-python/pyflink/testing/test_case_utils.py 
b/flink-python/pyflink/testing/test_case_utils.py
index bc547bd8d36..095438b927d 100644
--- a/flink-python/pyflink/testing/test_case_utils.py
+++ b/flink-python/pyflink/testing/test_case_utils.py
@@ -29,7 +29,6 @@ import unittest
 from abc import abstractmethod
 from decimal import Decimal
 from functools import wraps
-
 from py4j.java_gateway import JavaObject
 
 from pyflink.common import JobExecutionResult, Time, Instant, Row
@@ -183,6 +182,27 @@ class PyFlinkDataFrameUTTestCase(PyFlinkUTTestCase):
                 expected_column_data_types,
             )
 
+    def assert_dataframe_sql(self, dataframe, expected_sql, invoke):
+        environment = dataframe._table._t_env
+        original = environment.sql_query
+        captured = {}
+
+        def capture(query):
+            captured["sql"] = query
+            return original(query)
+
+        environment.sql_query = capture
+        try:
+            invoke()
+        finally:
+            del environment.sql_query
+
+        if "sql" in captured:
+            actual_sql = re.sub(r"UnnamedTable\$\d+", "SRC", captured["sql"])
+        else:
+            actual_sql = None
+        self.assertEqual(actual_sql, expected_sql)
+
 
 class PyFlinkStreamTableTestCase(PyFlinkITTestCase):
     """

Reply via email to