dianfu commented on code in PR #29196:
URL: https://github.com/apache/flink/pull/29196#discussion_r4055963018


##########
flink-python/pyflink/dataframe/dataframe.py:
##########
@@ -622,6 +623,104 @@ def top_n(
     distinct = drop_duplicates
     unique = drop_duplicates
 
+    @PublicEvolving()
+    def explode(
+        self,

Review Comment:
   Since this method accepts only one input column, the remaining parameters 
are configuration options, what about make the other parameters keyword-only:
   
   ```
   def explode(
       self,
       column,
       *,
       output_column=None,
       ignore_empty_and_null=False,
   )
   ```
   
   This avoids ambiguous positional calls and leaves room for future options 
without breaking callers.



##########
flink-python/pyflink/dataframe/dataframe.py:
##########
@@ -622,6 +623,104 @@ def top_n(
     distinct = drop_duplicates
     unique = drop_duplicates
 
+    @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 removed. Other input columns are 
retained, followed by
+        the expanded fields. For a computed collection expression, all input 
columns are retained.
+        MAP values yield key and value fields; ROW elements yield one field 
per ROW field.
+        Empty and null collections produce a row with null output fields unless
+        ``ignore_empty_and_null`` is true.
+
+        :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.
+
+        Example::
+
+            >>> import pyflink.dataframe as pf
+            >>> df = pf.from_dict({"id": [1, 2], "tags": [["a", "b"], []]})
+            >>> result = df.explode("tags")
+            >>> result = df.explode("tags", "tag", ignore_empty_and_null=True)
+
+        .. 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]
+        if isinstance(data_type, MapType):
+            field_count = 2
+        elif isinstance(data_type, (ArrayType, MultisetType)):
+            element_type = data_type.element_type
+            field_count = len(element_type.fields) if isinstance(element_type, 
RowType) else 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":
+            collection_name = columns.pop(resolved.getFieldIndex())
+        else:
+            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")
+
+        projections = ["src." + _quote_identifier(name) for name in columns]
+        projections.extend("expanded." + _quote_identifier(name) for name in 
output_names)

Review Comment:
   The current implementation always appends expanded fields. What about 
replacing a directly referenced column in place to follow the behavior of 
engines such as pandas, Polars, etc.
   
   For an input schema `(id, items, label)`:
   
   - `explode("items", output_column="item")` should produce `(id, item, 
label)`, not `(id, label, item)`.
   - If `items` is a MAP or `ARRAY<ROW>` with outputs `["key", "value"]`, the 
result should be `(id, key, value, label)`.
   - For a computed expression such as `explode(array(col("id"), lit(0)), 
output_column="item")`, there is no original column position to replace, so 
retaining all input columns and appending the result as `(id, items, label, 
item)` is reasonable.



##########
flink-python/pyflink/dataframe/tests/test_dataframe.py:
##########
@@ -2543,6 +2640,172 @@ def test_union_all_retains_duplicates(self):
         )
 
 
+class DataFrameExplodeITTests(PyFlinkITTestCase):

Review Comment:
   This adds 12 IT methods and, with the parameter loops, roughly 26 
`collect()` executions. Since this API delegates execution to the existing SQL 
UNNEST implementation, the ARRAY/MAP/MULTISET/ROW × batch/streaming × flag 
matrix largely duplicates the existing UnnestITCase coverage. Note that ITCase 
is expensive and should be avoid as much as possible.
   
   Could we keep the DataFrame IT coverage focused on wrapper-specific 
behavior: one batch test for the two `ignore_empty_and_null` modes, one 
middle-position multi-field output, computed/direct-expression handling, one 
streaming smoke test? Type resolution and validation can remain in the unit 
tests.
   
   Reducing the number of `collect()` calls, rather than only combining test 
methods, should substantially lower the IT cost.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to