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 17d58a40a85 [FLINK-40435][python] Add explode support to DataFrame API
17d58a40a85 is described below

commit 17d58a40a85c97eb48c335225d231d28b5ec4269
Author: beetle0915 <[email protected]>
AuthorDate: Tue Sep 15 23:39:17 2026 +0800

    [FLINK-40435][python] Add explode support to DataFrame API
    
    This closes #29196.
---
 .../docs/reference/pyflink.dataframe/dataframe.rst |   1 +
 flink-python/pyflink/dataframe/dataframe.py        | 175 ++++++++++++++++
 .../pyflink/dataframe/tests/test_dataframe.py      | 220 +++++++++++++++++++++
 3 files changed, 396 insertions(+)

diff --git a/flink-python/docs/reference/pyflink.dataframe/dataframe.rst 
b/flink-python/docs/reference/pyflink.dataframe/dataframe.rst
index 9c65ebdef80..13eef577d3e 100644
--- a/flink-python/docs/reference/pyflink.dataframe/dataframe.rst
+++ b/flink-python/docs/reference/pyflink.dataframe/dataframe.rst
@@ -66,6 +66,7 @@ Transformations
     DataFrame.rename
     DataFrame.filter
     DataFrame.where
+    DataFrame.explode
     DataFrame.drop_duplicates
     DataFrame.distinct
     DataFrame.unique
diff --git a/flink-python/pyflink/dataframe/dataframe.py 
b/flink-python/pyflink/dataframe/dataframe.py
index 119d48b06b6..a3635f876b4 100644
--- a/flink-python/pyflink/dataframe/dataframe.py
+++ b/flink-python/pyflink/dataframe/dataframe.py
@@ -50,6 +50,7 @@ from pyflink.table.expressions import (
     lit as table_lit,
 )
 from pyflink.table.table import Table
+from pyflink.table.types import ArrayType, MapType, MultisetType, RowType
 from pyflink.util.api_stability_decorators import PublicEvolving
 
 __all__ = ["DataFrame", "GroupedDataFrame", "col", "lit"]
@@ -711,6 +712,180 @@ class DataFrame:
         # Table UDTFs expose positional field names, so restore the declared 
names.
         return DataFrame(table.alias(output_columns[0], *output_columns[1:]))
 
