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 f2f76a16e48 [FLINK-40420][python] Add set operations to DataFrame API 
(#29166)
f2f76a16e48 is described below

commit f2f76a16e48bec35e662863b5e92efca718b1a24
Author: Beetle <[email protected]>
AuthorDate: Tue Sep 15 19:25:45 2026 +0800

    [FLINK-40420][python] Add set operations to DataFrame API (#29166)
    
    Generated-by: OpenAI Codex (codex-cli 0.154.0-alpha.6.2)
---
 .../docs/reference/pyflink.dataframe/dataframe.rst |  15 ++
 flink-python/pyflink/dataframe/dataframe.py        | 160 ++++++++++++++++
 .../pyflink/dataframe/tests/test_dataframe.py      | 213 +++++++++++++++++++++
 3 files changed, 388 insertions(+)

diff --git a/flink-python/docs/reference/pyflink.dataframe/dataframe.rst 
b/flink-python/docs/reference/pyflink.dataframe/dataframe.rst
index c4f36fb8ae6..e78bbaf0d93 100644
--- a/flink-python/docs/reference/pyflink.dataframe/dataframe.rst
+++ b/flink-python/docs/reference/pyflink.dataframe/dataframe.rst
@@ -70,6 +70,21 @@ Transformations
     DataFrame.head
     DataFrame.__getitem__
 
+Set Operations
+--------------
+
+.. currentmodule:: pyflink.dataframe
+
+.. autosummary::
+    :toctree: api/
+
+    DataFrame.union
+    DataFrame.union_all
+    DataFrame.intersect
+    DataFrame.intersect_all
+    DataFrame.minus
+    DataFrame.minus_all
+
 Aggregations
 ------------
 
diff --git a/flink-python/pyflink/dataframe/dataframe.py 
b/flink-python/pyflink/dataframe/dataframe.py
index 426e3a5640d..2e1b8fd8208 100644
--- a/flink-python/pyflink/dataframe/dataframe.py
+++ b/flink-python/pyflink/dataframe/dataframe.py
@@ -696,6 +696,166 @@ class DataFrame:
             order_expressions.append(expression.desc if is_descending else 
expression.asc)
         return DataFrame(self._table.order_by(*order_expressions))
 
+    # ======================== Set Operations ========================
+
+    @PublicEvolving()
+    def union(self, other: "DataFrame") -> "DataFrame":
+        """
+        Return rows from either DataFrame, removing duplicate rows (SQL 
``UNION``).
+
+        This operation is currently supported only in batch mode.
+
+        Both DataFrames must belong to the same TableEnvironment and have the 
same number
+        of columns with compatible types at each position. Columns are matched 
by position,
+        not by name; the result uses this DataFrame's column names.
+
+        :param other: The DataFrame to combine with this DataFrame.
+        :return: A new DataFrame containing the distinct rows from both inputs.
+        :raises TypeError: If ``other`` is not a DataFrame.
+
+        Example::
+
+            >>> result = left.union(right)
+
+        .. versionadded:: 2.4.0
+        """
+        if not isinstance(other, DataFrame):
+            raise TypeError("other must be a DataFrame")
+        return DataFrame(self._table.union(other._table))
+
+    @PublicEvolving()
+    def union_all(self, other: "DataFrame") -> "DataFrame":
+        """
+        Return rows from both DataFrames, retaining all duplicates (SQL 
``UNION ALL``).
+
+        Both DataFrames must belong to the same TableEnvironment and have the 
same number
+        of columns with compatible types at each position. Columns are matched 
by position,
+        not by name; the result uses this DataFrame's column names.
+
+        :param other: The DataFrame to combine with this DataFrame.
+        :return: A new DataFrame containing all rows from both inputs.
+        :raises TypeError: If ``other`` is not a DataFrame.
+
+        Example::
+
+            >>> result = left.union_all(right)
+
+        .. versionadded:: 2.4.0
+        """
+        if not isinstance(other, DataFrame):
+            raise TypeError("other must be a DataFrame")
+        return DataFrame(self._table.union_all(other._table))
+
+    @PublicEvolving()
+    def intersect(self, other: "DataFrame") -> "DataFrame":
+        """
+        Return rows present in both DataFrames, removing duplicates (SQL 
``INTERSECT``).
+
+        This operation is currently supported only in batch mode.
+
+        Both DataFrames must belong to the same TableEnvironment and have the 
same number
+        of columns with matching types at each position. Columns are matched 
by position,
+        not by name; the result uses this DataFrame's column names.
+        Cast differing column types explicitly before calling this method.
+
+        :param other: The DataFrame to intersect with this DataFrame.
+        :return: A new DataFrame containing the distinct rows common to both 
inputs.
+        :raises TypeError: If ``other`` is not a DataFrame.
+
+        Example::
+
+            >>> result = left.intersect(right)
+
+        .. versionadded:: 2.4.0
+        """
+        if not isinstance(other, DataFrame):
+            raise TypeError("other must be a DataFrame")
+        return DataFrame(self._table.intersect(other._table))
+
+    @PublicEvolving()
+    def intersect_all(self, other: "DataFrame") -> "DataFrame":
+        """
+        Return rows present in both DataFrames, retaining duplicates (SQL 
``INTERSECT ALL``).
+
+        This operation is currently supported only in batch mode.
+
+        A row occurring ``n`` times in this DataFrame and ``m`` times in 
``other`` is returned
+        ``min(n, m)`` times.
+
+        Both DataFrames must belong to the same TableEnvironment and have the 
same number
+        of columns with matching types at each position. Columns are matched 
by position,
+        not by name; the result uses this DataFrame's column names.
+        Cast differing column types explicitly before calling this method.
+
+        :param other: The DataFrame to intersect with this DataFrame.
+        :return: A new DataFrame containing the common rows with their shared 
multiplicities.
+        :raises TypeError: If ``other`` is not a DataFrame.
+
+        Example::
+
+            >>> result = left.intersect_all(right)
+
+        .. versionadded:: 2.4.0
+        """
+        if not isinstance(other, DataFrame):
+            raise TypeError("other must be a DataFrame")
+        return DataFrame(self._table.intersect_all(other._table))
+
+    @PublicEvolving()
+    def minus(self, other: "DataFrame") -> "DataFrame":
+        """
+        Return rows absent from ``other``, removing duplicate rows (SQL 
``EXCEPT``).
+
+        This operation is currently supported only in batch mode.
+
+        Both DataFrames must belong to the same TableEnvironment and have the 
same number
+        of columns with matching types at each position. Columns are matched 
by position,
+        not by name; the result uses this DataFrame's column names.
+        Cast differing column types explicitly before calling this method.
+
+        :param other: The DataFrame whose rows are excluded from this 
DataFrame.
+        :return: A new DataFrame containing the distinct rows present only in 
this DataFrame.
+        :raises TypeError: If ``other`` is not a DataFrame.
+
+        Example::
+
+            >>> result = left.minus(right)
+
+        .. versionadded:: 2.4.0
+        """
+        if not isinstance(other, DataFrame):
+            raise TypeError("other must be a DataFrame")
+        return DataFrame(self._table.minus(other._table))
+
+    @PublicEvolving()
+    def minus_all(self, other: "DataFrame") -> "DataFrame":
+        """
+        Subtract the occurrences of rows in ``other`` from this DataFrame (SQL 
``EXCEPT ALL``).
+
+        This operation is currently supported only in batch mode.
+
+        A row occurring ``n`` times in this DataFrame and ``m`` times in 
``other`` is returned
+        ``max(n - m, 0)`` times.
+
+        Both DataFrames must belong to the same TableEnvironment and have the 
same number
+        of columns with matching types at each position. Columns are matched 
by position,
+        not by name; the result uses this DataFrame's column names.
+        Cast differing column types explicitly before calling this method.
+
+        :param other: The DataFrame whose row occurrences are subtracted.
+        :return: A new DataFrame containing the remaining row occurrences.
+        :raises TypeError: If ``other`` is not a DataFrame.
+
+        Example::
+
+            >>> result = left.minus_all(right)
+
+        .. versionadded:: 2.4.0
+        """
+        if not isinstance(other, DataFrame):
+            raise TypeError("other must be a DataFrame")
+        return DataFrame(self._table.minus_all(other._table))
+
     # ======================== Windowing ========================
 
     @PublicEvolving()
diff --git a/flink-python/pyflink/dataframe/tests/test_dataframe.py 
b/flink-python/pyflink/dataframe/tests/test_dataframe.py
index f104db9242f..736fdbc17d7 100644
--- a/flink-python/pyflink/dataframe/tests/test_dataframe.py
+++ b/flink-python/pyflink/dataframe/tests/test_dataframe.py
@@ -249,6 +249,89 @@ class DataFrameSlicingTests(unittest.TestCase):
         self.table.execute.assert_not_called()
 
 
+class DataFrameSetOperationTests(PyFlinkDataFrameUTTestCase):
+    METHODS = ("union", "union_all", "intersect", "intersect_all", "minus", 
"minus_all")
+
+    def setUp(self):
+        super().setUp()
+        self.t_env = 
TableEnvironment.create(EnvironmentSettings.in_batch_mode())
+        pf.set_table_environment(self.t_env)
+
+    def test_set_operations_are_lazy_and_preserve_inputs(self):
+        left = pf.from_table(self.t_env.sql_query("SELECT 1 AS id"))
+        right = pf.from_table(self.t_env.sql_query("SELECT 2 AS id"))
+        left_table, right_table = left.to_table(), right.to_table()
+
+        with patch.object(type(left_table), "execute") as execute:
+            for method in self.METHODS:
+                with self.subTest(method=method):
+                    result = getattr(left, method)(right)
+                    self.assertIsInstance(result, pf.DataFrame)
+                    self.assertIsNot(result, left)
+                    self.assertIsNot(result, right)
+                    self.assertIs(result.to_table()._t_env, self.t_env)
+                    self.assertIs(left.to_table(), left_table)
+                    self.assertIs(right.to_table(), right_table)
+            execute.assert_not_called()
+
+    def test_set_operations_match_columns_by_position(self):
+        left = pf.from_table(self.t_env.sql_query("SELECT 1 AS id, 'a' AS 
name"))
+        right = pf.from_table(
+            self.t_env.sql_query("SELECT 2 AS other_id, 'b' AS other_name")
+        )
+
+        for method in self.METHODS:
+            with self.subTest(method=method):
+                result = getattr(left, method)(right)
+                self.assert_dataframe_schema(
+                    result, ["id", "name"],
+                    [TableDataTypes.INT().not_null(), 
TableDataTypes.CHAR(1).not_null()],
+                )
+
+    def test_set_operations_reject_non_dataframe_arguments(self):
+        dataframe = pf.from_table(self.t_env.sql_query("SELECT 1 AS id"))
+
+        for method in self.METHODS:
+            for other in (None, 1, "id", [], dataframe.to_table()):
+                with self.subTest(method=method, other=other):
+                    with self.assertRaisesRegex(TypeError, "other must be a 
DataFrame"):
+                        getattr(dataframe, method)(other)
+
+    def test_set_operations_reject_incompatible_schemas(self):
+        left = pf.from_table(self.t_env.sql_query("SELECT 1 AS id"))
+        others = [
+            pf.from_table(self.t_env.sql_query("SELECT 1 AS id, 2 AS extra")),
+            pf.from_table(self.t_env.sql_query("SELECT ROW(1) AS id")),
+        ]
+
+        for method in self.METHODS:
+            for other in others:
+                with self.subTest(method=method, columns=other.columns):
+                    with self.assertRaisesRegex(Py4JJavaError, 
"ValidationException"):
+                        getattr(left, method)(other)
+
+    def test_set_operations_reject_different_table_environments(self):
+        left = pf.from_table(self.t_env.sql_query("SELECT 1 AS id"))
+        other_environment = 
TableEnvironment.create(EnvironmentSettings.in_batch_mode())
+        right = pf.from_table(other_environment.sql_query("SELECT 2 AS id"))
+
+        for method in self.METHODS:
+            with self.subTest(method=method):
+                with self.assertRaisesRegex(Py4JJavaError, "same 
TableEnvironment"):
+                    getattr(left, method)(right)
+
+    def test_set_operations_preserve_streaming_restrictions(self):
+        streaming_environment = 
TableEnvironment.create(EnvironmentSettings.in_streaming_mode())
+        left = pf.from_table(streaming_environment.sql_query("SELECT 1 AS id"))
+        right = pf.from_table(streaming_environment.sql_query("SELECT 2 AS 
id"))
+
+        self.assert_dataframe_schema(left.union_all(right), ["id"])
+        for method in ("union", "intersect", "intersect_all", "minus", 
"minus_all"):
+            with self.subTest(method=method):
+                with self.assertRaisesRegex(Py4JJavaError, "currently not 
supported"):
+                    getattr(left, method)(right)
+
+
 class DataFrameSortingTests(PyFlinkDataFrameUTTestCase):
     def setUp(self):
         super().setUp()
@@ -2330,6 +2413,136 @@ class DataFrameBatchITTests(PyFlinkITTestCase):
         )
 
 
+class DataFrameSetOperationITTests(PyFlinkITTestCase):
+    def setUp(self):
+        self.t_env = 
TableEnvironment.create(EnvironmentSettings.in_batch_mode())
+
+    def test_set_operations_with_duplicate_and_null_rows(self):
+        left = pf.from_table(self.t_env.sql_query(
+            "SELECT * FROM (VALUES (1, 'a'), (1, 'a'), (1, 'a'), (2, 'b'), "
+            "(3, 'c'), (3, 'c'), (CAST(NULL AS INT), 'n'), "
+            "(CAST(NULL AS INT), 'n'), (1, 'x')) AS T(id, name)"
+        ))
+        right = pf.from_table(self.t_env.sql_query(
+            "SELECT * FROM (VALUES (1, 'a'), (1, 'a'), (2, 'b'), (2, 'b'), "
+            "(4, 'd'), (CAST(NULL AS INT), 'n')) AS T(other_id, other_name)"
+        ))
+        expected = {
+            "union": [Row(1, "a"), Row(2, "b"), Row(3, "c"), Row(4, "d"),
+                      Row(None, "n"), Row(1, "x")],
+            "union_all": ([Row(1, "a")] * 5 + [Row(2, "b")] * 3 + [Row(3, 
"c")] * 2
+                          + [Row(4, "d")] + [Row(None, "n")] * 3 + [Row(1, 
"x")]),
+            "intersect": [Row(1, "a"), Row(2, "b"), Row(None, "n")],
+            "intersect_all": [Row(1, "a"), Row(1, "a"), Row(2, "b"), Row(None, 
"n")],
+            "minus": [Row(3, "c"), Row(1, "x")],
+            "minus_all": [Row(1, "a"), Row(3, "c"), Row(3, "c"), Row(None, 
"n"), Row(1, "x")],
+        }
+
+        for method, rows in expected.items():
+            with self.subTest(method=method):
+                self.assertCountEqual(getattr(left, method)(right).collect(), 
rows)
+
+    def test_set_operations_with_empty_inputs(self):
+        dataframe = pf.from_table(self.t_env.sql_query(
+            "SELECT * FROM (VALUES (1), (1)) AS T(id)"
+        ))
+        empty = dataframe.filter(pf.lit(False))
+        expected = {
+            "union": ([Row(1)], [Row(1)]),
+            "union_all": ([Row(1), Row(1)], [Row(1), Row(1)]),
+            "intersect": ([], []),
+            "intersect_all": ([], []),
+            "minus": ([Row(1)], []),
+            "minus_all": ([Row(1), Row(1)], []),
+        }
+
+        for method, (right_empty, left_empty) in expected.items():
+            with self.subTest(method=method, empty="right"):
+                self.assertCountEqual(getattr(dataframe, 
method)(empty).collect(), right_empty)
+            with self.subTest(method=method, empty="left"):
+                self.assertCountEqual(getattr(empty, 
method)(dataframe).collect(), left_empty)
+
+    def test_set_operations_compose_with_other_transformations(self):
+        left = pf.from_table(self.t_env.sql_query(
+            "SELECT * FROM (VALUES (1, 'a'), (2, 'b'), (2, 'b'), (3, 'c')) AS 
T(id, name)"
+        ))
+        right = pf.from_table(self.t_env.sql_query(
+            "SELECT * FROM (VALUES (2, 'b'), (4, 'd')) AS T(id, name)"
+        ))
+        expected = {
+            "union": [Row("b"), Row("c"), Row("d")],
+            "union_all": [Row("b"), Row("b"), Row("b"), Row("c"), Row("d")],
+            "intersect": [Row("b")],
+            "intersect_all": [Row("b")],
+            "minus": [Row("c")],
+            "minus_all": [Row("b"), Row("c")],
+        }
+
+        for method, rows in expected.items():
+            with self.subTest(method=method):
+                result = getattr(left, method)(right).filter(pf.col("id") > 
1).select("name")
+                self.assertCountEqual(result.collect(), rows)
+
+    def test_union_operations_execute_with_type_coercion(self):
+        left = pf.from_table(self.t_env.sql_query(
+            "SELECT * FROM (VALUES (1), (2), (2), (3)) AS T(id)"
+        ))
+        right = pf.from_table(self.t_env.sql_query(
+            "SELECT * FROM (VALUES (CAST(2 AS BIGINT)), (CAST(2147483648 AS 
BIGINT))) "
+            "AS T(other_id)"
+        ))
+        expected = {
+            "union": [Row(1), Row(2), Row(3), Row(2147483648)],
+            "union_all": [Row(1), Row(2), Row(2), Row(2), Row(3), 
Row(2147483648)],
+        }
+
+        for method, rows in expected.items():
+            with self.subTest(method=method):
+                result = getattr(left, method)(right)
+                self.assertEqual(
+                    
result.to_table().get_resolved_schema().get_column_data_types(),
+                    [TableDataTypes.BIGINT().not_null()],
+                )
+                self.assertCountEqual(result.collect(), rows)
+
+    def test_set_operations_execute_after_explicit_cast(self):
+        left = pf.from_table(self.t_env.sql_query(
+            "SELECT * FROM (VALUES (1), (2), (2), (3)) AS T(id)"
+        )).select(id=pf.col("id").cast(TableDataTypes.BIGINT()))
+        right = pf.from_table(self.t_env.sql_query(
+            "SELECT * FROM (VALUES (CAST(2 AS BIGINT)), (CAST(2147483648 AS 
BIGINT))) "
+            "AS T(other_id)"
+        ))
+        expected = {
+            "union": [Row(1), Row(2), Row(3), Row(2147483648)],
+            "union_all": [Row(1), Row(2), Row(2), Row(2), Row(3), 
Row(2147483648)],
+            "intersect": [Row(2)],
+            "intersect_all": [Row(2)],
+            "minus": [Row(1), Row(3)],
+            "minus_all": [Row(1), Row(2), Row(3)],
+        }
+
+        for method, rows in expected.items():
+            with self.subTest(method=method):
+                result = getattr(left, method)(right)
+                self.assertEqual(
+                    
result.to_table().get_resolved_schema().get_column_data_types(),
+                    [TableDataTypes.BIGINT().not_null()],
+                )
+                self.assertCountEqual(result.collect(), rows)
+
+
+class DataFrameSetOperationStreamITTests(PyFlinkStreamDataFrameTestCase):
+    def test_union_all_retains_duplicates(self):
+        left = pf.from_records([(1,), (2,), (2,)], schema=["id"])
+        right = pf.from_records([(2,), (3,)], schema=["id"])
+
+        self.assertCountEqual(
+            left.union_all(right).collect(),
+            [Row(1), Row(2), Row(2), Row(2), Row(3)],
+        )
+
+
 class DataFrameWindowITTests(PyFlinkStreamDataFrameTestCase):
     @classmethod
     def setUpClass(cls):

Reply via email to