+    @PublicEvolving()
+    def explode(
+        self,
+        column: Union[str, Expression],
+        *,
+        output_column: Optional[Union[str, List[str]]] = None,
+        ignore_empty_and_null: bool = False,
+    ) -> "DataFrame":
+        """
+        Expand an ARRAY, MAP, or MULTISET into rows, preserving duplicate 
occurrences.
+
+        A referenced input column is replaced in place by the expanded 
element. For a computed
+        collection expression, all input columns are retained and the expanded 
element is
+        appended. MAP values yield key and value fields. ROW elements remain a 
single ROW column;
+        use :attr:`pyflink.table.Expression.flatten` in a subsequent 
:meth:`select` to expand its
+        fields. Empty and null collections produce a row with null output 
fields unless
+        ``ignore_empty_and_null`` is true.
+
+        .. warning::
+            Due to FLINK-40658, actual null ROW elements in an ARRAY are not 
handled correctly
+            and may be silently dropped or cause execution to fail. This 
limitation is independent
+            of ``ignore_empty_and_null``. A non-null ROW whose fields are all 
null is not affected.
+
+        :param column: Collection column name or row-wise expression to expand.
+        :param output_column: Output name or list of names. Required for 
multiple fields;
+            a single field defaults to the selected column name. Names must be 
unique and must
+            not conflict with retained input columns.
+        :param ignore_empty_and_null: Whether to drop rows with empty or null 
collections.
+        :return: A new DataFrame with the expanded rows.
+        :raises TypeError: If an argument has an unsupported type or the input 
is not a collection.
+        :raises ValueError: If the expression is not row-wise, selects 
multiple columns,
+            or output names are invalid.
+
+        Examples:
+
+        Explode an ARRAY of scalar values::
+
+            >>> import pyflink.dataframe as pf
+            >>> df = pf.from_dict({"id": [1, 2], "tags": [["a", "b"], []]})
+            >>> result = df.explode(
+            ...     "tags", output_column="tag", ignore_empty_and_null=True)
+
+        Explode an ARRAY of ROW values, then expand the ROW fields explicitly::
+
+            >>> import pyflink.dataframe as pf
+            >>> from typing import NamedTuple
+            >>> class Item(NamedTuple):
+            ...     name: str
+            ...     quantity: int
+            >>> orders = pf.from_records(
+            ...     [(1, [Item("apple", 2)])], schema=["id", "items"])
+            >>> exploded = orders.explode("items", ignore_empty_and_null=True)
+            >>> flattened = exploded.select("id", pf.col("items").flatten)
+            >>> flattened.columns
+            ['id', 'items$name', 'items$quantity']
+
+        .. versionadded:: 2.4.0
+        """
+        if not isinstance(column, (str, Expression)):
+            raise TypeError("column must be a column name or expression")
+        if not isinstance(ignore_empty_and_null, bool):
+            raise TypeError("ignore_empty_and_null must be a boolean")
+        if output_column is not None and not isinstance(output_column, (str, 
list)):
+            raise TypeError("output_column must be a string or list of 
strings")
+
+        expression = table_col(column) if isinstance(column, str) else column
+        selected = self._table.select(expression)
+        schema = selected.get_resolved_schema()
+        if len(schema.get_column_names()) != 1:
+            raise ValueError("column must select a single column")
+        projection = selected._j_table.getQueryOperation()
+        # Aggregates insert an intermediate operation whose field indexes 
refer to its result.
+        if not 
projection.getChildren().get(0).equals(self._table._j_table.getQueryOperation()):
+            raise ValueError("column must be a row-wise expression, not an 
aggregation")
+
+        data_type = schema.get_column_data_types()[0]
+        element_row_type = None
+        element_row_type_sql = None
+        if isinstance(data_type, MapType):
+            field_count = 2
+        elif isinstance(data_type, (ArrayType, MultisetType)):
+            element_type = data_type.element_type
+            element_row_type = element_type if isinstance(element_type, 
RowType) else None
+            if element_row_type is not None:
+                collection_type = 
schema._j_resolved_schema.getColumnDataTypes().get(0)
+                element_row_type_sql = (
+                    collection_type.getChildren()
+                    .get(0)
+                    .getLogicalType()
+                    .copy(True)
+                    .asSerializableString()
+                )
+            field_count = 1
+        else:
+            raise TypeError("column must have an ARRAY, MAP, or MULTISET type")
+
+        if output_column is None:
+            if field_count != 1:
+                raise ValueError("output_column is required for multiple 
output fields")
+            output_names = [schema.get_column_names()[0]]
+        else:
+            output_names = [output_column] if isinstance(output_column, str) 
else output_column
+        if not all(isinstance(name, str) for name in output_names):
+            raise TypeError("output_column must contain only strings")
+        if len(output_names) != field_count:
+            raise ValueError("output_column must contain %d name(s)" % 
field_count)
+        if any(not name for name in output_names) or len(set(output_names)) != 
field_count:
+            raise ValueError("output_column names must be non-empty and 
unique")
+
+        table = self._table
+        columns = list(table.get_resolved_schema().get_column_names())
+        resolved = projection.getProjectList().get(0)
+        # Resolve the input field by index so an alias does not hide the 
column to remove.
+        if resolved.getClass().getSimpleName() == "FieldReferenceExpression":
+            output_index = resolved.getFieldIndex()
+            collection_name = columns.pop(output_index)
+        else:
+            output_index = len(columns)
+            taken = set(columns) | set(output_names)
+            collection_name = _unique_name("__pf_explode", taken)
+            table = table.add_columns(expression.alias(collection_name))
+        if set(output_names).intersection(columns):
+            raise ValueError("output_column names conflict with retained input 
columns")
+
+        unnest_output_names = output_names
+        ordinality_name = None
+        if element_row_type is not None:
+            # SQL UNNEST expands ROW fields. Reassemble them below, using 
ordinality to
+            # distinguish an outer-join placeholder from a real ROW whose 
fields are all null.
+            taken = set(columns) | set(output_names) | {collection_name}
+            unnest_output_names = []
+            for index in range(len(element_row_type.fields)):
+                name = _unique_name("__pf_explode_field_%d" % index, taken)
+                taken.add(name)
+                unnest_output_names.append(name)
+            if not ignore_empty_and_null:
+                ordinality_name = _unique_name("__pf_explode_ordinality", 
taken)
+
+        if element_row_type is None:
+            explode_projection = [
+                "expanded." + _quote_identifier(name) for name in 
unnest_output_names
+            ]
+        else:
+            row_value = "CAST(ROW(%s) AS %s)" % (
+                ", ".join(
+                    "expanded." + _quote_identifier(name) for name in 
unnest_output_names
+                ),
+                element_row_type_sql,
+            )
+            if ordinality_name is not None:
+                row_value = "CASE WHEN expanded.%s IS NULL THEN CAST(NULL AS 
%s) ELSE %s END" % (
+                    _quote_identifier(ordinality_name),
+                    element_row_type_sql,
+                    row_value,
+                )
+            explode_projection = [row_value + " AS " + 
_quote_identifier(output_names[0])]
+
+        projections = ["src." + _quote_identifier(name) for name in columns]
+        projections[output_index:output_index] = explode_projection
+        query = "SELECT %s FROM %s AS src %s UNNEST(src.%s)%s AS 
expanded(%s)%s" % (
+            ", ".join(projections),
+            _quote_identifier(str(table)),
+            "CROSS JOIN" if ignore_empty_and_null else "LEFT JOIN",
+            _quote_identifier(collection_name),
+            " WITH ORDINALITY" if ordinality_name is not None else "",
+            ", ".join(
+                _quote_identifier(name)
+                for name in unnest_output_names
+                + ([ordinality_name] if ordinality_name is not None else [])
+            ),
+            "" if ignore_empty_and_null else " ON TRUE",
+        )
+        return DataFrame(table._t_env.sql_query(query))
+
     # ======================== Filtering & Ordering ========================
 
     @PublicEvolving()
diff --git a/flink-python/pyflink/dataframe/tests/test_dataframe.py 
b/flink-python/pyflink/dataframe/tests/test_dataframe.py
index 510e5af1529..f7be076ca5a 100644
--- a/flink-python/pyflink/dataframe/tests/test_dataframe.py
+++ b/flink-python/pyflink/dataframe/tests/test_dataframe.py
@@ -38,6 +38,7 @@ from pyflink.dataframe.datatype import DataType
 from pyflink.table import (
     DataTypes as TableDataTypes,
     EnvironmentSettings,
+    Table,
     TableEnvironment,
     TableSchema,
 )
@@ -332,6 +333,134 @@ class 
DataFrameSetOperationTests(PyFlinkDataFrameUTTestCase):
                     getattr(left, method)(right)
 
 
+class DataFrameExplodeTests(PyFlinkDataFrameUTTestCase):
+    def setUp(self):
+        super().setUp()
+        self.df = pf.from_table(self.t_env.sql_query(
+            "SELECT 1 AS id, ARRAY[1, 2] AS items, 'A' AS label"))
+
+    def test_explode_is_lazy_and_preserves_input(self):
+        table = self.df.to_table()
+        with patch.object(Table, "execute") as execute:
+            result = self.df.explode("items")
+            execute.assert_not_called()
+        self.assertIsNot(result, self.df)
+        self.assertIs(result.to_table()._t_env, self.t_env)
+        self.assertIs(self.df.to_table(), table)
+        self.assert_dataframe_schema(self.df, ["id", "items", "label"])
+        self.assert_dataframe_schema(result, ["id", "items", "label"], [
+            TableDataTypes.INT().not_null(), TableDataTypes.INT(),
+            TableDataTypes.CHAR(1).not_null(),
+        ])
+
+    def test_explode_supports_column_expressions_and_aliases(self):
+        for column, output, expected in [
+            ("items", None, "items"),
+            (pf.col("items"), None, "items"),
+            (self.df["items"], "item", "item"),
+            (pf.col("items").alias("renamed"), None, "renamed"),
+            ("items", ["item"], "item"),
+        ]:
+            with self.subTest(column=str(column), output=output):
+                self.assert_dataframe_schema(
+                    self.df.explode(column, output_column=output), ["id", 
expected, "label"])
+
+    def test_explode_supports_computed_collection_expressions(self):
+        from pyflink.table.expressions import array
+
+        result = self.df.explode(
+            array(pf.col("id"), pf.lit(2)), output_column="value")
+        self.assert_dataframe_schema(result, ["id", "items", "label", "value"])
+
+    def test_explode_quotes_identifiers(self):
+        df = pf.from_table(self.t_env.sql_query(
+            "SELECT 1 AS `select`, ARRAY[2] AS `a``b`, 3 AS `value`"))
+
+        result = df.explode(pf.col("a`b"), output_column="value` name")
+
+        self.assert_dataframe_schema(result, ["select", "value` name", 
"value"])
+
+    def test_explode_resolves_collection_output_types(self):
+        item_type = TableDataTypes.ROW([
+            TableDataTypes.FIELD("number", TableDataTypes.INT()),
+            TableDataTypes.FIELD("text", TableDataTypes.STRING()),
+        ]).not_null()
+        single_field_item_type = TableDataTypes.ROW([
+            TableDataTypes.FIELD("number", TableDataTypes.INT()),
+        ]).not_null()
+        for sql, output, ignore, names, types in [
+            ("SELECT 1 AS id, MAP['a', 1] AS items, 'x' AS label",
+             ["key", "value"], False, ["id", "key", "value", "label"],
+             [TableDataTypes.INT().not_null(), TableDataTypes.CHAR(1),
+              TableDataTypes.INT(), TableDataTypes.CHAR(1).not_null()]),
+            ("SELECT ARRAY[CAST(ROW(1, 'a') AS ROW<number INT, text STRING>)] 
AS items",
+             "item", True, ["item"], [item_type]),
+            ("SELECT ARRAY[CAST(ROW(1) AS ROW<number INT>)] AS items",
+             None, True, ["items"], [single_field_item_type]),
+            ("SELECT MULTISET[1, 1, 2] AS items", None, False, ["items"],
+             [TableDataTypes.INT()]),
+        ]:
+            with self.subTest(sql=sql):
+                df = pf.from_table(self.t_env.sql_query(sql))
+                self.assert_dataframe_schema(
+                    df.explode(
+                        "items",
+                        output_column=output,
+                        ignore_empty_and_null=ignore,
+                    ),
+                    names,
+                    types,
+                )
+
+    def test_explode_rejects_invalid_arguments(self):
+        for column in [None, 1, ["items"]]:
+            with self.subTest(column=column):
+                with self.assertRaisesRegex(TypeError, "column"):
+                    self.df.explode(column)
+        for flag in [None, 1, "true"]:
+            with self.subTest(flag=flag):
+                with self.assertRaisesRegex(TypeError, 
"ignore_empty_and_null"):
+                    self.df.explode("items", ignore_empty_and_null=flag)
+        for output in [1, ("item",), [1]]:
+            with self.subTest(output=output):
+                with self.assertRaisesRegex(TypeError, "output_column"):
+                    self.df.explode("items", output_column=output)
+        with self.assertRaisesRegex(TypeError, "positional argument"):
+            self.df.explode("items", "item")
+
+    def test_explode_rejects_missing_and_non_collection_columns(self):
+        for column in ["missing", pf.col("missing")]:
+            with self.subTest(column=str(column)):
+                with self.assertRaisesRegex(Py4JJavaError, "missing"):
+                    self.df.explode(column)
+        with self.assertRaisesRegex(TypeError, "ARRAY, MAP, or MULTISET"):
+            self.df.explode("id")
+        with self.assertRaisesRegex(ValueError, "single column"):
+            self.df.explode(pf.col("*"))
+
+    def test_explode_rejects_aggregate_expressions(self):
+        df = pf.from_table(self.t_env.sql_query("SELECT ARRAY[9] AS items, 1 
AS id"))
+        with self.assertRaisesRegex(ValueError, "row-wise"):
+            df.explode(pf.col("id").collect, output_column="value")
+
+    def test_explode_rejects_invalid_output_names(self):
+        for output in [[], ["a", "b"], "", [""]]:
+            with self.subTest(output=output):
+                with self.assertRaisesRegex(ValueError, "output_column"):
+                    self.df.explode("items", output_column=output)
+        with self.assertRaisesRegex(ValueError, "conflict"):
+            self.df.explode("items", output_column="id")
+        df = pf.from_table(self.t_env.sql_query("SELECT MAP['a', 1] AS items"))
+        for output in [None, "item", ["item"], ["item", "item"]]:
+            with self.subTest(output=output):
+                with self.assertRaisesRegex(ValueError, "output_column"):
+                    df.explode("items", output_column=output)
+        df = pf.from_table(self.t_env.sql_query(
+            "SELECT ARRAY[CAST(ROW(1, 'a') AS ROW<number INT, text STRING>)] 
AS items"))
+        with self.assertRaisesRegex(ValueError, "output_column"):
+            df.explode("items", output_column=["number", "text"])
+
+
 class DataFrameSortingTests(PyFlinkDataFrameUTTestCase):
     def setUp(self):
         super().setUp()
@@ -2686,6 +2815,97 @@ class 
DataFrameSetOperationStreamITTests(PyFlinkStreamDataFrameTestCase):
         )
 
 
+class DataFrameExplodeITTests(PyFlinkITTestCase):
+    def setUp(self):
+        self.t_env = 
TableEnvironment.create(EnvironmentSettings.in_batch_mode())
+
+    def test_explode_direct_column_with_both_empty_collection_modes(self):
+        df = pf.from_table(self.t_env.from_elements(
+            [(1, [2, 2, None], "a"), (2, [], "b"), (3, None, "c")],
+            TableDataTypes.ROW([
+                TableDataTypes.FIELD("id", TableDataTypes.INT()),
+                TableDataTypes.FIELD("items", 
TableDataTypes.ARRAY(TableDataTypes.INT())),
+                TableDataTypes.FIELD("label", TableDataTypes.STRING()),
+            ])))
+        for ignore in [False, True]:
+            with self.subTest(ignore=ignore):
+                expected = [Row(1, 2, "a"), Row(1, 2, "a"), Row(1, None, "a")]
+                if not ignore:
+                    expected += [Row(2, None, "b"), Row(3, None, "c")]
+                self.assertCountEqual(
+                    df.explode("items", 
ignore_empty_and_null=ignore).collect(), expected)
+
+    def test_explode_preserves_row_elements_and_distinguishes_outer_null(self):
+        df = pf.from_table(self.t_env.from_elements(
+            [(1, [Row(2, "a"), Row(3, "b")], "x")],
+            TableDataTypes.ROW([
+                TableDataTypes.FIELD("id", TableDataTypes.INT()),
+                TableDataTypes.FIELD("items", 
TableDataTypes.ARRAY(TableDataTypes.ROW([
+                    TableDataTypes.FIELD("number", TableDataTypes.INT()),
+                    TableDataTypes.FIELD("text", TableDataTypes.STRING()),
+                ]))),
+                TableDataTypes.FIELD("label", TableDataTypes.STRING()),
+            ])))
+        exploded = df.explode("items", ignore_empty_and_null=True)
+        self.assertEqual(exploded.columns, ["id", "items", "label"])
+        flattened = exploded.select("id", pf.col("items").flatten, "label")
+        self.assertEqual(
+            flattened.columns, ["id", "items$number", "items$text", "label"])
+        self.assertCountEqual(
+            flattened.collect(),
+            [
+                Row(1, 2, "a", "x"),
+                Row(1, 3, "b", "x"),
+            ],
+        )
+
+        multiset_df = pf.from_table(self.t_env.sql_query(
+            "SELECT id, COLLECT(item) AS items, MAX(label) AS label FROM "
+            "(VALUES "
+            "(1, CAST(ROW(2, 3) AS ROW<number INT, code INT>), 9), "
+            "(1, CAST(ROW(2, 3) AS ROW<number INT, code INT>), 9), "
+            "(1, CAST(ROW(CAST(NULL AS INT), CAST(NULL AS INT)) "
+            "AS ROW<number INT, code INT>), 9)) AS T(id, item, label) GROUP BY 
id "
+            "UNION ALL SELECT 3, "
+            "CAST(NULL AS ROW<number INT, code INT> MULTISET), 8"))
+        distinguished = multiset_df.explode("items").select(
+            "id",
+            pf.col("items").is_null.alias("item_is_null"),
+            pf.col("items").get("number").alias("number"),
+            pf.col("items").get("code").alias("code"),
+            "label",
+        )
+        self.assertCountEqual(
+            distinguished.collect(),
+            [
+                Row(1, False, 2, 3, 9),
+                Row(1, False, 2, 3, 9),
+                Row(1, False, None, None, 9),
+                Row(3, True, None, None, 8),
+            ],
+        )
+
+    def test_explode_computed_expression_appends_output(self):
+        from pyflink.table.expressions import array
+
+        df = pf.from_table(self.t_env.sql_query("SELECT 1 AS __pf_explode"))
+        result = df.explode(
+            array(pf.col("__pf_explode"), pf.lit(2)).alias("pair"), 
output_column="value")
+        self.assertEqual(result.columns, ["__pf_explode", "value"])
+        self.assertCountEqual(result.collect(), [Row(1, 1), Row(1, 2)])
+
+
+class DataFrameExplodeStreamITTests(PyFlinkStreamDataFrameTestCase):
+    def test_explode_in_streaming_mode(self):
+        df = pf.from_table(self.t_env.from_elements(
+            [(1, [2, 3])],
+            TableDataTypes.ROW([
+                TableDataTypes.FIELD("id", TableDataTypes.INT()),
+                TableDataTypes.FIELD("items", 
TableDataTypes.ARRAY(TableDataTypes.INT())),
+            ])))
+        self.assertCountEqual(df.explode("items").collect(), [Row(1, 2), 
Row(1, 3)])
+
+
 class DataFrameWindowITTests(PyFlinkStreamDataFrameTestCase):
     @classmethod
     def setUpClass(cls):

Reply via email to