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 20172254a50 [FLINK-40433][python] Introduce map and map_batches in
DataFrame API
20172254a50 is described below
commit 20172254a5006dcdcc449808778be260bfacc00d
Author: auroflow <[email protected]>
AuthorDate: Mon Oct 12 19:27:53 2026 +0800
[FLINK-40433][python] Introduce map and map_batches in DataFrame API
This closes #29469.
---
.../docs/reference/pyflink.dataframe/dataframe.rst | 2 +
flink-python/pyflink/dataframe/dataframe.py | 148 +++
flink-python/pyflink/dataframe/tests/test_udf.py | 1015 +++++++++++++++++++-
flink-python/pyflink/dataframe/tests/test_udtf.py | 12 +
flink-python/pyflink/dataframe/udf.py | 371 ++++++-
.../pyflink/fn_execution/coder_impl_fast.pxd | 1 +
.../pyflink/fn_execution/coder_impl_fast.pyx | 10 +-
.../pyflink/fn_execution/coder_impl_slow.py | 10 +-
flink-python/pyflink/fn_execution/coders.py | 19 +-
.../pyflink/fn_execution/flink_fn_execution_pb2.py | 22 +-
.../fn_execution/flink_fn_execution_pb2.pyi | 6 +-
.../pyflink/fn_execution/tests/test_coders.py | 113 ++-
.../pyflink/proto/flink-fn-execution.proto | 2 +
flink-python/pyflink/table/table.py | 9 +-
.../pyflink/table/tests/test_pandas_udf.py | 38 +-
.../table/tests/test_row_based_operation.py | 24 +-
flink-python/pyflink/table/udf.py | 3 +
flink-python/pyflink/table/utils.py | 40 +-
.../org/apache/flink/python/util/ProtoUtils.java | 11 +
.../AbstractPythonScalarFunctionOperator.java | 14 +
.../arrow/ArrowPythonScalarFunctionOperator.java | 6 +-
.../operations/utils/OperationTreeBuilder.java | 3 +-
.../python/PythonAsyncScalarFunction.java | 71 ++
.../table/functions/python/PythonFunction.java | 5 +
.../functions/python/PythonScalarFunction.java | 71 ++
.../plan/rules/logical/PythonMapMergeRule.java | 8 +
.../PythonCalcSplitFunctionKindRuleTest.java | 37 +
.../PythonCalcSplitFunctionKindRuleTest.xml | 42 +
28 files changed, 2040 insertions(+), 73 deletions(-)
diff --git a/flink-python/docs/reference/pyflink.dataframe/dataframe.rst
b/flink-python/docs/reference/pyflink.dataframe/dataframe.rst
index d03d62b27e6..1fc52491ec5 100644
--- a/flink-python/docs/reference/pyflink.dataframe/dataframe.rst
+++ b/flink-python/docs/reference/pyflink.dataframe/dataframe.rst
@@ -73,6 +73,8 @@ Transformations
DataFrame.rename_columns
DataFrame.rename
DataFrame.filter
+ DataFrame.map
+ DataFrame.map_batches
DataFrame.where
DataFrame.explode
DataFrame.drop_duplicates
diff --git a/flink-python/pyflink/dataframe/dataframe.py
b/flink-python/pyflink/dataframe/dataframe.py
index e81bfee1d95..1d3eedd0973 100644
--- a/flink-python/pyflink/dataframe/dataframe.py
+++ b/flink-python/pyflink/dataframe/dataframe.py
@@ -184,6 +184,154 @@ class DataFrame:
# ======================== Core Operations ========================
+ @PublicEvolving()
+ def map(self, func, *, return_dtype=None) -> "DataFrame":
+ """
+ Apply a function to each row, producing a new DataFrame.
+
+ Pass a Python function, a callable class or instance, a
``ScalarFunction`` or
+ ``AsyncScalarFunction`` class or instance, or a UDF created with
+ :func:`pyflink.dataframe.udf`. The function (or its ``__call__`` or
``eval`` method)
+ receives a dictionary whose keys are input column names and whose
values are the
+ values in that row. It must return one dictionary whose keys name the
output columns
+ and whose values form the output row. The output replaces the input
columns.
+
+ Specify the output schema with ``return_dtype``, or omit it when the
callable has a
+ ``TypedDict`` return annotation. For an already declared UDF, its
schema is reused
+ and ``return_dtype`` must be omitted. Output columns follow the
schema's order;
+ extra dictionary keys are ignored, and missing output fields raise an
error.
+
+ :param func: A callable or a DataFrame UDF declared with
``func_type="general"``.
+ See :func:`pyflink.dataframe.udf` for callable lifecycle
behavior.
+ :param return_dtype: Output schema as a struct DataType, TypedDict
type, or SQL ROW
+ type string. May be inferred from a callable's
return annotation.
+ :return: A new DataFrame with one output row per input row.
+
+ Examples:
+
+ A Python function with an explicit output schema::
+
+ >>> import pyflink.dataframe as pf
+ >>> df = pf.from_records([(1, "alice")], schema=["id", "name"])
+ >>> output_type = pf.DataType.struct({"label":
pf.DataType.string()})
+ >>> result = df.map(
+ ... lambda row: {"label": row["name"].upper()},
+ ... return_dtype=output_type,
+ ... )
+
+ Infer the output schema from a TypedDict return annotation::
+
+ >>> from typing import TypedDict
+ >>> class Output(TypedDict):
+ ... label: str
+ >>> def uppercase(row) -> Output:
+ ... return {"label": row["name"].upper()}
+ >>> result = df.map(uppercase)
+
+ Pass a callable class, or configure an instance before passing it::
+
+ >>> class PrefixName:
+ ... def __init__(self, prefix="user_"):
+ ... self.prefix = prefix
+ ... def __call__(self, row) -> Output:
+ ... return {"label": self.prefix + row["name"]}
+ >>> result = df.map(PrefixName)
+ >>> result = df.map(PrefixName("member_"))
+
+ Use a ScalarFunction's eval method::
+
+ >>> from pyflink.table.udf import ScalarFunction
+ >>> class UppercaseName(ScalarFunction):
+ ... def eval(self, row) -> Output:
+ ... return {"label": row["name"].upper()}
+ >>> result = df.map(UppercaseName())
+
+ Reuse a declared DataFrame UDF without repeating its output schema::
+
+ >>> declared_uppercase = pf.udf(uppercase)
+ >>> result = df.map(declared_uppercase)
+
+ .. versionadded:: 2.4.0
+ """
+ from pyflink.dataframe.udf import _resolve_map_udf
+
+ function = _resolve_map_udf(func, return_dtype, "general",
self.schema.to_row_data_type())
+ return DataFrame(self._table.map(function(*[table_col(name) for name
in self.columns])))
+
+ @PublicEvolving()
+ def map_batches(self, func, *, return_dtype=None, batch_format: str) ->
"DataFrame":
+ """
+ Apply a function to batches of rows, producing a new DataFrame.
+
+ Flink automatically groups input rows into batches and calls ``func``
once per batch.
+ Pass a Python function, a callable class or instance, a
``ScalarFunction`` class or
+ instance, or a UDF created with :func:`pyflink.dataframe.udf`. The
callable receives
+ and returns a dictionary keyed by column name: each non-ROW value is a
``pandas.Series``
+ when ``batch_format="pandas"``, or a ``pyarrow.Array`` when
``batch_format="arrow"``.
+ Input values contain the batched column values; returned values form
the output
+ columns, replacing the input columns.
+
+ Each output row must correspond to the input row at the same position,
preserving
+ the number and order of rows. Specify
+ the output struct schema with ``return_dtype``; for a declared UDF,
omit it to reuse
+ that UDF's schema. Output columns follow the schema's order; extra
dictionary keys
+ are ignored, and missing output fields raise an error. Pandas output
columns align
+ by index.
+
+ .. note::
+
+ For ROW input and output columns, use ``pandas.DataFrame`` in
pandas mode
+ and ``pyarrow.StructArray`` in Arrow mode.
+
+ :param func: A callable or a DataFrame UDF declared for the selected
batch format.
+ :param return_dtype: Output struct schema. Required for raw callables
in both formats;
+ must be omitted for an already declared UDF.
+ :param batch_format: ``"pandas"`` or ``"arrow"``. Required even for
declared UDFs.
+ :return: A new DataFrame with the same number of rows as the input.
+
+ Examples:
+
+ Transform pandas batches with a Python function::
+
+ >>> import pandas as pd
+ >>> import pyflink.dataframe as pf
+ >>> df = pf.from_records([(1,), (2,), (3,)], schema=["value"])
+ >>> output_type = pf.DataType.struct({"doubled":
pf.DataType.int64()})
+ >>> def double_pandas(batch: dict[str, pd.Series]) -> dict[str,
pd.Series]:
+ ... return {"doubled": batch["value"] * 2}
+ >>> result = df.map_batches(
+ ... double_pandas, batch_format="pandas",
return_dtype=output_type,
+ ... )
+
+ Use a callable class to transform Arrow batches::
+
+ >>> import pyarrow as pa
+ >>> import pyarrow.compute as pc
+ >>> class DoubleArrow:
+ ... def __call__(self, batch: dict[str, pa.Array]) ->
dict[str, pa.Array]:
+ ... return {"doubled": pc.multiply(batch["value"], 2)}
+ >>> result = df.map_batches(
+ ... DoubleArrow, batch_format="arrow",
return_dtype=output_type,
+ ... )
+
+ Reuse a declared UDF, specifying its batch format at the call site::
+
+ >>> declared_double = pf.udf(
+ ... double_pandas, return_dtype=output_type,
func_type="pandas",
+ ... )
+ >>> result = df.map_batches(declared_double, batch_format="pandas")
+
+ .. versionadded:: 2.4.0
+ """
+ from pyflink.dataframe.udf import _resolve_map_udf
+
+ if batch_format not in ("pandas", "arrow"):
+ raise ValueError("batch_format must be 'pandas' or 'arrow'.")
+ function = _resolve_map_udf(
+ func, return_dtype, batch_format, self.schema.to_row_data_type()
+ )
+ return DataFrame(self._table.map(function(*[table_col(name) for name
in self.columns])))
+
@PublicEvolving()
def filter(
self,
diff --git a/flink-python/pyflink/dataframe/tests/test_udf.py
b/flink-python/pyflink/dataframe/tests/test_udf.py
index 66fe1334bf0..a8b2e0e1a9c 100644
--- a/flink-python/pyflink/dataframe/tests/test_udf.py
+++ b/flink-python/pyflink/dataframe/tests/test_udf.py
@@ -33,7 +33,7 @@ import pyflink.dataframe as pf
from pyflink.common import Row, RowKind
from pyflink.table import DataTypes as TableDataTypes
from pyflink.table.expression import Expression
-from pyflink.table.types import RowType
+from pyflink.table.types import MapType, RowType
from pyflink.table.udf import AsyncScalarFunction, ScalarFunction,
TableFunction
from pyflink.testing.test_case_utils import (
PyFlinkDataFrameUTTestCase,
@@ -934,11 +934,8 @@ class DataFrameUDFDeclarationTests(unittest.TestCase):
False,
),
("class default", lambda: pf.udf(DefaultDeterministic), True),
- (
- "class matching metadata",
- lambda: pf.udf(NonDeterministic, deterministic=False),
- False,
- ),
+ ("matching class metadata", lambda: pf.udf(NonDeterministic,
deterministic=False),
+ False),
]
for case_name, declare, expected in declarations:
with self.subTest(case=case_name):
@@ -950,12 +947,40 @@ class DataFrameUDFDeclarationTests(unittest.TestCase):
)
with self.assertRaisesRegex(ValueError, "Inconsistent deterministic"):
pf.udf(instance)
- self.assertTrue(pf.udf(NonDeterministic)._deterministic)
-
named = pf.udf(instance, deterministic=False, name="identity")
self.assertEqual(named.__name__, "identity")
self.assertEqual(named._table_udf_wrapper._name, "identity")
+ def test_scalar_function_classes_defer_custom_determinism_validation(self):
+ for base in (ScalarFunction, AsyncScalarFunction):
+ for deterministic in (True, False):
+ constructed = []
+
+ class CustomDeterminism(base):
+ def __init__(self):
+ constructed.append(True)
+
+ def is_deterministic(self):
+ return deterministic
+
+ if base is AsyncScalarFunction:
+ async def eval(self, value):
+ return value
+ else:
+ def eval(self, value):
+ return value
+
+ class InheritedDeterminism(CustomDeterminism):
+ pass
+
+ for source in (CustomDeterminism, InheritedDeterminism):
+ with self.subTest(base=base, deterministic=deterministic,
source=source):
+ pf.udf(source, return_dtype=int,
deterministic=deterministic)
+ self.assertEqual(constructed, [])
+ declaration = pf.udf(
+ CustomDeterminism(), return_dtype=int,
deterministic=deterministic)
+ self.assertEqual(declaration._deterministic, deterministic)
+
def test_general_structured_results_are_normalized_recursively(self):
from pyflink.dataframe.udf import _create_result_normalizer
@@ -1296,6 +1321,634 @@ class DataFrameUDFDeclarationTests(unittest.TestCase):
class DataFrameUDFAdapterTests(unittest.TestCase):
+ def pandas_map_output(self, adapter, dtype, *inputs):
+ from pyflink.table.utils import create_pandas_table_row_schema,
pandas_table_row_to_arrow
+ import pytz
+
+ row_type = dtype._to_table_data_type()
+ schema = create_pandas_table_row_schema(TableDataTypes.ROW([
+ TableDataTypes.FIELD("result", row_type)]))
+ return pandas_table_row_to_arrow(
+ schema, pytz.UTC, row_type,
adapter.eval(*inputs)).column(0).to_pylist()
+
+ def test_pandas_map_batches_returns_flattened_dataframe(self):
+ from pyflink.dataframe.udf import _resolve_map_udf
+
+ dtype = pf.DataType.struct({
+ "v": pf.DataType.int64(),
+ "payload": pf.DataType.struct({"v": pf.DataType.int64(),
+ "tail": pf.DataType.int64()}),
+ "other": pf.DataType.struct({"v": pf.DataType.string()}),
+ "tail": pf.DataType.int64(),
+ })
+ input_type = TableDataTypes.ROW([
+ TableDataTypes.FIELD("tag", TableDataTypes.STRING()),
+ TableDataTypes.FIELD("id", TableDataTypes.BIGINT())])
+
+ def transform(batch):
+ self.assertEqual(batch["tag"].tolist(), ["x"] * len(batch["id"]))
+ return {"tail": batch["id"] + 10,
+ "other": pd.DataFrame({"v": ["a", None]},
index=batch["id"].index),
+ "payload": pd.DataFrame({
+ "v": pd.Series([9007199254740993, None],
+ index=batch["id"].index, dtype=object),
+ "tail": batch["id"] + 20}),
+ "v": batch["id"].sort_index(ascending=False)}
+
+ adapter = _resolve_map_udf(transform, dtype, "pandas",
input_type)._func
+ adapter.open(object())
+ try:
+ result = adapter.eval("x", pd.Series([1, 2]))
+ self.assertIsInstance(result, pd.DataFrame)
+ pd.testing.assert_frame_equal(result, pd.concat([
+ pd.Series([1, 2], name="v"),
+ pd.Series([9007199254740993, None], name="v", dtype=object),
+ pd.Series([21, 22], name="tail"),
+ pd.Series(["a", None], name="v"),
+ pd.Series([11, 12], name="tail"),
+ ], axis=1))
+ self.assertEqual(self.pandas_map_output(adapter, dtype, "x",
pd.Series([1, 2])), [
+ {"v": 1, "payload": {"v": 9007199254740993, "tail": 21},
+ "other": {"v": "a"}, "tail": 11},
+ {"v": 2, "payload": {"v": None, "tail": 22},
+ "other": {"v": None}, "tail": 12},
+ ])
+ finally:
+ adapter.close()
+
+ def test_map_bindings_leave_other_uses_of_a_declaration_unchanged(self):
+ from pyflink.dataframe.udf import _resolve_map_udf
+
+ @pf.udf(return_dtype=pf.DataType.struct({"total":
pf.DataType.int64()}))
+ def total(value):
+ return {"total": sum(value.values()) if isinstance(value, dict)
else value}
+
+ expression = total._table_udf_wrapper._func
+ maps = [_resolve_map_udf(total, None, "general", TableDataTypes.ROW([
+ TableDataTypes.FIELD(name, TableDataTypes.BIGINT()) for name in
names
+ ]))._func for names in (["left", "right"], ["value"])]
+ for adapter in [expression] + maps:
+ adapter.open(object())
+ self.addCleanup(adapter.close)
+ self.assertEqual(maps[0].eval(1, 2), Row(total=3))
+ self.assertEqual(maps[1].eval(4), Row(total=4))
+ self.assertEqual(maps[0].eval(5, 6), Row(total=11))
+ self.assertEqual(expression.eval(7), Row(total=7))
+
+ def test_map_requires_a_dictionary_result(self):
+ from pyflink.dataframe.udf import _resolve_map_udf
+
+ dtype = pf.DataType.struct({"value": pf.DataType.int64()})
+ for result in (None, (1,), 1):
+ with self.subTest(result=result):
+ adapter = _resolve_map_udf(
+ lambda row: result, dtype, "general",
dtype._to_table_data_type())._func
+ adapter.open(object())
+ try:
+ with self.assertRaisesRegex(TypeError, "dictionary"):
+ adapter.eval(1)
+ finally:
+ adapter.close()
+
+ def
test_pandas_map_batches_preserves_row_columns_for_transported_and_fused_inputs(self):
+ from pyflink.dataframe.udf import _resolve_map_udf
+ from pyflink.table.types import create_arrow_schema
+ from pyflink.table.utils import arrow_to_pandas
+ import pytz
+
+ input_type = TableDataTypes.ROW([
+ TableDataTypes.FIELD("id", TableDataTypes.BIGINT()),
+ TableDataTypes.FIELD("payload", TableDataTypes.ROW([
+ TableDataTypes.FIELD("left", TableDataTypes.BIGINT()),
+ TableDataTypes.FIELD("right", TableDataTypes.BIGINT())])),
+ TableDataTypes.FIELD("tail", TableDataTypes.BIGINT()),
+ ])
+ dtype = pf.DataType.struct({"score": pf.DataType.int64()})
+
+ def transform(batch):
+ self.assertEqual(list(batch), ["id", "payload", "tail"])
+ payload = batch["payload"]
+ self.assertIsInstance(payload, pd.DataFrame)
+ self.assertEqual(list(payload), ["left", "right"])
+ pd.testing.assert_frame_equal(payload, pd.DataFrame(
+ {"left": [2, None, 4], "right": [3, None, None]},
+ index=payload.index))
+ return {"score": batch["tail"] + batch["id"]}
+
+ adapter = _resolve_map_udf(transform, dtype, "pandas",
input_type)._func
+ adapter.open(object())
+ try:
+ grouped = pd.DataFrame({
+ "id": [1, 2, 3], "tail": [10, 20, 30], "payload": [
+ {"left": 2, "right": 3}, None,
+ {"left": 4, "right": None},
+ ],
+ })[["id", "payload", "tail"]]
+ schema = create_arrow_schema(input_type.field_names(),
input_type.field_types())
+ batch = pa.RecordBatch.from_pandas(grouped, schema=schema)
+ transported = arrow_to_pandas(pytz.UTC, input_type.field_types(),
[batch])
+ self.assertEqual(self.pandas_map_output(adapter, dtype,
*transported),
+ [{"score": 11}, {"score": 22}, {"score": 33}])
+ for names in (["unrelated1", "unrelated2"], ["right", "left"]):
+ fused_payload = transported[1].set_axis(names, axis=1)
+ self.assertEqual(self.pandas_map_output(
+ adapter, dtype, transported[0], fused_payload,
transported[2]),
+ [{"score": 11}, {"score": 22}, {"score": 33}])
+ self.assertEqual(list(fused_payload), names)
+ grouped.index = [2, 0, 1]
+ with self.assertRaisesRegex(TypeError, "ROW.*pandas.DataFrame"):
+ adapter.eval(*(grouped[name] for name in
input_type.field_names()))
+ fused = [column.set_axis(grouped.index, axis=0) for column in
transported]
+ self.assertEqual(self.pandas_map_output(adapter, dtype, *fused),
+ [{"score": 11}, {"score": 22}, {"score": 33}])
+ finally:
+ adapter.close()
+
+ def
test_pandas_map_batches_preserves_nested_collections_with_repeated_names(self):
+ import cloudpickle
+ from pyflink.dataframe.udf import _resolve_map_udf
+ from pyflink.fn_execution import flink_fn_execution_pb2 as proto
+ from pyflink.fn_execution.table.operations import
ScalarFunctionOperation
+ from pyflink.table.udf import DelegatingScalarFunction
+ from pyflink.table.utils import arrow_to_pandas
+ import pytz
+
+ row_type = TableDataTypes.ROW([TableDataTypes.FIELD(
+ "payload", TableDataTypes.MAP(TableDataTypes.STRING(),
TableDataTypes.BIGINT()))])
+ input_type = TableDataTypes.ROW([TableDataTypes.FIELD("payload",
row_type)])
+ dtype = pf.DataType.struct({"size": pf.DataType.int64()})
+
+ def transform(batch):
+ self.assertEqual(batch["payload"]["payload"].tolist(), [[("x", 1),
("y", 2)], []])
+ return {"size": batch["payload"]["payload"].map(len)}
+
+ batch = pa.record_batch([pa.array([
+ {"payload": [("x", 1), ("y", 2)]}, {"payload": []},
+ ], type=pa.struct([("payload", pa.map_(pa.string(), pa.int64()))]))],
names=["payload"])
+ columns = arrow_to_pandas(pytz.UTC, [row_type], [batch])
+ adapter = _resolve_map_udf(transform, dtype, "pandas",
input_type)._func
+ adapter.open(object())
+ try:
+ self.assertEqual(self.pandas_map_output(adapter, dtype,
columns[0]),
+ [{"size": 2}, {"size": 0}])
+ nested_dtype = pf.DataType.struct({"payload": pf.DataType.struct({
+ "payload": pf.DataType.map(pf.DataType.string(),
pf.DataType.int64())})})
+ producer = _resolve_map_udf(
+ lambda batch: {"payload": batch["payload"]},
+ nested_dtype, "pandas", input_type)._func
+ producer_proto = proto.UserDefinedFunction(
+ payload=cloudpickle.dumps(producer), is_pandas_udf=True,
+ inputs=[proto.Input(inputOffset=0)])
+ producer.open(object())
+ try:
+ self.assertEqual(self.pandas_map_output(producer,
nested_dtype, columns[0]),
+ [{"payload": {"payload": [("x", 1), ("y",
2)]}},
+ {"payload": {"payload": []}}])
+ finally:
+ producer.close()
+ for values, expected in ((columns, [2, 0]),
+ ([column.head(0) for column in columns],
[])):
+ for shared in (False, True):
+ with self.subTest(empty_batch=not expected, shared=shared):
+ consumer = proto.UserDefinedFunction(
+ payload=cloudpickle.dumps(DelegatingScalarFunction(
+ lambda frame: frame["payload"].map(len))),
+ is_pandas_udf=True, takes_row_as_input=True,
+ inputs=[proto.Input(refIndex=0)] if shared
+ else [proto.Input(udf=producer_proto)])
+ operation =
ScalarFunctionOperation(proto.UserDefinedFunctions(
+ udfs=[producer_proto, consumer] if shared else
[consumer],
+ output_indices=[1] if shared else []))
+ operation.open()
+ try:
+
self.assertEqual(operation.process_element(values)[0].tolist(),
+ expected)
+ finally:
+ operation.close()
+ finally:
+ adapter.close()
+
+ def test_pandas_map_batches_preserves_map_inputs(self):
+ from pyflink.dataframe.udf import _resolve_map_udf
+ from pyflink.table.types import create_arrow_schema
+ from pyflink.table.utils import arrow_to_pandas, pandas_to_arrow
+ import pytz
+
+ lookup = TableDataTypes.MAP(TableDataTypes.STRING(),
TableDataTypes.BIGINT())
+ payload = TableDataTypes.ROW([
+ TableDataTypes.FIELD("lookup", lookup),
+ ])
+ input_type = TableDataTypes.ROW([
+ TableDataTypes.FIELD("lookup", lookup),
TableDataTypes.FIELD("payload", payload)])
+ dtype = pf.DataType.struct({"total": pf.DataType.int64()})
+
+ def transform(batch):
+ self.assertIs(batch["lookup"], inputs[0])
+ self.assertIs(batch["payload"], inputs[1])
+ return {"total": batch["lookup"].map(
+ lambda value: 0 if value is None else sum(value.values())
+ if isinstance(value, dict) else sum(item for _, item in
value))}
+
+ columns = [pd.Series([{"top": 1}, {}, None], dtype=object),
pd.DataFrame({
+ "lookup": [{"field": 2}, [("same", 3), ("same", 4)], None],
+ })]
+ schema = create_arrow_schema(input_type.field_names(),
input_type.field_types())
+ transported = arrow_to_pandas(pytz.UTC, input_type.field_types(),
[pandas_to_arrow(
+ schema, pytz.UTC, input_type.field_types(), columns)])
+ adapter = _resolve_map_udf(transform, dtype, "pandas",
input_type)._func
+ adapter.open(object())
+ try:
+ for inputs in (columns, transported):
+ self.assertEqual(self.pandas_map_output(adapter, dtype,
*inputs),
+ [{"total": 1}, {"total": 0}, {"total": 0}])
+ finally:
+ adapter.close()
+
+ def test_arrow_map_batches_preserves_nested_and_chunked_columns(self):
+ from pyflink.dataframe.udf import _resolve_map_udf
+ from pyflink.fn_execution.utils.operation_utils import
check_arrow_udf_result
+
+ dtype = pf.DataType.struct({
+ "id": pf.DataType.int64(),
+ "payload": pf.DataType.struct({"x": pf.DataType.int64()}),
+ })
+ batch = pa.StructArray.from_arrays([
+ pa.array([1, 2]), pa.array([{"x": None}, None],
type=pa.struct([("x", pa.int64())])),
+ pa.array([{"inner": {"v": 1}}, {"inner": {"v": 2}}],
+ type=pa.struct([("inner", pa.struct([("v",
pa.int64())]))])),
+ pa.array([[{"v": 1}], []], type=pa.list_(pa.struct([("v",
pa.int64())])))
+ ], names=["id", "payload", "deep", "items"])
+
+ def transform(columns):
+ self.assertEqual(columns["tag"].to_pylist(), ["x"] *
len(columns["id"]))
+ self.assertIsInstance(columns["payload"], pa.StructArray)
+
self.assertEqual(columns["deep"].field("inner").field("v").to_pylist(),
+ [1, 2][:len(columns["id"])])
+ self.assertEqual(columns["items"].to_pylist(),
+ [[{"v": 1}], []][:len(columns["id"])])
+ return {"payload": columns["payload"], "ignored": pa.array([0, 0]),
+ "id": pa.chunked_array([columns["id"].slice(0, 1),
+ columns["id"].slice(1)])}
+
+ input_type = TableDataTypes.ROW([
+ TableDataTypes.FIELD("tag", TableDataTypes.STRING())
+ ] + dtype._to_table_data_type().fields + [
+ TableDataTypes.FIELD("deep", TableDataTypes.ROW([
+ TableDataTypes.FIELD("inner", TableDataTypes.ROW([
+ TableDataTypes.FIELD("v", TableDataTypes.BIGINT())]))])),
+ TableDataTypes.FIELD("items",
TableDataTypes.ARRAY(TableDataTypes.ROW([
+ TableDataTypes.FIELD("v", TableDataTypes.BIGINT())]))),
+ ])
+ adapter = _resolve_map_udf(transform, dtype, "arrow", input_type)._func
+ adapter.open(object())
+ try:
+ result = check_arrow_udf_result(adapter.eval, "x",
*batch.flatten())
+ self.assertEqual(result.to_pylist(),
+ [{"id": 1, "payload": {"x": None}}, {"id": 2,
"payload": None}])
+ self.assertEqual(adapter.eval("x", *batch.slice(0,
0).flatten()).to_pylist(), [])
+ finally:
+ adapter.close()
+
+ def test_pandas_map_batches_aligns_indexes_and_checks_cardinality(self):
+ from pyflink.dataframe.udf import _resolve_map_udf
+
+ dtype = pf.DataType.struct({"score": pf.DataType.int64(), "id":
pf.DataType.int64()})
+
+ def align(batch):
+ self.assertIsInstance(batch, dict)
+ self.assertIsInstance(batch["id"], pd.Series)
+ return {"score": pd.Series([100, 200],
index=batch["id"].index[::-1]).rename_axis(None),
+ "id": batch["id"],
+ "ignored": pd.Series([0, 0])}
+
+ def wrong_length(batch):
+ return {"id": batch["id"], "score": pd.Series([100, 200],
index=[1, 2])}
+
+ def too_short(batch):
+ return {"id": batch["id"].head(1), "score": batch["id"].head(1)}
+
+ def same_order(batch):
+ return {"score": pd.Series([200, 100],
index=batch["id"].index).rename_axis(None),
+ "id": batch["id"]}
+
+ def partial(batch):
+ return {"score": pd.Series([9007199254740993], index=[0]), "id":
batch["id"]}
+
+ input_type = TableDataTypes.ROW([TableDataTypes.FIELD("id",
TableDataTypes.BIGINT())])
+ for source, index in ((align, pd.RangeIndex(2)), (align, pd.Index([2,
0], name="rows")),
+ (align, pd.to_datetime(["2024-01-03",
"2024-01-01"])),
+ (same_order, pd.Index([2, 0], name="rows")),
+ (partial, pd.RangeIndex(2)),
+ (wrong_length, pd.RangeIndex(2)), (too_short,
pd.RangeIndex(2)),
+ (functools.partial(too_short),
pd.RangeIndex(2))):
+ adapter = _resolve_map_udf(source, dtype, "pandas",
input_type)._func
+ adapter.open(object())
+ try:
+ batch = pd.DataFrame({"id": [10, 20]}, index=index)
+ if source in (wrong_length, too_short, partial) \
+ or isinstance(source, functools.partial):
+ with self.assertRaisesRegex(ValueError, "length"):
+ adapter.eval(batch["id"])
+ else:
+ self.assertEqual(self.pandas_map_output(adapter, dtype,
batch["id"]),
+ [{"score": 200, "id": 10}, {"score": 100,
"id": 20}])
+ finally:
+ adapter.close()
+
+ def
test_pandas_map_batches_requires_row_fields_including_collections(self):
+ from pyflink.dataframe.udf import _resolve_map_udf
+ from pyflink.table.utils import create_pandas_table_row_schema,
pandas_table_row_to_arrow
+ import pytz
+
+ details = pf.DataType.struct({"name": pf.DataType.string()})
+ dtype = pf.DataType.struct({
+ "details": details,
+ "items": pf.DataType.list(
+ pf.DataType.map(pf.DataType.string(), details)),
+ "plain_items": pf.DataType.list(
+ pf.DataType.map(pf.DataType.string(), pf.DataType.int64())),
+ "lookup": pf.DataType.map(pf.DataType.string(), details),
+ })
+ input_type = TableDataTypes.ROW([TableDataTypes.FIELD("id",
TableDataTypes.BIGINT())])
+ valid = {"details": pd.DataFrame({"name": [None]}),
+ "items": [None, {"item key": {"name": "a"}}],
+ "plain_items": [None, {"plain key": 1}, {}],
+ "lookup": {"any key": {"name": None}}, "extra": 2}
+ cases = [(valid, None), ({"items": [], "lookup": {}}, KeyError),
+ (dict(valid, details=pd.DataFrame(index=[0])), ValueError),
+ (dict(valid, details=pd.DataFrame({"name": [None], "extra":
[1]})), ValueError),
+ (dict(valid, items=[{"item key": {}}]), KeyError),
+ (dict(valid, lookup={"any key": {}}), KeyError),
+ (dict(valid, lookup={"any key": types.SimpleNamespace()}),
AttributeError),
+ (dict(valid, lookup={"any key": ()}), ValueError),
+ (dict(valid, lookup={"any key": (None, "extra")}),
ValueError)]
+ for result, error in cases:
+ with self.subTest(result=result):
+ adapter = _resolve_map_udf(
+ lambda batch: {name: value if isinstance(value,
pd.DataFrame)
+ else pd.Series([value]) for name, value in
result.items()},
+ dtype, "pandas", input_type)._func
+ adapter.open(object())
+ try:
+ if result is valid:
+ output = adapter.eval(pd.Series([1]))
+ self.assertEqual(output["items"].iloc[0],
+ [None, [("item key", {"name": "a"})]])
+ self.assertEqual(output["plain_items"].iloc[0],
+ [None, [("plain key", 1)], []])
+ self.assertEqual(valid["items"], [None, {"item key":
{"name": "a"}}])
+ self.assertEqual(valid["plain_items"], [None, {"plain
key": 1}, {}])
+ row_type = dtype._to_table_data_type()
+ schema =
create_pandas_table_row_schema(TableDataTypes.ROW([
+ TableDataTypes.FIELD("result", row_type)]))
+ batch = pandas_table_row_to_arrow(schema, pytz.UTC,
row_type, output)
+ self.assertEqual(batch.to_pylist(), [{"result": {
+ "details": {"name": None},
+ "items": [None, [("item key", {"name": "a"})]],
+ "plain_items": [None, [("plain key", 1)], []],
+ "lookup": [("any key", {"name": None})]}}])
+ else:
+ with self.assertRaises(error):
+ adapter.eval(pd.Series([1]))
+ finally:
+ adapter.close()
+
+ def test_pandas_map_batches_preserves_nullable_structured_columns(self):
+ import tracemalloc
+ from pyflink.dataframe.udf import _create_pandas_result_normalizer,
_resolve_map_udf
+
+ row_type = TableDataTypes.ROW([TableDataTypes.FIELD("v",
TableDataTypes.BIGINT())])
+ for data_type, payload in (
+ (TableDataTypes.ARRAY(row_type), [{"v": i} for i in range(10000)]),
+ (TableDataTypes.MAP(TableDataTypes.STRING(), row_type),
+ [(str(i), {"v": i}) for i in range(10000)]),
+ ):
+ normalize = _create_pandas_result_normalizer(data_type)
+ normalize(payload)
+ tracemalloc.start()
+ try:
+ normalized = normalize(payload)
+ peak = tracemalloc.get_traced_memory()[1]
+ finally:
+ tracemalloc.stop()
+ self.assertIs(normalized, payload)
+ self.assertLess(peak, 16384)
+ original = Row(v=1)
+ if isinstance(data_type, MapType):
+ source = [("same", payload[0][1]), ("changed", original)]
+ converted = normalize(source)
+ self.assertIs(converted[0][1], source[0][1])
+ self.assertEqual(converted[1], ("changed", {"v": 1}))
+ self.assertIs(source[1][1], original)
+ else:
+ source = [payload[0], original]
+ converted = normalize(source)
+ self.assertIs(converted[0], source[0])
+ self.assertEqual(converted[1], {"v": 1})
+ self.assertIs(source[1], original)
+
+ dtype = pf.DataType.struct({
+ "details": pf.DataType.struct({"name": pf.DataType.string()}),
+ "items": pf.DataType.list(pf.DataType.int64()),
+ "lookup": pf.DataType.map(pf.DataType.string(),
pf.DataType.int64()),
+ })
+ input_type = TableDataTypes.ROW([TableDataTypes.FIELD("id",
TableDataTypes.BIGINT())])
+
+ class RowFrame(pd.DataFrame):
+ pass
+
+ for frame_type in (pd.DataFrame, RowFrame):
+ with self.subTest(frame_type=frame_type):
+ adapter = _resolve_map_udf(
+ lambda batch: {"details": frame_type({"name": [None,
float("nan"), pd.NA]}),
+ "items": pd.Series([None, float("nan"),
pd.NA]),
+ "lookup": pd.Series([None, float("nan"),
pd.NA])},
+ dtype, "pandas", input_type)._func
+ adapter.open(object())
+ try:
+ self.assertEqual(self.pandas_map_output(adapter, dtype,
pd.Series([1, 2, 3])),
+ [{"details": {"name": None}, "items":
None, "lookup": None}]
+ * 3)
+ finally:
+ adapter.close()
+
+ def test_pandas_map_batches_requires_matching_output_containers(self):
+ from pyflink.dataframe.udf import _resolve_map_udf
+
+ input_type = TableDataTypes.ROW([TableDataTypes.FIELD("id",
TableDataTypes.BIGINT())])
+ for field_type, columns, container in (
+ (pf.DataType.struct({"name": pf.DataType.string()}),
+ (pd.Series([{"name": "a"}]), None, [1]), "DataFrame"),
+ (pf.DataType.int64(), (pd.DataFrame({"inner": [1]}), None, [1]),
"Series"),
+ ):
+ dtype = pf.DataType.struct({"payload": field_type})
+ for column in columns:
+ with self.subTest(column=column, field_type=field_type):
+ adapter = _resolve_map_udf(
+ lambda batch: {"payload": column}, dtype, "pandas",
input_type)._func
+ adapter.open(object())
+ try:
+ with self.assertRaisesRegex(TypeError,
f"pandas.{container}"):
+ adapter.eval(pd.Series([1]))
+ finally:
+ adapter.close()
+
+ def test_pandas_map_batches_rejects_one_shot_structured_collections(self):
+ from pyflink.dataframe.udf import _resolve_map_udf
+
+ details = pf.DataType.struct({"name": pf.DataType.string()})
+ input_type = TableDataTypes.ROW([TableDataTypes.FIELD("id",
TableDataTypes.BIGINT())])
+ for field_type, value in (
+ (pf.DataType.list(pf.DataType.map(pf.DataType.string(), details)),
+ iter([{"key": {"name": "a"}}])),
+ (pf.DataType.map(pf.DataType.string(), details), iter([("key",
{"name": "a"})])),
+ ):
+ with self.subTest(field_type=field_type):
+ dtype = pf.DataType.struct({"value": field_type})
+ adapter = _resolve_map_udf(
+ lambda batch: {"value": pd.Series([value])}, dtype,
"pandas", input_type)._func
+ adapter.open(object())
+ try:
+ with self.assertRaisesRegex(TypeError, "iterator"):
+ adapter.eval(pd.Series([1]))
+ finally:
+ adapter.close()
+
+ def test_map_requires_output_fields_even_when_nullable(self):
+ from pyflink.dataframe.udf import _resolve_map_udf
+
+ class Output(TypedDict):
+ value: Optional[str]
+
+ def transform(row) -> Output:
+ return {"value": None} if row["id"] else {}
+
+ input_type = TableDataTypes.ROW([TableDataTypes.FIELD("id",
TableDataTypes.BIGINT())])
+ explicit = pf.DataType.struct({"value": pf.DataType.string()})
+ for source, dtype in (
+ (transform, None), (transform, Output),
+ (pf.udf(transform), None), (transform, explicit),
+ ):
+ with self.subTest(source=source, dtype=dtype):
+ adapter = _resolve_map_udf(source, dtype, "general",
input_type)._func
+ adapter.open(object())
+ try:
+ self.assertEqual(adapter.eval(1), Row(value=None))
+ with self.assertRaisesRegex(KeyError, "value"):
+ adapter.eval(0)
+ finally:
+ adapter.close()
+
+ def test_map_requires_nested_row_fields_including_collections(self):
+ from pyflink.dataframe.udf import _resolve_map_udf
+
+ details = pf.DataType.struct({"name": pf.DataType.string()})
+ dtype = pf.DataType.struct({
+ "details": details,
+ "items": pf.DataType.list(details),
+ "lookup": pf.DataType.map(pf.DataType.string(), details),
+ })
+ input_type = TableDataTypes.ROW([TableDataTypes.FIELD("id",
TableDataTypes.BIGINT())])
+ valid = {"details": {"name": None, "extra": 1}, "items": [None,
{"name": "a"}],
+ "lookup": {"any key": {"name": None}}, "extra": 2}
+ for result in (valid, dict(valid, details={}), dict(valid, items=[{}]),
+ dict(valid, lookup={"any key": {}}),
+ dict(valid, details=types.SimpleNamespace())):
+ def transform(row):
+ return result
+
+ async def transform_async(row):
+ return result
+
+ for source in (transform, transform_async):
+ with self.subTest(result=result, source=source):
+ adapter = _resolve_map_udf(source, dtype, "general",
input_type)._func
+ adapter.open(object())
+ try:
+ def invoke():
+ value = adapter.eval(1)
+ return asyncio.run(value) if
inspect.isawaitable(value) else value
+
+ if result is valid:
+ self.assertEqual(invoke(), Row(
+ details=Row(name=None), items=[None,
Row(name="a")],
+ lookup={"any key": Row(name=None)}))
+ else:
+ with self.assertRaisesRegex((KeyError,
AttributeError), "name"):
+ invoke()
+ finally:
+ adapter.close()
+
+ def test_row_maps_receive_named_fields_and_project_output_schema(self):
+ from pyflink.dataframe.udf import (
+ _DataFrameAsyncScalarFunctionAdapter,
+ _DataFrameScalarFunctionAdapter,
+ _UDFUsage,
+ _resolve_udf,
+ )
+
+ def transform(row):
+ self.assertEqual(row, {"value": 3, "label": "a"})
+ return {"label": row["label"].upper(), "extra": 99, "value":
row["value"] + 1}
+
+ async def transform_async(row):
+ return transform(row)
+
+ dtype = pf.DataType.struct({"value": pf.DataType.int64(), "label":
pf.DataType.string()})
+ for source, adapter_type in (
+ (transform, _DataFrameScalarFunctionAdapter),
+ (transform_async, _DataFrameAsyncScalarFunctionAdapter),
+ ):
+ with self.subTest(source=source):
+ adapter = adapter_type(
+ _resolve_udf(source).runtime_source, dtype, True,
_UDFUsage.MAP, "general",
+ input_type=dtype._to_table_data_type(),
+ )
+ adapter.open(object())
+ try:
+ result = adapter.eval(3, "a")
+ if inspect.isawaitable(result):
+ result = asyncio.run(result)
+ self.assertEqual(result, Row(value=4, label="A"))
+ finally:
+ adapter.close()
+
+ def test_row_maps_preserve_resolved_column_inputs(self):
+ import cloudpickle
+ from pyflink.dataframe.udf import _resolve_map_udf
+ from pyflink.fn_execution import flink_fn_execution_pb2 as proto
+ from pyflink.fn_execution.table.operations import
ScalarFunctionOperation
+ from pyflink.serializers import PickleSerializer
+ from pyflink.table.udf import DelegatingScalarFunction
+
+ literal = proto.Input(inputConstant=b"\x00" +
PickleSerializer().dumps("xyz"))
+ increment = proto.UserDefinedFunction(
+ payload=cloudpickle.dumps(DelegatingScalarFunction(lambda value:
value + 1)),
+ inputs=[proto.Input(inputOffset=0)])
+ for fields, inputs, values, expected in (
+ ({"tag": pf.DataType.string()}, [literal], [], Row(tag="xyz")),
+ ({"id": pf.DataType.int64(), "tag": pf.DataType.string()},
+ [proto.Input(inputOffset=0), literal], [1], Row(id=1, tag="xyz")),
+ ({"first": pf.DataType.int64(), "second": pf.DataType.int64()},
+ [proto.Input(udf=increment), proto.Input(udf=increment)], [1],
+ Row(first=2, second=2)),
+ ({"payload": pf.DataType.struct({"tag": pf.DataType.string()})},
+ [proto.Input(inputOffset=0)], [Row(tag="xyz")],
Row(payload=Row(tag="xyz"))),
+ ):
+ with self.subTest(fields=fields):
+ dtype = pf.DataType.struct(fields)
+ function = _resolve_map_udf(
+ lambda row: row, dtype, "general",
dtype._to_table_data_type())
+ operation =
ScalarFunctionOperation(proto.UserDefinedFunctions(udfs=[
+ proto.UserDefinedFunction(
+ payload=cloudpickle.dumps(function._func),
inputs=inputs,
+ takes_row_as_input=function._takes_row_as_input)]))
+ operation.open()
+ try:
+ self.assertEqual(operation.process_element(values),
[expected])
+ finally:
+ operation.close()
+
def test_general_result_normalizers_are_bound_by_return_type(self):
from pyflink.dataframe.udf import (
_DataFrameAsyncScalarFunctionAdapter,
@@ -1564,7 +2217,7 @@ class DataFrameUDFAdapterTests(unittest.TestCase):
"general",
)
for _ in range(2):
- with self.assertRaisesRegex(NotImplementedError, "'map'"):
+ with self.assertRaisesRegex(ValueError, "input schema"):
adapter.open(object())
adapter.close()
@@ -1575,14 +2228,127 @@ class DataFrameUDFAdapterTests(unittest.TestCase):
class DataFrameUDFPlannerTests(PyFlinkDataFrameUTTestCase):
+ def test_map_function_classes_require_explicit_custom_determinism(self):
+ class DefaultDeterminism(ScalarFunction):
+ def eval(self, value):
+ return {"value": value["value"]}
+
+ class CustomDeterminism(DefaultDeterminism):
+ def is_deterministic(self):
+ return False
+
+ class InheritedDeterminism(CustomDeterminism):
+ pass
+
+ class DeferredDeterminism(CustomDeterminism):
+ def __init__(self):
+ raise AssertionError("Function classes must be constructed on
the worker.")
+
+ dataframe = pf.from_records([(1,)], schema=["value"])
+ dtype = pf.DataType.struct({"value": pf.DataType.int64()})
+ for mode in ("general", "pandas", "arrow"):
+ def bind(source, **kwargs):
+ return (dataframe.map(source, **kwargs) if mode == "general"
else
+ dataframe.map_batches(source, batch_format=mode,
**kwargs))
+
+ for source in (CustomDeterminism, InheritedDeterminism,
DeferredDeterminism):
+ with self.subTest(mode=mode, source=source):
+ with self.assertRaisesRegex(TypeError,
"deterministic=.*instance"):
+ bind(source, return_dtype=dtype)
+ declared = pf.udf(source, return_dtype=dtype,
func_type=mode,
+ deterministic=False)
+ self.assert_dataframe_schema(bind(declared), ["value"])
+ for source in (DefaultDeterminism, CustomDeterminism()):
+ self.assert_dataframe_schema(bind(source, return_dtype=dtype),
["value"])
+
+ class CustomAsyncDeterminism(AsyncScalarFunction):
+ async def eval(self, value):
+ return {"value": value["value"]}
+
+ def is_deterministic(self):
+ return False
+
+ class InheritedAsyncDeterminism(CustomAsyncDeterminism):
+ pass
+
+ for source in (CustomAsyncDeterminism, InheritedAsyncDeterminism):
+ with self.assertRaisesRegex(TypeError, "deterministic=.*instance"):
+ dataframe.map(source, return_dtype=dtype)
+ declared = pf.udf(source, return_dtype=dtype, deterministic=False)
+ self.assert_dataframe_schema(dataframe.map(declared), ["value"])
+ self.assert_dataframe_schema(dataframe.map(source(),
return_dtype=dtype), ["value"])
+
+ def test_map_batches_requires_matching_format_and_struct_schema(self):
+ dataframe = pf.from_records([(1,)], schema=["id"])
+ dtype = pf.DataType.struct({"value": pf.DataType.int64()})
+
+ def transform(batch):
+ return {"value": batch["id"]}
+
+ for source in (transform, pf.udf(transform, return_dtype=dtype,
func_type="pandas")):
+ result = dataframe.map_batches(
+ source, batch_format="pandas",
+ **({"return_dtype": dtype} if source is transform else {}),
+ )
+ self.assertEqual(result.columns, ["value"])
+ for call, error, message in (
+ (lambda: dataframe.map_batches(transform), TypeError,
"batch_format"),
+ (lambda: dataframe.map_batches(transform, batch_format="numpy"),
ValueError, "format"),
+ (lambda: dataframe.map_batches(transform, batch_format="pandas"),
TypeError,
+ "return_dtype"),
+ (lambda: dataframe.map_batches(transform, batch_format="pandas",
return_dtype=int),
+ TypeError, "struct"),
+ (lambda: dataframe.map_batches(
+ pf.udf(transform, return_dtype=dtype), batch_format="pandas"),
ValueError, "mode"),
+ ):
+ with self.subTest(message=message):
+ with self.assertRaisesRegex(error, message):
+ call()
+
+ for field_type in (
+ pf.DataType.struct({"inner": pf.DataType.struct({"v":
pf.DataType.int64()})}),
+ pf.DataType.struct({"ts": pf.DataType.timestamp_ltz(3)}),
+ pf.DataType.list(pf.DataType.struct({"v": pf.DataType.int64()})),
+ pf.DataType.list(pf.DataType.timestamp_ltz(3)),
+ ):
+ with self.subTest(field_type=field_type):
+ output_type = pf.DataType.struct({"value": field_type})
+ with self.assertRaises((TypeError, ValueError)):
+ dataframe.map_batches(transform, batch_format="pandas",
+ return_dtype=output_type)
+ nested = dataframe.with_columns(value=pf.udf(
+ lambda value: value,
return_dtype=field_type)(pf.col("id"))).select("value")
+ with self.assertRaises((TypeError, ValueError)):
+ nested.map_batches(transform, batch_format="pandas",
return_dtype=dtype)
+ self.assertEqual(dataframe.map_batches(
+ transform, batch_format="arrow",
return_dtype=output_type).columns, ["value"])
+
+ def test_map_infers_schema_and_rejects_conflicting_declarations(self):
+ class Output(TypedDict):
+ label: str
+ value: int
+
+ def transform(row) -> Output:
+ return {"value": row["id"] + 1, "label": "mapped"}
+
+ dataframe = pf.from_records([(1,)], schema=["id"])
+ declaration = pf.udf(transform)
+ for source in (transform, declaration):
+ result = dataframe.map(source)
+ self.assertEqual(result.columns, ["label", "value"])
+ with self.assertRaisesRegex(ValueError, "return_dtype"):
+ dataframe.map(declaration, return_dtype=Output)
+ with self.assertRaisesRegex(TypeError, "struct"):
+ dataframe.map(lambda row: row["id"], return_dtype=int)
+ with self.assertRaisesRegex(ValueError, "mode"):
+ dataframe.map(pf.udf(transform, return_dtype=Output,
func_type="pandas"))
+
def test_arrow_calls_require_a_column_argument(self):
from pyflink.table import ExplainDetail
- @pf.udf(return_dtype=pf.DataType.int64(), func_type="arrow")
- def identity(*values):
- return values[0]
-
dataframe = pf.from_records([(1,)], schema=["id"])
+ identity = pf.udf(lambda *values: values[0],
return_dtype=pf.DataType.int64(),
+ func_type="arrow")
for args in ((), (1,), (pf.lit(1),), (identity(),)):
with self.subTest(args=args):
with self.assertRaisesRegex(Exception, "at least one
column-valued argument"):
@@ -1630,6 +2396,229 @@ class
DataFrameUDFPlannerTests(PyFlinkDataFrameUTTestCase):
class DataFrameUDFITCase(PyFlinkStreamDataFrameTestCase):
+ def test_row_maps_preserve_literal_and_fused_inputs(self):
+ self.env.set_parallelism(1)
+
+ def identity(row):
+ return row
+
+ async def async_identity(row):
+ return row
+
+ @pf.udf
+ def increment(value: int) -> int:
+ return value + 1
+
+ source = pf.from_records([(1,), (2,)], schema=["id"])
+ tagged = source.with_columns(tag=pf.lit("xyz"))
+ for dataframe, fields, expected in (
+ (tagged.select("tag"), {"tag": pf.DataType.string()},
+ [Row("xyz"), Row("xyz")]),
+ (tagged, {"id": pf.DataType.int64(), "tag": pf.DataType.string()},
+ [Row(1, "xyz"), Row(2, "xyz")]),
+ (source.select(increment(pf.col("id")).alias("first"),
+ increment(pf.col("id")).alias("second")),
+ {"first": pf.DataType.int64(), "second": pf.DataType.int64()},
+ [Row(2, 2), Row(3, 3)]),
+ ):
+ dtype = pf.DataType.struct(fields)
+ for function in (identity, async_identity):
+ with self.subTest(fields=fields, function=function):
+ result = dataframe.map(function, return_dtype=dtype).map(
+ identity, return_dtype=dtype)
+ self.assertEqual(result.collect(), expected)
+
+ def test_batch_maps_broadcast_literal_columns(self):
+ self.env.set_parallelism(1)
+ self.t_env.get_config().set("python.fn-execution.bundle.size", "3")
+ self.t_env.get_config().set("python.fn-execution.arrow.batch.size",
"2")
+ dtype = pf.DataType.struct({
+ "tag": pf.DataType.string(), "empty": pf.DataType.int64(),
+ "enabled": pf.DataType.bool(), "id": pf.DataType.int64()})
+ dataframe = pf.from_records([(1,), (2,), (3,)],
schema=["id"]).with_columns(
+ tag=pf.lit("x"), empty=pf.lit(None, pf.DataType.int64()),
+ enabled=pf.lit(True)).select("tag", "empty", "enabled", "id")
+ for batch_format in ("pandas", "arrow"):
+ with self.subTest(batch_format=batch_format):
+ result = dataframe.map_batches(
+ lambda batch: batch, batch_format=batch_format,
return_dtype=dtype)
+ self.assertEqual(result.collect(),
+ [Row("x", None, True, value) for value in (1,
2, 3)])
+
+ def test_pandas_output_alignment_preserves_input_order_and_ltz(self):
+ import datetime
+
+ self.env.set_parallelism(1)
+ self.t_env.get_config().set("python.fn-execution.bundle.size", "3")
+ self.t_env.get_config().set("python.fn-execution.arrow.batch.size",
"2")
+ timestamp = datetime.datetime(2020, 1, 1, 12)
+
+ def transform(batch):
+ return {"score": (batch["id"] * 10).sort_index(ascending=False),
+ "id": batch["id"], "ts": batch["ts"]}
+
+ timezone = self.t_env.get_config().get_local_timezone()
+ try:
+ for zone, epoch in [("UTC", 1577880000000), ("Asia/Shanghai",
1577851200000)]:
+ with self.subTest(timezone=zone):
+ self.t_env.get_config().set_local_timezone(zone)
+ from pyflink.table.expressions import call_sql, col
+
+ source = self.t_env.from_elements(
+ [(10, epoch), (20, epoch), (30, None)], ["id",
"epoch"])
+ dataframe = pf.from_table(source.select(
+ col("id"), call_sql("TO_TIMESTAMP_LTZ(epoch,
3)").alias("ts")))
+ result = dataframe.map_batches(
+ transform, batch_format="pandas",
return_dtype=pf.DataType.struct({
+ "score": pf.DataType.int64(), "id":
pf.DataType.int64(),
+ "ts": pf.DataType.timestamp_ltz(3)}))
+ result = result.select(pf.col("score"), pf.col("id"),
+
pf.col("ts").cast(pf.DataType.timestamp(3)).alias("ts"))
+ self.assertEqual(result.collect(), [
+ Row(100, 10, timestamp), Row(200, 20, timestamp),
Row(300, 30, None)])
+
+ finally:
+ self.t_env.get_config().set_local_timezone(timezone)
+
+ def
test_pandas_map_preserves_row_columns_across_execution_boundaries(self):
+ from pyflink.table.udf import udf as table_udf
+
+ self.env.set_parallelism(1)
+ self.t_env.get_config().set("python.fn-execution.bundle.size", "3")
+ self.t_env.get_config().set("python.fn-execution.arrow.batch.size",
"2")
+ payload_type = pf.DataType.struct({
+ "lookup": pf.DataType.map(pf.DataType.string().not_null(),
pf.DataType.int64()),
+ "v": pf.DataType.int64(),
+ })
+ input_type = pf.DataType.struct({"id": pf.DataType.int64(), "payload":
payload_type})
+ value_type = pf.DataType.struct({"v": pf.DataType.int64()})
+ output_type = pf.DataType.struct({"total": pf.DataType.int64(),
"value": value_type})
+ flat_type = pf.DataType.struct({"total": pf.DataType.int64(), "v":
pf.DataType.int64()})
+
+ def produce(values):
+ rows = [
+ {"lookup": [("long-key", int(value))], "v": 11} if value > 0
else
+ {"lookup": [], "v": None} if value == 0 else
+ {"lookup": None, "v": None}
+ for value in values
+ ]
+ return pd.DataFrame({
+ label: pd.Series([row[field] for row in rows],
index=values.index[::-1],
+ dtype=object)
+ for label, field in zip(("unrelated1", "unrelated2"),
("lookup", "v"))
+ })
+
+ def consume(batch):
+ assert isinstance(batch["payload"], pd.DataFrame)
+ return {"total": batch["payload"]["lookup"].map(
+ lambda pairs: -1 if pairs is None else sum(value for _, value
in pairs)
+ ) + batch["id"] * 10, "value":
batch["payload"][["v"]].sort_index(ascending=False)}
+
+ def extract(batch):
+ return {"total": batch["total"], "v": batch["value"].field("v")}
+
+ @table_udf(result_type=flat_type._to_table_data_type(),
func_type="pandas")
+ def table_extract(batch):
+ assert isinstance(batch["total"], pd.Series)
+ return pd.DataFrame({name: batch[name] for name in ("total", "v")})
+
+ producer = pf.udf(produce, return_dtype=payload_type,
func_type="pandas")
+ for transported in (False, True):
+ with self.subTest(transported=transported):
+ dataframe = pf.from_records([(1,), (0,), (-1,)],
schema=["id"]).with_columns(
+ payload=producer(pf.col("id")))
+ if transported:
+ dataframe = dataframe.map_batches(
+ lambda batch: batch, return_dtype=input_type,
batch_format="arrow")
+ mapped = dataframe.map_batches(
+ consume, return_dtype=output_type,
batch_format="pandas").map_batches(
+ lambda batch: batch, return_dtype=output_type,
batch_format="pandas")
+ for table_map in (False, True):
+ result =
(pf.from_table(mapped.to_table().map(table_extract)) if table_map
+ else mapped.map_batches(extract,
batch_format="arrow",
+ return_dtype=flat_type))
+ self.assertEqual(sorted(result.collect(), key=lambda row:
row[0]),
+ [Row(-11, None), Row(0, None), Row(11,
11)])
+
+ def test_row_and_batch_maps_in_one_job(self):
+ self.env.set_parallelism(1)
+ self.t_env.get_config().set("python.fn-execution.bundle.size", "3")
+ self.t_env.get_config().set("python.fn-execution.arrow.batch.size",
"2")
+ dtype = pf.DataType.struct({"id": pf.DataType.int64(), "value":
pf.DataType.int64()})
+
+ class Prepare(ScalarFunction):
+ def eval(self, row):
+ assert isinstance(row, dict)
+ return {"value": row["id"] * 2, "id": row["id"], "ignored": 0}
+
+ def is_deterministic(self):
+ return False
+
+ prepare = pf.udf(Prepare, return_dtype=dtype, deterministic=False)
+
+ async def enrich(row):
+ assert isinstance(row, dict)
+ return {"id": row["id"], "value": row["value"] + 1}
+
+ def pandas_add(batch):
+ assert isinstance(batch, dict)
+ assert all(isinstance(column, pd.Series) for column in
batch.values())
+ assert 1 <= len(batch["id"]) <= 2
+ return {"id": batch["id"], "value": batch["value"] + 10}
+
+ def arrow_double(batch):
+ import pyarrow.compute as pc
+
+ assert isinstance(batch, dict)
+ assert all(isinstance(column, pa.Array) for column in
batch.values())
+ return {"id": pa.chunked_array([batch["id"].slice(0, 1),
batch["id"].slice(1)]),
+ "value": pc.multiply(batch["value"], 2)}
+
+ flat_dtype = pf.DataType.struct({
+ "id": pf.DataType.int64(), "value": pf.DataType.int64(),
+ "inner": pf.DataType.int64(),
+ })
+ nested_dtype = pf.DataType.struct({
+ "id": pf.DataType.int64(),
+ "payload": pf.DataType.struct({
+ "value": pf.DataType.int64(),
+ "inner": pf.DataType.struct({"value": pf.DataType.int64()}),
+ }),
+ })
+
+ def pandas_prepare(batch):
+ return {"id": batch["id"], "value": batch["value"],
+ "inner": pd.Series([None if identifier == 3 else 3
+ for identifier in batch["id"]],
dtype=object)}
+
+ def arrow_nest(batch):
+ return {"id": batch["id"], "payload": pa.array([
+ None if identifier == 5 else {"value": value, "inner":
+ None if identifier == 4 else {"value": inner}}
+ for identifier, value, inner in zip(
+ batch["id"].to_pylist(), batch["value"].to_pylist(),
batch["inner"].to_pylist())
+ ], type=pa.struct([("value", pa.int64()),
+ ("inner", pa.struct([("value", pa.int64())]))]))}
+
+ def arrow_unnest(batch):
+ assert isinstance(batch["payload"], pa.StructArray)
+ return {"id": batch["id"], "value": pa.array([
+ -1 if payload is None else -2 if payload["inner"] is None else
-3
+ if payload["inner"]["value"] is None
+ else payload["value"] + payload["inner"]["value"]
+ for payload in batch["payload"].to_pylist()
+ ], type=pa.int64())}
+
+ result = (pf.from_records([(1,), (2,), (3,), (4,), (5,)],
schema=["id"])
+ .map(prepare).map(enrich, return_dtype=dtype)
+ .map_batches(pandas_add, return_dtype=dtype,
batch_format="pandas")
+ .map_batches(arrow_double, return_dtype=dtype,
batch_format="arrow")
+ .map_batches(pandas_prepare, return_dtype=flat_dtype,
batch_format="pandas")
+ .map_batches(arrow_nest, return_dtype=nested_dtype,
batch_format="arrow")
+ .map_batches(arrow_unnest, return_dtype=dtype,
batch_format="arrow"))
+ self.assertEqual(sorted(result.collect(), key=lambda row: row[0]),
+ [Row(1, 29), Row(2, 33), Row(3, -3), Row(4, -2),
Row(5, -1)])
+
def test_supported_scalar_udfs_in_one_job(self):
import pyarrow.compute as pc
diff --git a/flink-python/pyflink/dataframe/tests/test_udtf.py
b/flink-python/pyflink/dataframe/tests/test_udtf.py
index 4ea27607be8..3ce842c54f0 100644
--- a/flink-python/pyflink/dataframe/tests/test_udtf.py
+++ b/flink-python/pyflink/dataframe/tests/test_udtf.py
@@ -58,6 +58,18 @@ def _eval_udtf(declaration, *args):
class DataFrameUDTFDeclarationTests(unittest.TestCase):
+ def test_missing_row_fields_remain_nullable_in_expression_results(self):
+ details = pf.DataType.struct({"label": pf.DataType.string()})
+ dtype = pf.DataType.struct({
+ "missing": pf.DataType.string(),
+ "details": details,
+ "items": pf.DataType.list(details),
+ })
+ declaration = pf.udtf(lambda value: [{"details": {}, "items": [{}]}],
+ return_dtype=dtype)
+ self.assertEqual(_eval_udtf(declaration, 1), [
+ Row(missing=None, details=Row(label=None),
items=[Row(label=None)])])
+
def test_decorator_forms_and_type_inference(self):
def emit(value) -> Iterator[_Output]:
yield {"value": value, "label": "ok"}
diff --git a/flink-python/pyflink/dataframe/udf.py
b/flink-python/pyflink/dataframe/udf.py
index e2f3955f435..df0b92da8d3 100644
--- a/flink-python/pyflink/dataframe/udf.py
+++ b/flink-python/pyflink/dataframe/udf.py
@@ -20,7 +20,7 @@
import functools
import inspect
-from collections.abc import Mapping
+from collections.abc import Iterator, Mapping
from dataclasses import dataclass, field
from enum import Enum
from typing import (
@@ -50,6 +50,8 @@ from pyflink.table.udf import (
ScalarFunction,
UserDefinedFunction,
UserDefinedFunctionWrapper,
+ UserDefinedAsyncScalarFunctionWrapper,
+ UserDefinedScalarFunctionWrapper,
udf as table_udf,
)
from pyflink.util.api_stability_decorators import PublicEvolving
@@ -245,7 +247,7 @@ class _DataFrameUDFWrapper:
return cast(UserDefinedFunctionWrapper, self._cached_table_udf_wrapper)
def _create_table_udf_wrapper(
- self, usage: _UDFUsage
+ self, usage: _UDFUsage, input_type: Optional[RowType] = None
) -> UserDefinedFunctionWrapper:
adapter_type = (
_DataFrameAsyncScalarFunctionAdapter
@@ -260,9 +262,10 @@ class _DataFrameUDFWrapper:
self._deterministic,
usage,
self._func_type,
+ input_type,
),
)
- return cast(
+ wrapper = cast(
UserDefinedFunctionWrapper,
table_udf(
actual_func,
@@ -272,12 +275,60 @@ class _DataFrameUDFWrapper:
func_type=self._func_type,
),
)
+ wrapper._returns_table_row = usage in (_UDFUsage.MAP,
_UDFUsage.MAP_BATCHES)
+ return wrapper
@property
def return_dtype(self) -> DataType:
return self._return_dtype
+def _resolve_map_udf(
+ func: Union[_UDFInput, _DataFrameUDFWrapper],
+ return_dtype: Optional[_DataTypeLike],
+ func_type: str,
+ input_type: RowType,
+) -> Union[UserDefinedScalarFunctionWrapper,
UserDefinedAsyncScalarFunctionWrapper]:
+ if isinstance(func, _DataFrameUDFWrapper):
+ if return_dtype is not None:
+ raise ValueError("return_dtype must not be specified for an
existing DataFrame UDF.")
+ declaration = func
+ else:
+ if (inspect.isclass(func) and issubclass(func, (ScalarFunction,
AsyncScalarFunction))
+ and inspect.getattr_static(func, "is_deterministic")
+ is not inspect.getattr_static(UserDefinedFunction,
"is_deterministic")):
+ raise TypeError(
+ f"Cannot infer determinism from class {func.__name__!r}. "
+ "Declare it with pf.udf(..., deterministic=...), or pass an
instance."
+ )
+ deterministic = (
+ func.is_deterministic()
+ if isinstance(func, (ScalarFunction, AsyncScalarFunction))
+ else True
+ )
+ declaration = cast(_DataFrameUDFWrapper, udf(
+ func, return_dtype=return_dtype, func_type=func_type,
deterministic=deterministic
+ ))
+ if declaration._func_type != func_type:
+ raise ValueError(
+ f"The declared UDF mode {declaration._func_type!r} does not match "
+ f"the required mode {func_type!r}."
+ )
+ output_type = declaration.return_dtype._to_table_data_type()
+ if not isinstance(output_type, RowType):
+ raise TypeError("return_dtype for map and map_batches must be a struct
type.")
+ usage = _UDFUsage.MAP if func_type == "general" else _UDFUsage.MAP_BATCHES
+ if usage is _UDFUsage.MAP_BATCHES:
+ if not input_type.fields:
+ raise ValueError("map_batches requires at least one input column.")
+ if func_type == "pandas":
+ _validate_pandas_map_schema(input_type, output_type)
+ return cast(
+ Union[UserDefinedScalarFunctionWrapper,
UserDefinedAsyncScalarFunctionWrapper],
+ declaration._create_table_udf_wrapper(usage, input_type),
+ )
+
+
@overload
def udf(
func: _UDFInput,
@@ -528,6 +579,16 @@ def _validate_scalar_udf_options(
)
+def _validate_pandas_map_schema(input_type: RowType, output_type: RowType) ->
None:
+ from pyflink.table.types import create_arrow_schema
+
+ if not output_type.fields:
+ raise ValueError("Pandas map_batches requires at least one output
column.")
+ # Validate physical columns even when fusion bypasses Arrow transport.
+ for row_type in (input_type, output_type):
+ create_arrow_schema(row_type.field_names(), row_type.field_types())
+
+
# ======================== Callable Inspection and Resolution
========================
# ---- Invocation target resolution ----
@@ -1080,10 +1141,13 @@ class _DataFrameUDFAdapterBase:
deterministic: bool,
usage: _UDFUsage,
func_type: str,
+ input_type: Optional[RowType] = None,
) -> None:
self._runtime_source = runtime_source
self._worker_udf: Optional[_WorkerUDF] = None
- self._return_dtype = return_dtype if func_type == "general" else None
+ self._return_dtype = return_dtype
+ # MAP and MAP_BATCHES need the input DataFrame schema to build their
input dictionaries.
+ self._input_type = input_type
self._deterministic = deterministic
self._usage = usage
self._func_type = func_type
@@ -1108,13 +1172,34 @@ class _DataFrameUDFAdapterBase:
raise
def _bind_func(self, invoke_func: Callable[..., Any]) -> Callable[...,
Any]:
- if self._usage is not _UDFUsage.EXPRESSION:
- raise NotImplementedError(
- f"DataFrame UDF usage {self._usage.value!r} is not supported
yet."
- )
+ if self._usage in (_UDFUsage.MAP, _UDFUsage.MAP_BATCHES):
+ if self._input_type is None:
+ raise ValueError("A map binding requires an input schema.")
+ output_type = cast(RowType,
self._return_dtype._to_table_data_type())
+ if self._usage is _UDFUsage.MAP:
+ input_names = self._input_type.field_names()
+ normalize_result = cast(Callable[[Any], Any],
_create_result_normalizer(
+ output_type, require_row_fields=True))
+
+ def normalize_map_result(value):
+ if not isinstance(value, Mapping):
+ raise TypeError("A map function must return a
dictionary.")
+ return normalize_result(value)
+
+ normalized_func = _wrap_scalar_general_result(
+ invoke_func, normalize_map_result,
self._runtime_source.is_async,
+ )
+
+ @functools.wraps(invoke_func)
+ def map_row(*values):
+ return normalized_func(dict(zip(input_names, values)))
+
+ return map_row
+ wrap = _wrap_map_pandas if self._func_type == "pandas" else
_wrap_map_arrow
+ return wrap(invoke_func, self._input_type, output_type)
if self._func_type == "general":
result_normalizer = _create_result_normalizer(
- cast(DataType, self._return_dtype)._to_table_data_type()
+ self._return_dtype._to_table_data_type()
)
if result_normalizer is None:
return invoke_func
@@ -1148,7 +1233,7 @@ class
_DataFrameScalarFunctionAdapter(_DataFrameUDFAdapterBase, ScalarFunction):
def eval(self, *args: Any) -> Any:
invoke_func = self._invocation()
- if self._func_type == "pandas":
+ if self._func_type == "pandas" and self._usage is not
_UDFUsage.MAP_BATCHES:
from pyflink.fn_execution.utils.operation_utils import (
check_pandas_udf_result,
)
@@ -1169,8 +1254,12 @@ class _DataFrameAsyncScalarFunctionAdapter(
# ======================== Result Normalization ========================
-def _row_field_values(value: Any, field_names: List[str]) -> List[Any]:
+def _row_field_values(
+ value: Any, field_names: List[str], *, require_row_fields: bool = False,
+) -> List[Any]:
if isinstance(value, Mapping):
+ if require_row_fields:
+ return [value[field_name] for field_name in field_names]
return [value.get(field_name) for field_name in field_names]
if isinstance(value, Row) and hasattr(value, "_fields"):
field_indices: Dict[str, int] = {}
@@ -1200,13 +1289,13 @@ def _row_field_values(value: Any, field_names:
List[str]) -> List[Any]:
)
return list(value)
return [
- _object_row_field_value(value, field_name, field_names)
+ _object_row_field_value(value, field_name, field_names,
require_row_fields)
for field_name in field_names
]
def _object_row_field_value(
- value: Any, field_name: str, field_names: List[str]
+ value: Any, field_name: str, field_names: List[str], require_row_fields:
bool
) -> Any:
try:
return getattr(value, field_name)
@@ -1217,6 +1306,11 @@ def _object_row_field_value(
attributes = getattr(value, "__dict__", None)
has_slots = any("__slots__" in cls.__dict__ for cls in
type(value).__mro__)
if isinstance(attributes, Mapping) or has_slots:
+ if require_row_fields:
+ raise AttributeError(
+ f"Field name {field_name!r} does not exist in "
+ f"{type(value).__name__}."
+ ) from None
return None
else:
raise
@@ -1226,19 +1320,255 @@ def _object_row_field_value(
) from None
+def _is_pandas_null(value):
+ import pandas as pd
+
+ return bool(pd.isna(value)) if pd.api.types.is_scalar(value) else False
+
+
+def _wrap_map_pandas(func, input_type: RowType, output_type: RowType):
+ import pandas as pd
+
+ input_converters = [
+ _create_pandas_row_column_converter(field.data_type, [None] *
len(field.data_type))
+ if isinstance(field.data_type, RowType) else None for field in
input_type]
+ output_converters =
[_create_pandas_output_column_converter(field.data_type)
+ for field in output_type]
+
+ @functools.wraps(func)
+ def wrapper(*input_columns):
+ batch_size = next((len(column) for column in input_columns
+ if isinstance(column, (pd.Series, pd.DataFrame))),
None)
+ if batch_size is None:
+ raise ValueError("Pandas map_batches requires a column-valued
input.")
+ input_index = pd.RangeIndex(batch_size)
+ columns = {}
+ for input_field, column, convert in zip(input_type, input_columns,
input_converters):
+ # The planner can fold an input column into a scalar literal
argument.
+ if not isinstance(column, (pd.Series, pd.DataFrame)):
+ if isinstance(input_field.data_type, RowType):
+ names = input_field.data_type.field_names()
+ values = ([None] * len(names) if column is None
+ else _row_field_values(column, names))
+ column = pd.DataFrame([values] * batch_size, columns=names)
+ else:
+ column = pd.Series([column] * batch_size,
index=input_index,
+ name=input_field.name)
+ if convert is not None:
+ column = convert(column)
+ index = pd.RangeIndex(len(column))
+ if not column.index.identical(index):
+ # Arrow transports values positionally and discards upstream
pandas indexes.
+ column = column.copy(deep=False)
+ column.index = index
+ columns[input_field.name] = column
+ result = func(columns)
+ output = []
+ for output_field, convert in zip(output_type, output_converters):
+ column = result[output_field.name]
+ container = pd.DataFrame if isinstance(output_field.data_type,
RowType) else pd.Series
+ if not isinstance(column, container):
+ raise TypeError(f"Pandas map_batches output column
{output_field.name!r} "
+ f"must be a pandas.{container.__name__}.")
+ if len(column) != len(input_index):
+ raise ValueError(
+ f"The result length '{len(column)}' of Pandas UDF
'{wrapper.__name__}' "
+ f"column {output_field.name!r} is not equal to the input
length "
+ f"'{len(input_index)}'")
+ if container is pd.DataFrame and type(column) is not pd.DataFrame:
+ # The shared Arrow converter expects the base DataFrame
produced by concat.
+ column = pd.DataFrame(column, copy=False)
+ if convert is not None:
+ column = convert(column)
+ if isinstance(column, pd.Series) and column.name !=
output_field.name:
+ column = column.copy(deep=False)
+ column.name = output_field.name
+ output.append(column)
+ output = pd.concat(output, axis=1, copy=False)
+ index = output.index
+ if index.equals(input_index):
+ index = input_index
+ elif len(index) == len(input_index):
+ # Restore input order without discarding extra labels or padding
short batches.
+ extra_labels = index[~index.isin(input_index)]
+ index = input_index if extra_labels.empty else
input_index.append(extra_labels)
+ if len(index) != len(input_index):
+ raise ValueError(
+ f"The result length '{len(index)}' of Pandas UDF
'{wrapper.__name__}' "
+ f"is not equal to the input length '{len(input_index)}'")
+ if not output.index.identical(index):
+ if output.index.equals(index):
+ output.index = index
+ else:
+ output = output.reindex(index)
+ return output
+
+ return wrapper
+
+
+def _normalize_pandas_series(column, normalize):
+ import pandas as pd
+
+ converted = None
+ for index, value in enumerate(column):
+ normalized = normalize(value)
+ if normalized is not value:
+ if converted is None:
+ converted = list(column)
+ converted[index] = normalized
+ return (column if converted is None
+ else pd.Series(converted, name=column.name, index=column.index,
dtype=object))
+
+
+def _create_pandas_row_column_converter(data_type, converters):
+ import pandas as pd
+
+ names = data_type.field_names()
+
+ def convert_row(column):
+ if not isinstance(column, pd.DataFrame):
+ raise TypeError("Pandas ROW columns must be pandas.DataFrame. "
+ "Return a pandas.DataFrame from the upstream
pandas UDF.")
+ if list(column.columns) != names:
+ # ROW fields are positional in Arrow transport, regardless of
producer labels.
+ column = column.copy(deep=False)
+ column.columns = names
+ converted_columns = None
+ for index, convert in enumerate(converters):
+ if convert is not None:
+ original = column.iloc[:, index]
+ normalized = convert(original)
+ if normalized is not original:
+ if converted_columns is None:
+ converted_columns = [column.iloc[:, i] for i in
range(len(converters))]
+ converted_columns[index] = normalized
+ return (column if converted_columns is None
+ else pd.concat(converted_columns, axis=1, copy=False))
+
+ return convert_row
+
+
+def _create_pandas_output_column_converter(data_type):
+ if isinstance(data_type, RowType):
+ return _create_pandas_row_column_converter(
+ data_type, [_create_pandas_output_column_converter(field.data_type)
+ for field in data_type])
+ normalize = _create_pandas_result_normalizer(data_type)
+ return (None if normalize is None
+ else lambda column: _normalize_pandas_series(column, normalize))
+
+
+def _create_pandas_result_normalizer(data_type):
+ """Validate map ROW fields and adapt only values that Arrow cannot consume
directly."""
+ if isinstance(data_type, RowType):
+ names = data_type.field_names()
+ normalizers = [_create_pandas_result_normalizer(field.data_type) for
field in data_type]
+
+ def normalize_row(value):
+ if _is_pandas_null(value):
+ return None
+ values = _row_field_values(value, names, require_row_fields=True)
+ normalized = [item if normalize is None else normalize(item)
+ for item, normalize in zip(values, normalizers)]
+ if isinstance(value, dict) and all(item is original
+ for item, original in
zip(normalized, values)):
+ return value
+ return dict(zip(names, normalized))
+
+ return normalize_row
+ if isinstance(data_type, ArrayType):
+ normalize_element =
_create_pandas_result_normalizer(data_type.element_type)
+ if normalize_element is not None:
+ def normalize_array(value):
+ if isinstance(value, Iterator):
+ raise TypeError("Pandas ARRAY values containing ROW must
not be an iterator; "
+ "return a list instead.")
+ if _is_pandas_null(value):
+ return None
+ converted = None
+ for index, item in enumerate(value):
+ normalized = normalize_element(item)
+ if normalized is not item:
+ if converted is None:
+ converted = list(value)
+ converted[index] = normalized
+ return value if converted is None else converted
+
+ return normalize_array
+ if isinstance(data_type, MapType):
+ normalize_key = _create_pandas_result_normalizer(data_type.key_type)
+ normalize_value =
_create_pandas_result_normalizer(data_type.value_type)
+
+ def normalize_map(value):
+ if isinstance(value, Iterator):
+ raise TypeError("Pandas MAP values must not be an iterator; "
+ "return a dictionary or list of pairs
instead.")
+ if _is_pandas_null(value):
+ return None
+ # Older Arrow versions require pair sequences for MAPs nested in
collections.
+ converted = list(value.items()) if isinstance(value, Mapping) else
None
+ if normalize_key is None and normalize_value is None:
+ return value if converted is None else converted
+ items = value if converted is None else converted
+ for index, (key, item) in enumerate(items):
+ normalized_key = key if normalize_key is None else
normalize_key(key)
+ normalized_item = item if normalize_value is None else
normalize_value(item)
+ if normalized_key is not key or normalized_item is not item:
+ if converted is None:
+ converted = list(value)
+ converted[index] = (normalized_key, normalized_item)
+ return value if converted is None else converted
+
+ return normalize_map
+ return None
+
+
+def _wrap_map_arrow(func, input_type: RowType, output_type: RowType):
+ import pyarrow as pa
+ from pyflink.table.types import to_arrow_type
+
+ input_names = input_type.field_names()
+ input_types = [to_arrow_type(field.data_type, allow_nested=True) for field
in input_type]
+ output_names = output_type.field_names()
+
+ @functools.wraps(func)
+ def wrapper(*input_columns):
+ batch_size = next((len(column) for column in input_columns
+ if isinstance(column, (pa.Array,
pa.ChunkedArray))), None)
+ if batch_size is None:
+ raise ValueError("Arrow map_batches requires a column-valued
input.")
+ inputs = {
+ name: column if isinstance(column, (pa.Array, pa.ChunkedArray))
+ else pa.array([column] * batch_size, type=data_type)
+ for name, data_type, column in zip(input_names, input_types,
input_columns)
+ }
+ result = func(inputs)
+ columns = []
+ for name in output_names:
+ column = result[name]
+ if isinstance(column, pa.ChunkedArray):
+ column = column.chunk(0) if column.num_chunks == 1 else
column.combine_chunks()
+ columns.append(column)
+ return pa.StructArray.from_arrays(columns, names=output_names)
+
+ return wrapper
+
+
def _create_result_normalizer(
- data_type: Any,
+ data_type: Any, *, require_row_fields: bool = False,
) -> Optional[Callable[[Any], Any]]:
if isinstance(data_type, RowType):
field_names = data_type.field_names()
field_normalizers = tuple(
- _create_result_normalizer(field.data_type) for field in data_type
+ _create_result_normalizer(field.data_type,
require_row_fields=require_row_fields)
+ for field in data_type
)
def normalize_row(value: Any) -> Any:
if value is None:
return None
- field_values = _row_field_values(value, field_names)
+ field_values = _row_field_values(
+ value, field_names, require_row_fields=require_row_fields)
normalized_fields = [
field_value
if field_normalizer is None
@@ -1255,7 +1585,8 @@ def _create_result_normalizer(
return normalize_row
if isinstance(data_type, ArrayType):
- element_normalizer = _create_result_normalizer(data_type.element_type)
+ element_normalizer = _create_result_normalizer(
+ data_type.element_type, require_row_fields=require_row_fields)
if element_normalizer is None:
def normalize_leaf_array(value: Any) -> Any:
@@ -1270,8 +1601,10 @@ def _create_result_normalizer(
return normalize_array
if isinstance(data_type, MapType):
- key_normalizer = _create_result_normalizer(data_type.key_type)
- value_normalizer = _create_result_normalizer(data_type.value_type)
+ key_normalizer = _create_result_normalizer(
+ data_type.key_type, require_row_fields=require_row_fields)
+ value_normalizer = _create_result_normalizer(
+ data_type.value_type, require_row_fields=require_row_fields)
def normalize_map(value: Any) -> Any:
if value is None:
diff --git a/flink-python/pyflink/fn_execution/coder_impl_fast.pxd
b/flink-python/pyflink/fn_execution/coder_impl_fast.pxd
index aeacced5dc5..ff018ecd21f 100644
--- a/flink-python/pyflink/fn_execution/coder_impl_fast.pxd
+++ b/flink-python/pyflink/fn_execution/coder_impl_fast.pxd
@@ -91,6 +91,7 @@ cdef class RowCoderImpl(FieldCoderImpl):
cdef class ArrowCoderImpl(FieldCoderImpl):
cdef object _batch_format
+ cdef bint _returns_table_row
cdef object _schema
cdef list _field_types
cdef object _timezone
diff --git a/flink-python/pyflink/fn_execution/coder_impl_fast.pyx
b/flink-python/pyflink/fn_execution/coder_impl_fast.pyx
index c25ac8cfdd7..ff2cff868ad 100644
--- a/flink-python/pyflink/fn_execution/coder_impl_fast.pyx
+++ b/flink-python/pyflink/fn_execution/coder_impl_fast.pyx
@@ -36,7 +36,8 @@ from pyflink.datastream.window import CountWindow,
TimeWindow, GlobalWindow
from pyflink.fn_execution.formats.avro import FlinkAvroDecoder,
FlinkAvroDatumReader, \
FlinkAvroBufferWrapper, FlinkAvroEncoder, FlinkAvroDatumWriter
from pyflink.fn_execution.ResettableIO import ResettableIO
-from pyflink.table.utils import pandas_to_arrow, arrow_to_pandas,
validate_arrow_batch
+from pyflink.table.utils import pandas_to_arrow, arrow_to_pandas,
validate_arrow_batch, \
+ pandas_table_row_to_arrow
ROW_KIND_BIT_SIZE = 2
@@ -431,8 +432,10 @@ cdef class ArrowCoderImpl(FieldCoderImpl):
A coder for arrow format data.
"""
- def __init__(self, schema, row_type, timezone, batch_format="PANDAS"):
+ def __init__(self, schema, row_type, timezone, batch_format="PANDAS",
+ returns_table_row=False):
self._batch_format = batch_format
+ self._returns_table_row = returns_table_row
self._schema = schema
self._field_types = row_type.field_types()
self._timezone = timezone
@@ -446,6 +449,9 @@ cdef class ArrowCoderImpl(FieldCoderImpl):
batch_writer = pa.RecordBatchStreamWriter(self._resettable_io,
self._schema)
if self._batch_format == "ARROW":
batch = validate_arrow_batch(cols, self._schema, self._field_types)
+ elif self._returns_table_row:
+ batch = pandas_table_row_to_arrow(
+ self._schema, self._timezone, self._field_types[0], cols[0])
else:
batch = pandas_to_arrow(self._schema, self._timezone,
self._field_types, cols)
batch_writer.write_batch(batch)
diff --git a/flink-python/pyflink/fn_execution/coder_impl_slow.py
b/flink-python/pyflink/fn_execution/coder_impl_slow.py
index a7df600d8fb..bf66f6e4ad3 100644
--- a/flink-python/pyflink/fn_execution/coder_impl_slow.py
+++ b/flink-python/pyflink/fn_execution/coder_impl_slow.py
@@ -31,7 +31,8 @@ from pyflink.fn_execution.ResettableIO import ResettableIO
from pyflink.fn_execution.formats.avro import FlinkAvroDecoder,
FlinkAvroDatumReader, \
FlinkAvroBufferWrapper, FlinkAvroEncoder, FlinkAvroDatumWriter
from pyflink.fn_execution.stream_slow import InputStream, OutputStream
-from pyflink.table.utils import pandas_to_arrow, arrow_to_pandas,
validate_arrow_batch
+from pyflink.table.utils import pandas_to_arrow, arrow_to_pandas,
validate_arrow_batch, \
+ pandas_table_row_to_arrow
ROW_KIND_BIT_SIZE = 2
@@ -278,8 +279,10 @@ class ArrowCoderImpl(FieldCoderImpl):
A coder for arrow format data.
"""
- def __init__(self, schema, row_type, timezone, batch_format="PANDAS"):
+ def __init__(self, schema, row_type, timezone, batch_format="PANDAS",
+ returns_table_row=False):
self._batch_format = batch_format
+ self._returns_table_row = returns_table_row
self._schema = schema
self._field_types = row_type.field_types()
self._timezone = timezone
@@ -293,6 +296,9 @@ class ArrowCoderImpl(FieldCoderImpl):
batch_writer = pa.RecordBatchStreamWriter(self._resettable_io,
self._schema)
if self._batch_format == "ARROW":
batch = validate_arrow_batch(cols, self._schema, self._field_types)
+ elif self._returns_table_row:
+ batch = pandas_table_row_to_arrow(
+ self._schema, self._timezone, self._field_types[0], cols[0])
else:
batch = pandas_to_arrow(self._schema, self._timezone,
self._field_types, cols)
batch_writer.write_batch(batch)
diff --git a/flink-python/pyflink/fn_execution/coders.py
b/flink-python/pyflink/fn_execution/coders.py
index d75afcc1ff9..7003cfbb484 100644
--- a/flink-python/pyflink/fn_execution/coders.py
+++ b/flink-python/pyflink/fn_execution/coders.py
@@ -92,9 +92,15 @@ class LengthPrefixBaseCoder(ABC):
# are not recognized by pytz, so resolve the timezone only for
pandas batches.
timezone = (None if batch_format == "ARROW"
else
pytz.timezone(os.environ['TABLE_LOCAL_TIME_ZONE']))
- schema = create_arrow_schema(row_type.field_names(),
row_type.field_types(),
- allow_nested=batch_format == "ARROW")
- return ArrowCoder(schema, row_type, timezone, batch_format)
+ returns_table_row =
coder_info_descriptor_proto.arrow_type.returns_table_row
+ if batch_format == "PANDAS" and returns_table_row:
+ from pyflink.table.utils import create_pandas_table_row_schema
+
+ schema = create_pandas_table_row_schema(row_type)
+ else:
+ schema = create_arrow_schema(row_type.field_names(),
row_type.field_types(),
+ allow_nested=batch_format ==
"ARROW")
+ return ArrowCoder(schema, row_type, timezone, batch_format,
returns_table_row)
elif coder_info_descriptor_proto.HasField('over_window_arrow_type'):
timezone = pytz.timezone(os.environ['TABLE_LOCAL_TIME_ZONE'])
schema_proto =
coder_info_descriptor_proto.over_window_arrow_type.schema
@@ -243,15 +249,18 @@ class ArrowCoder(FieldCoder):
Coder for Arrow.
"""
- def __init__(self, schema, row_type, timezone, batch_format="PANDAS"):
+ def __init__(self, schema, row_type, timezone, batch_format="PANDAS",
+ returns_table_row=False):
self._batch_format = batch_format
+ self._returns_table_row = returns_table_row
self._schema = schema
self._row_type = row_type
self._timezone = timezone
def get_impl(self):
return coder_impl.ArrowCoderImpl(
- self._schema, self._row_type, self._timezone, self._batch_format)
+ self._schema, self._row_type, self._timezone, self._batch_format,
+ self._returns_table_row)
def __repr__(self):
return 'ArrowCoder[%s]' % self._schema
diff --git a/flink-python/pyflink/fn_execution/flink_fn_execution_pb2.py
b/flink-python/pyflink/fn_execution/flink_fn_execution_pb2.py
index 54016508148..70183d79f23 100644
--- a/flink-python/pyflink/fn_execution/flink_fn_execution_pb2.py
+++ b/flink-python/pyflink/fn_execution/flink_fn_execution_pb2.py
@@ -41,7 +41,7 @@ _sym_db = _symbol_database.Default()
-DESCRIPTOR =
_descriptor_pool.Default().AddSerializedFile(b'\n\x18\x66link-fn-execution.proto\x12
org.apache.flink.fn_execution.v1\"*\n\x0cJobParameter\x12\x0b\n\x03key\x18\x01
\x01(\t\x12\r\n\x05value\x18\x02
\x01(\t\"\x9a\x01\n\x05Input\x12\x44\n\x03udf\x18\x01
\x01(\x0b\x32\x35.org.apache.flink.fn_execution.v1.UserDefinedFunctionH\x00\x12\x15\n\x0binputOffset\x18\x02
\x01(\x05H\x00\x12\x17\n\rinputConstant\x18\x03
\x01(\x0cH\x00\x12\x12\n\x08refIndex\x18\x04 \x01(\x05H\x00\x42\x07\n\x [...]
+DESCRIPTOR =
_descriptor_pool.Default().AddSerializedFile(b'\n\x18\x66link-fn-execution.proto\x12
org.apache.flink.fn_execution.v1\"*\n\x0cJobParameter\x12\x0b\n\x03key\x18\x01
\x01(\t\x12\r\n\x05value\x18\x02
\x01(\t\"\x9a\x01\n\x05Input\x12\x44\n\x03udf\x18\x01
\x01(\x0b\x32\x35.org.apache.flink.fn_execution.v1.UserDefinedFunctionH\x00\x12\x15\n\x0binputOffset\x18\x02
\x01(\x05H\x00\x12\x17\n\rinputConstant\x18\x03
\x01(\x0cH\x00\x12\x12\n\x08refIndex\x18\x04 \x01(\x05H\x00\x42\x07\n\x [...]
_globals = globals()
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
@@ -150,19 +150,19 @@ if not _descriptor._USE_C_DESCRIPTORS:
_globals['_STATEDESCRIPTOR_STATETTLCONFIG_TTLTIMECHARACTERISTIC']._serialized_start=9709
_globals['_STATEDESCRIPTOR_STATETTLCONFIG_TTLTIMECHARACTERISTIC']._serialized_end=9752
_globals['_CODERINFODESCRIPTOR']._serialized_start=9755
- _globals['_CODERINFODESCRIPTOR']._serialized_end=10902
+ _globals['_CODERINFODESCRIPTOR']._serialized_end=10929
_globals['_CODERINFODESCRIPTOR_FLATTENROWTYPE']._serialized_start=10348
_globals['_CODERINFODESCRIPTOR_FLATTENROWTYPE']._serialized_end=10422
_globals['_CODERINFODESCRIPTOR_ROWTYPE']._serialized_start=10424
_globals['_CODERINFODESCRIPTOR_ROWTYPE']._serialized_end=10491
_globals['_CODERINFODESCRIPTOR_ARROWTYPE']._serialized_start=10494
- _globals['_CODERINFODESCRIPTOR_ARROWTYPE']._serialized_end=10700
-
_globals['_CODERINFODESCRIPTOR_ARROWTYPE_BATCHFORMAT']._serialized_start=10664
- _globals['_CODERINFODESCRIPTOR_ARROWTYPE_BATCHFORMAT']._serialized_end=10700
- _globals['_CODERINFODESCRIPTOR_OVERWINDOWARROWTYPE']._serialized_start=10702
- _globals['_CODERINFODESCRIPTOR_OVERWINDOWARROWTYPE']._serialized_end=10781
- _globals['_CODERINFODESCRIPTOR_RAWTYPE']._serialized_start=10783
- _globals['_CODERINFODESCRIPTOR_RAWTYPE']._serialized_end=10855
- _globals['_CODERINFODESCRIPTOR_MODE']._serialized_start=10857
- _globals['_CODERINFODESCRIPTOR_MODE']._serialized_end=10889
+ _globals['_CODERINFODESCRIPTOR_ARROWTYPE']._serialized_end=10727
+
_globals['_CODERINFODESCRIPTOR_ARROWTYPE_BATCHFORMAT']._serialized_start=10691
+ _globals['_CODERINFODESCRIPTOR_ARROWTYPE_BATCHFORMAT']._serialized_end=10727
+ _globals['_CODERINFODESCRIPTOR_OVERWINDOWARROWTYPE']._serialized_start=10729
+ _globals['_CODERINFODESCRIPTOR_OVERWINDOWARROWTYPE']._serialized_end=10808
+ _globals['_CODERINFODESCRIPTOR_RAWTYPE']._serialized_start=10810
+ _globals['_CODERINFODESCRIPTOR_RAWTYPE']._serialized_end=10882
+ _globals['_CODERINFODESCRIPTOR_MODE']._serialized_start=10884
+ _globals['_CODERINFODESCRIPTOR_MODE']._serialized_end=10916
# @@protoc_insertion_point(module_scope)
diff --git a/flink-python/pyflink/fn_execution/flink_fn_execution_pb2.pyi
b/flink-python/pyflink/fn_execution/flink_fn_execution_pb2.pyi
index 520077f395b..201882de23d 100644
--- a/flink-python/pyflink/fn_execution/flink_fn_execution_pb2.pyi
+++ b/flink-python/pyflink/fn_execution/flink_fn_execution_pb2.pyi
@@ -647,7 +647,7 @@ class CoderInfoDescriptor(_message.Message):
schema: Schema
def __init__(self, schema: _Optional[_Union[Schema, _Mapping]] = ...)
-> None: ...
class ArrowType(_message.Message):
- __slots__ = ("schema", "batch_format")
+ __slots__ = ("schema", "batch_format", "returns_table_row")
class BatchFormat(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
__slots__ = ()
PANDAS: _ClassVar[CoderInfoDescriptor.ArrowType.BatchFormat]
@@ -656,9 +656,11 @@ class CoderInfoDescriptor(_message.Message):
ARROW: CoderInfoDescriptor.ArrowType.BatchFormat
SCHEMA_FIELD_NUMBER: _ClassVar[int]
BATCH_FORMAT_FIELD_NUMBER: _ClassVar[int]
+ RETURNS_TABLE_ROW_FIELD_NUMBER: _ClassVar[int]
schema: Schema
batch_format: CoderInfoDescriptor.ArrowType.BatchFormat
- def __init__(self, schema: _Optional[_Union[Schema, _Mapping]] = ...,
batch_format: _Optional[_Union[CoderInfoDescriptor.ArrowType.BatchFormat, str]]
= ...) -> None: ...
+ returns_table_row: bool
+ def __init__(self, schema: _Optional[_Union[Schema, _Mapping]] = ...,
batch_format: _Optional[_Union[CoderInfoDescriptor.ArrowType.BatchFormat, str]]
= ..., returns_table_row: bool = ...) -> None: ...
class OverWindowArrowType(_message.Message):
__slots__ = ("schema",)
SCHEMA_FIELD_NUMBER: _ClassVar[int]
diff --git a/flink-python/pyflink/fn_execution/tests/test_coders.py
b/flink-python/pyflink/fn_execution/tests/test_coders.py
index 8cdb473b623..3bbc8cf859f 100644
--- a/flink-python/pyflink/fn_execution/tests/test_coders.py
+++ b/flink-python/pyflink/fn_execution/tests/test_coders.py
@@ -36,25 +36,49 @@ from pyflink.testing.test_case_utils import PyFlinkTestCase
class ArrowSchemaTests(unittest.TestCase):
- def test_nested_types_require_arrow_mode(self):
+ def test_pandas_positional_rows_require_matching_arity(self):
+ import pandas as pd
+ from pyflink.common import Row
+ from pyflink.table import DataTypes
+ from pyflink.table.types import create_arrow_schema
+ from pyflink.table.utils import pandas_to_arrow
+
+ row_type = DataTypes.ROW([
+ DataTypes.FIELD("a", DataTypes.BIGINT()),
+ DataTypes.FIELD("b", DataTypes.BIGINT()),
+ ])
+ for data_type, wrap in (
+ (row_type, lambda value: value),
+ (DataTypes.MAP(DataTypes.STRING(), row_type), lambda value:
{"key": value}),
+ ):
+ schema = create_arrow_schema(["value"], [data_type])
+ for value in ((1,), (1, 2, 3), [1], [1, 2, 3], Row(1), Row(1, 2,
3)):
+ with self.subTest(data_type=data_type, value=value):
+ with self.assertRaises(RuntimeError):
+ pandas_to_arrow(schema, pytz.UTC, [data_type],
+ [pd.Series([wrap(value)],
dtype=object)])
+
+ schema = create_arrow_schema(["value"], [row_type])
+ self.assertEqual(pandas_to_arrow(
+ schema, pytz.UTC, [row_type], [pd.Series([{"a": 1}], dtype=object)]
+ ).to_pylist(), [{"value": {"a": 1, "b": None}}])
+
+ def test_pandas_row_ltz_and_arrow_array_ltz_schemas(self):
from pyflink.table import DataTypes
from pyflink.table.types import create_arrow_schema
- for data_type, error, expected in (
- (DataTypes.ARRAY(DataTypes.ROW([DataTypes.FIELD("v",
DataTypes.INT())])), ValueError,
- pa.list_(pa.field("item", pa.struct([pa.field("v",
pa.int32())])))),
- (DataTypes.ARRAY(DataTypes.TIMESTAMP_LTZ(3)), ValueError,
pa.list_(pa.timestamp('ms'))),
- (DataTypes.ROW([DataTypes.FIELD("r", DataTypes.ROW([
- DataTypes.FIELD("v", DataTypes.INT())]))]), TypeError,
- pa.struct([pa.field("r", pa.struct([pa.field("v",
pa.int32())]))])),
- (DataTypes.ROW([DataTypes.FIELD("t",
DataTypes.TIMESTAMP_LTZ(3))]), TypeError,
- pa.struct([pa.field("t", pa.timestamp('ms'))])),
+ row = DataTypes.ROW([DataTypes.FIELD("v", DataTypes.BIGINT())])
+ for data_type, error in (
+ (DataTypes.ROW([DataTypes.FIELD("t",
DataTypes.TIMESTAMP_LTZ(3))]), TypeError),
+ (DataTypes.ROW([DataTypes.FIELD("inner", row)]), TypeError),
+ (DataTypes.ARRAY(row), ValueError),
+ (DataTypes.ARRAY(DataTypes.TIMESTAMP_LTZ(3)), ValueError),
):
with self.subTest(data_type=data_type):
with self.assertRaises(error):
create_arrow_schema(["value"], [data_type])
- schema = create_arrow_schema(["value"], [data_type],
allow_nested=True)
- self.assertEqual(schema.field("value").type, expected)
+ self.assertEqual(len(create_arrow_schema(["value"],
[data_type],
+ allow_nested=True)),
1)
def test_pandas_collection_schema_and_round_trip(self):
import pandas as pd
@@ -253,6 +277,71 @@ class ArrowCodersTests(unittest.TestCase):
result = coder.decode(coder.encode([values]))
self.assertEqual(result[0].tolist(), [[("value", 10)], [("value",
20)], None, []])
+ def test_pandas_table_row_results_round_trip(self):
+ import datetime
+ import pandas as pd
+ from pyflink.table import DataTypes
+ from pyflink.table.types import create_arrow_schema
+
+ result_type = DataTypes.ROW([
+ DataTypes.FIELD("ts", DataTypes.TIMESTAMP_LTZ(3)),
+ DataTypes.FIELD("payload", DataTypes.ROW([
+ DataTypes.FIELD("v", DataTypes.BIGINT()),
+ DataTypes.FIELD("other", DataTypes.BIGINT())])),
+ DataTypes.FIELD("other", DataTypes.ROW([DataTypes.FIELD("v",
DataTypes.STRING())])),
+ ])
+ timezone = pytz.timezone("Asia/Shanghai")
+ columns = [
+ pd.Series(pd.to_datetime(["2020-01-01T00:00:00Z",
None]).tz_convert(timezone)),
+ pd.DataFrame({"v": pd.Series([9007199254740993, None],
dtype=object),
+ "other": pd.Series([7, None], dtype=object)}),
+ pd.DataFrame({"v": ["other", None]}),
+ ]
+ cases = [
+ (columns, [
+ {"result": {"ts": datetime.datetime(2020, 1, 1, 8),
+ "payload": {"v": 9007199254740993, "other": 7},
+ "other": {"v": "other"}}},
+ {"result": {"ts": None, "payload": {"v": None, "other": None},
+ "other": {"v": None}}},
+ ]),
+ ([column.head(0) for column in columns], []),
+ ]
+ for result, expected in cases:
+ with self.subTest(empty_batch=not expected):
+ row_type = DataTypes.ROW([DataTypes.FIELD("result",
result_type)])
+ schema = create_arrow_schema(row_type.field_names(),
row_type.field_types(),
+ allow_nested=True)
+ coder = self.implementation.ArrowCoderImpl(
+ schema, row_type, timezone, "PANDAS", True)
+ restored = self.arrow_coder(schema, row_type).decode(
+ coder.encode([pd.concat(result, axis=1)])).to_pylist()
+ self.assertEqual(restored, expected)
+
+ def test_pandas_row_collections_round_trip(self):
+ import numpy as np
+ import pandas as pd
+ from pyflink.table import DataTypes
+ from pyflink.table.types import create_arrow_schema
+
+ mapping = DataTypes.MAP(DataTypes.STRING(), DataTypes.BIGINT())
+ row_type = DataTypes.ROW([
+ DataTypes.FIELD("payload",
DataTypes.ROW([DataTypes.FIELD("payload", mapping)])),
+ DataTypes.FIELD("legacy", DataTypes.ARRAY(DataTypes.BIGINT()))])
+ schema = create_arrow_schema(row_type.field_names(),
row_type.field_types())
+ coder = self.implementation.ArrowCoderImpl(schema, row_type, pytz.UTC)
+ columns = [pd.Series([{"payload": [("x", 1), ("y", 2)]}, {"payload":
[]}]),
+ pd.Series([[1, None], []])]
+ encoded = coder.encode(columns)
+ restored = coder.decode(encoded)
+ self.assertEqual(restored[0]["payload"].tolist(), [[("x", 1), ("y",
2)], []])
+ self.assertEqual(restored[1].iloc[0].dtype, np.dtype("float64"))
+ self.assertEqual(restored[1].iloc[0][0], 1.0)
+ self.assertTrue(np.isnan(restored[1].iloc[0][1]))
+ arrow_coder = self.arrow_coder(schema, row_type)
+
self.assertEqual(arrow_coder.decode(coder.encode(restored)).to_pylist(),
+ arrow_coder.decode(encoded).to_pylist())
+
def test_inferred_struct_nullability(self):
from pyflink.table import DataTypes
from pyflink.table.types import create_arrow_schema
diff --git a/flink-python/pyflink/proto/flink-fn-execution.proto
b/flink-python/pyflink/proto/flink-fn-execution.proto
index a9300ba4949..b2a3bcc2e62 100644
--- a/flink-python/pyflink/proto/flink-fn-execution.proto
+++ b/flink-python/pyflink/proto/flink-fn-execution.proto
@@ -534,6 +534,8 @@ message CoderInfoDescriptor {
ARROW = 1;
}
BatchFormat batch_format = 2;
+ // The sole output field is a result ROW containing physical table columns.
+ bool returns_table_row = 3;
}
// only used in batch over window
diff --git a/flink-python/pyflink/table/table.py
b/flink-python/pyflink/table/table.py
index 820600efd63..6195d560817 100644
--- a/flink-python/pyflink/table/table.py
+++ b/flink-python/pyflink/table/table.py
@@ -34,7 +34,8 @@ from pyflink.table.table_schema import TableSchema
from pyflink.table.catalog import ResolvedSchema
from pyflink.table.types import create_arrow_schema
from pyflink.table.udf import UserDefinedScalarFunctionWrapper, \
- UserDefinedAggregateFunctionWrapper, UserDefinedTableFunctionWrapper
+ UserDefinedAggregateFunctionWrapper, UserDefinedTableFunctionWrapper, \
+ UserDefinedAsyncScalarFunctionWrapper
from pyflink.table.utils import tz_convert_from_internal, to_expression_jarray
from pyflink.table.window import OverWindow, GroupWindow
@@ -770,7 +771,11 @@ class Table(object):
return Table(self._j_table.dropColumns(to_expression_jarray(fields)),
self._t_env)
- def map(self, func: Union[Expression, UserDefinedScalarFunctionWrapper])
-> 'Table':
+ def map(
+ self,
+ func: Union[Expression, UserDefinedScalarFunctionWrapper,
+ UserDefinedAsyncScalarFunctionWrapper],
+ ) -> 'Table':
"""
Performs a map operation with a user-defined scalar function.
diff --git a/flink-python/pyflink/table/tests/test_pandas_udf.py
b/flink-python/pyflink/table/tests/test_pandas_udf.py
index 84d25d4307d..bdc1d565f64 100644
--- a/flink-python/pyflink/table/tests/test_pandas_udf.py
+++ b/flink-python/pyflink/table/tests/test_pandas_udf.py
@@ -22,7 +22,7 @@ import pytz
from pyflink.common import Row
from pyflink.table import DataTypes
from pyflink.table.tests.test_udf import SubtractOne
-from pyflink.table.udf import udf
+from pyflink.table.udf import ScalarFunction, udf
from pyflink.testing import source_sink_utils
from pyflink.testing.test_case_utils import PyFlinkBatchTableTestCase, \
PyFlinkStreamTableTestCase, PyFlinkTestCase
@@ -399,6 +399,42 @@ class PandasUDFITTests(object):
[list(r) for r in
self.t_env.sql_query(sql).execute().collect()])
self.assertEqual(actual, expected, sql)
+ import pandas as pd
+
+ class Seven(ScalarFunction):
+ def eval(self):
+ return pd.Series([7])
+
+ zero_argument = udf(Seven(), result_type=DataTypes.BIGINT(),
+ func_type="pandas")
+ batch_size =
self.t_env.get_config().get("python.fn-execution.arrow.batch.size", "1000")
+ try:
+
self.t_env.get_config().set("python.fn-execution.arrow.batch.size", "1")
+ result = t.select(plus_one(t.a),
zero_argument()).execute().collect()
+ self.assertEqual(sorted(result, key=lambda row: row[0]),
+ [Row(2, 7), Row(3, 7), Row(4, 7)])
+ finally:
+
self.t_env.get_config().set("python.fn-execution.arrow.batch.size", batch_size)
+
+ import numpy as np
+
+ square_root = udf(np.sqrt, result_type=DataTypes.DOUBLE(),
func_type="pandas")
+ nullable = self.t_env.from_elements(
+ [(1,), (None,), (4,)], DataTypes.ROW([DataTypes.FIELD("v",
DataTypes.BIGINT())]))
+
self.assertEqual(list(nullable.select(square_root(nullable.v)).execute().collect()),
+ [Row(1.0), Row(None), Row(2.0)])
+
+ result_type = DataTypes.MAP(DataTypes.STRING().not_null(),
DataTypes.ROW([
+ DataTypes.FIELD("v", DataTypes.BIGINT())]))
+ for as_pairs in (False, True):
+ def produce_rows(values):
+ return pd.Series([{"key": [("v", int(value))] if as_pairs
+ else {"v": int(value)}} for value in
values])
+
+ rows = udf(produce_rows, result_type=result_type,
func_type="pandas")
+ self.assertEqual(list(t.select(rows(t.a)).execute().collect()),
+ [Row({"key": Row(1)}), Row({"key": Row(2)}),
Row({"key": Row(3)})])
+
def test_data_types(self):
import pandas as pd
diff --git a/flink-python/pyflink/table/tests/test_row_based_operation.py
b/flink-python/pyflink/table/tests/test_row_based_operation.py
index 06d8008ecfe..dcbb11828c0 100644
--- a/flink-python/pyflink/table/tests/test_row_based_operation.py
+++ b/flink-python/pyflink/table/tests/test_row_based_operation.py
@@ -25,7 +25,29 @@ from pyflink.table.types import DataTypes
from pyflink.table.udf import udf, udtf, udaf, AggregateFunction,
TableAggregateFunction, udtaf
from pyflink.testing import source_sink_utils
from pyflink.testing.test_case_utils import PyFlinkBatchTableTestCase, \
- PyFlinkStreamTableTestCase
+ PyFlinkStreamTableTestCase, PyFlinkUTTestCase
+
+
+class RowBasedOperationPlanningTests(PyFlinkUTTestCase):
+ def test_async_map_resolves_scalar_and_row_outputs(self):
+ table = self.t_env.from_elements([(1,)], ["input"])
+
+ async def identity(value):
+ return value
+
+ for result_type, names, types in (
+ (DataTypes.BIGINT(), ["f0"], [DataTypes.BIGINT()]),
+ (DataTypes.ROW([DataTypes.FIELD("label", DataTypes.STRING()),
+ DataTypes.FIELD("value", DataTypes.BIGINT())]),
+ ["label", "value"], [DataTypes.STRING(), DataTypes.BIGINT()]),
+ ):
+ for as_expression in (True, False):
+ with self.subTest(result_type=result_type,
as_expression=as_expression):
+ function = udf(identity, result_type=result_type)
+ result = table.map(function(table.input) if as_expression
else function)
+ schema = result.get_resolved_schema()
+ self.assertEqual(list(schema.get_column_names()), names)
+ self.assertEqual(schema.get_column_data_types(), types)
class RowBasedOperationTests(object):
diff --git a/flink-python/pyflink/table/udf.py
b/flink-python/pyflink/table/udf.py
index 733b8a79178..222cfb3be17 100644
--- a/flink-python/pyflink/table/udf.py
+++ b/flink-python/pyflink/table/udf.py
@@ -483,6 +483,7 @@ class UserDefinedFunctionWrapper(object):
self._func_type = func_type
self._judf_placeholder = None
self._takes_row_as_input = False
+ self._returns_table_row = False
def __call__(self, *args) -> Expression:
from pyflink.table import expressions as expr
@@ -570,6 +571,7 @@ class
UserDefinedScalarFunctionWrapper(UserDefinedFunctionWrapper):
j_function_kind,
self._deterministic,
self._takes_row_as_input,
+ self._returns_table_row,
_get_python_env())
return j_scalar_function
@@ -609,6 +611,7 @@ class
UserDefinedAsyncScalarFunctionWrapper(UserDefinedFunctionWrapper):
j_function_kind,
self._deterministic,
self._takes_row_as_input,
+ self._returns_table_row,
_get_python_env())
return j_async_scalar_function
diff --git a/flink-python/pyflink/table/utils.py
b/flink-python/pyflink/table/utils.py
index be1e71f64b2..7001dc9dc9a 100644
--- a/flink-python/pyflink/table/utils.py
+++ b/flink-python/pyflink/table/utils.py
@@ -22,7 +22,7 @@ from pyflink.common.types import RowKind
from pyflink.java_gateway import get_gateway
from pyflink.table.types import DataType, LocalZonedTimestampType, Row,
RowType, \
- TimeType, DateType, ArrayType, MapType, TimestampType, FloatType, RawType
+ TimeType, DateType, ArrayType, MapType, TimestampType, FloatType, RawType,
create_arrow_schema
from pyflink.util.api_stability_decorators import Internal
from pyflink.util.java_utils import to_jarray
import datetime
@@ -160,6 +160,44 @@ def _get_offsets(column):
[None, column.buffers()[1]],
offset=column.offset)
+@Internal()
+def create_pandas_table_row_schema(row_type):
+ import pyarrow as pa
+
+ if len(row_type) != 1 or not isinstance(row_type[0].data_type, RowType):
+ raise TypeError("A table-row result must have exactly one ROW field.")
+ field = row_type[0]
+ # Validate its children as physical columns, not as fields of a user ROW
column.
+ child_schema = create_arrow_schema(
+ field.data_type.field_names(), field.data_type.field_types())
+ return pa.schema([pa.field(field.name, pa.struct(list(child_schema)),
+ nullable=field.data_type._nullable)])
+
+
+@Internal()
+def pandas_table_row_to_arrow(schema, timezone, row_type, dataframe):
+ import pyarrow as pa
+
+ field_types = row_type.field_types()
+ widths = [len(field_type) if isinstance(field_type, RowType) else 1
+ for field_type in field_types]
+ field_count = sum(widths)
+ if dataframe.shape[1] != field_count:
+ raise ValueError(f"Pandas table-row result has {dataframe.shape[1]}
columns, "
+ f"expected {field_count}.")
+ columns = []
+ offset = 0
+ for field_type, width in zip(field_types, widths):
+ # Schema positions retain ROW boundaries even when flattened names are
duplicated.
+ columns.append(dataframe.iloc[:, offset:offset + width]
+ if isinstance(field_type, RowType) else
dataframe.iloc[:, offset])
+ offset += width
+ batch = pandas_to_arrow(
+ pa.schema(list(schema[0].type)), timezone, field_types, columns)
+ array = pa.StructArray.from_arrays(batch.columns,
fields=list(schema[0].type))
+ return pa.RecordBatch.from_arrays([array], schema=schema)
+
+
@Internal()
def pandas_to_arrow(schema, timezone, field_types, series):
import pyarrow as pa
diff --git
a/flink-python/src/main/java/org/apache/flink/python/util/ProtoUtils.java
b/flink-python/src/main/java/org/apache/flink/python/util/ProtoUtils.java
index 6da72d75bfc..af45fd9c804 100644
--- a/flink-python/src/main/java/org/apache/flink/python/util/ProtoUtils.java
+++ b/flink-python/src/main/java/org/apache/flink/python/util/ProtoUtils.java
@@ -121,12 +121,23 @@ public enum ProtoUtils {
FlinkFnApi.CoderInfoDescriptor.Mode mode,
boolean separatedWithEndMessage,
FlinkFnApi.CoderInfoDescriptor.ArrowType.BatchFormat batchFormat) {
+ return createArrowTypeCoderInfoDescriptorProto(
+ rowType, mode, separatedWithEndMessage, batchFormat, false);
+ }
+
+ public static FlinkFnApi.CoderInfoDescriptor
createArrowTypeCoderInfoDescriptorProto(
+ RowType rowType,
+ FlinkFnApi.CoderInfoDescriptor.Mode mode,
+ boolean separatedWithEndMessage,
+ FlinkFnApi.CoderInfoDescriptor.ArrowType.BatchFormat batchFormat,
+ boolean returnsTableRow) {
return createCoderInfoDescriptorProto(
null,
null,
FlinkFnApi.CoderInfoDescriptor.ArrowType.newBuilder()
.setSchema(toProtoType(rowType).getRowSchema())
.setBatchFormat(batchFormat)
+ .setReturnsTableRow(returnsTableRow)
.build(),
null,
null,
diff --git
a/flink-python/src/main/java/org/apache/flink/table/runtime/operators/python/scalar/AbstractPythonScalarFunctionOperator.java
b/flink-python/src/main/java/org/apache/flink/table/runtime/operators/python/scalar/AbstractPythonScalarFunctionOperator.java
index 571b7148e14..a5d7bcef8f8 100644
---
a/flink-python/src/main/java/org/apache/flink/table/runtime/operators/python/scalar/AbstractPythonScalarFunctionOperator.java
+++
b/flink-python/src/main/java/org/apache/flink/table/runtime/operators/python/scalar/AbstractPythonScalarFunctionOperator.java
@@ -159,6 +159,20 @@ public abstract class AbstractPythonScalarFunctionOperator
config.get(PYTHON_PROFILE_ENABLED));
}
+ protected boolean returnsTableRow() {
+ final int outputCount =
+ udfOutputIndices == null ? scalarFunctions.length :
udfOutputIndices.length;
+ for (int i = 0; i < outputCount; i++) {
+ final int functionIndex = udfOutputIndices == null ? i :
udfOutputIndices[i];
+ if
(scalarFunctions[functionIndex].getPythonFunction().returnsTableRow()) {
+ Preconditions.checkArgument(
+ outputCount == 1, "A table-row result must be the sole
UDF output.");
+ return true;
+ }
+ }
+ return false;
+ }
+
@Override
public String getFunctionUrn() {
return SCALAR_FUNCTION_URN;
diff --git
a/flink-python/src/main/java/org/apache/flink/table/runtime/operators/python/scalar/arrow/ArrowPythonScalarFunctionOperator.java
b/flink-python/src/main/java/org/apache/flink/table/runtime/operators/python/scalar/arrow/ArrowPythonScalarFunctionOperator.java
index bee19392571..6e4d77eab2f 100644
---
a/flink-python/src/main/java/org/apache/flink/table/runtime/operators/python/scalar/arrow/ArrowPythonScalarFunctionOperator.java
+++
b/flink-python/src/main/java/org/apache/flink/table/runtime/operators/python/scalar/arrow/ArrowPythonScalarFunctionOperator.java
@@ -115,7 +115,11 @@ public class ArrowPythonScalarFunctionOperator extends
AbstractPythonScalarFunct
public FlinkFnApi.CoderInfoDescriptor createOutputCoderInfoDescriptor(
RowType runnerOutputType) {
return createArrowTypeCoderInfoDescriptorProto(
- runnerOutputType, FlinkFnApi.CoderInfoDescriptor.Mode.SINGLE,
false, batchFormat);
+ runnerOutputType,
+ FlinkFnApi.CoderInfoDescriptor.Mode.SINGLE,
+ false,
+ batchFormat,
+ returnsTableRow());
}
@Override
diff --git
a/flink-table/flink-table-api-java/src/main/java/org/apache/flink/table/operations/utils/OperationTreeBuilder.java
b/flink-table/flink-table-api-java/src/main/java/org/apache/flink/table/operations/utils/OperationTreeBuilder.java
index 28978469b6e..1170cdecd22 100644
---
a/flink-table/flink-table-api-java/src/main/java/org/apache/flink/table/operations/utils/OperationTreeBuilder.java
+++
b/flink-table/flink-table-api-java/src/main/java/org/apache/flink/table/operations/utils/OperationTreeBuilder.java
@@ -552,7 +552,8 @@ public final class OperationTreeBuilder {
final ExpressionResolver resolver = getResolverBuilder(child).build();
final ResolvedExpression resolvedCall =
resolveSingleExpression(mapFunction, resolver);
- if (!isFunctionOfKind(resolvedCall, FunctionKind.SCALAR)) {
+ if (!isFunctionOfKind(resolvedCall, FunctionKind.SCALAR)
+ && !isFunctionOfKind(resolvedCall, FunctionKind.ASYNC_SCALAR))
{
throw new ValidationException(
"Only a scalar function can be used in the map operator.");
}
diff --git
a/flink-table/flink-table-common/src/main/java/org/apache/flink/table/functions/python/PythonAsyncScalarFunction.java
b/flink-table/flink-table-common/src/main/java/org/apache/flink/table/functions/python/PythonAsyncScalarFunction.java
index 85c166424c0..e2498ab50a1 100644
---
a/flink-table/flink-table-common/src/main/java/org/apache/flink/table/functions/python/PythonAsyncScalarFunction.java
+++
b/flink-table/flink-table-common/src/main/java/org/apache/flink/table/functions/python/PythonAsyncScalarFunction.java
@@ -43,6 +43,7 @@ public class PythonAsyncScalarFunction extends
AsyncScalarFunction implements Py
private final boolean deterministic;
private final PythonEnv pythonEnv;
private final boolean takesRowAsInput;
+ private final boolean returnsTableRow;
private DataType[] inputTypes;
private String[] inputTypesString;
@@ -61,9 +62,32 @@ public class PythonAsyncScalarFunction extends
AsyncScalarFunction implements Py
this(
name,
serializedAsyncScalarFunction,
+ inputTypes,
+ resultType,
pythonFunctionKind,
deterministic,
takesRowAsInput,
+ false,
+ pythonEnv);
+ }
+
+ public PythonAsyncScalarFunction(
+ String name,
+ byte[] serializedAsyncScalarFunction,
+ DataType[] inputTypes,
+ DataType resultType,
+ PythonFunctionKind pythonFunctionKind,
+ boolean deterministic,
+ boolean takesRowAsInput,
+ boolean returnsTableRow,
+ PythonEnv pythonEnv) {
+ this(
+ name,
+ serializedAsyncScalarFunction,
+ pythonFunctionKind,
+ deterministic,
+ takesRowAsInput,
+ returnsTableRow,
pythonEnv);
this.inputTypes = inputTypes;
this.resultType = resultType;
@@ -81,9 +105,32 @@ public class PythonAsyncScalarFunction extends
AsyncScalarFunction implements Py
this(
name,
serializedAsyncScalarFunction,
+ inputTypesString,
+ resultTypeString,
pythonFunctionKind,
deterministic,
takesRowAsInput,
+ false,
+ pythonEnv);
+ }
+
+ public PythonAsyncScalarFunction(
+ String name,
+ byte[] serializedAsyncScalarFunction,
+ String[] inputTypesString,
+ String resultTypeString,
+ PythonFunctionKind pythonFunctionKind,
+ boolean deterministic,
+ boolean takesRowAsInput,
+ boolean returnsTableRow,
+ PythonEnv pythonEnv) {
+ this(
+ name,
+ serializedAsyncScalarFunction,
+ pythonFunctionKind,
+ deterministic,
+ takesRowAsInput,
+ returnsTableRow,
pythonEnv);
this.inputTypesString = inputTypesString;
this.resultTypeString = resultTypeString;
@@ -96,12 +143,31 @@ public class PythonAsyncScalarFunction extends
AsyncScalarFunction implements Py
boolean deterministic,
boolean takesRowAsInput,
PythonEnv pythonEnv) {
+ this(
+ name,
+ serializedAsyncScalarFunction,
+ pythonFunctionKind,
+ deterministic,
+ takesRowAsInput,
+ false,
+ pythonEnv);
+ }
+
+ public PythonAsyncScalarFunction(
+ String name,
+ byte[] serializedAsyncScalarFunction,
+ PythonFunctionKind pythonFunctionKind,
+ boolean deterministic,
+ boolean takesRowAsInput,
+ boolean returnsTableRow,
+ PythonEnv pythonEnv) {
this.name = name;
this.serializedAsyncScalarFunction = serializedAsyncScalarFunction;
this.pythonFunctionKind = pythonFunctionKind;
this.deterministic = deterministic;
this.pythonEnv = pythonEnv;
this.takesRowAsInput = takesRowAsInput;
+ this.returnsTableRow = returnsTableRow;
}
public void eval(CompletableFuture<Object> future, Object... args) {
@@ -129,6 +195,11 @@ public class PythonAsyncScalarFunction extends
AsyncScalarFunction implements Py
return takesRowAsInput;
}
+ @Override
+ public boolean returnsTableRow() {
+ return returnsTableRow;
+ }
+
@Override
public boolean isDeterministic() {
return deterministic;
diff --git
a/flink-table/flink-table-common/src/main/java/org/apache/flink/table/functions/python/PythonFunction.java
b/flink-table/flink-table-common/src/main/java/org/apache/flink/table/functions/python/PythonFunction.java
index a1a77764cdb..482dad404c9 100644
---
a/flink-table/flink-table-common/src/main/java/org/apache/flink/table/functions/python/PythonFunction.java
+++
b/flink-table/flink-table-common/src/main/java/org/apache/flink/table/functions/python/PythonFunction.java
@@ -44,4 +44,9 @@ public interface PythonFunction extends Serializable {
default boolean takesRowAsInput() {
return false;
}
+
+ /** Whether the result ROW contains the physical columns of an output
table. */
+ default boolean returnsTableRow() {
+ return false;
+ }
}
diff --git
a/flink-table/flink-table-common/src/main/java/org/apache/flink/table/functions/python/PythonScalarFunction.java
b/flink-table/flink-table-common/src/main/java/org/apache/flink/table/functions/python/PythonScalarFunction.java
index 9d14db265f9..59c2f3594a9 100644
---
a/flink-table/flink-table-common/src/main/java/org/apache/flink/table/functions/python/PythonScalarFunction.java
+++
b/flink-table/flink-table-common/src/main/java/org/apache/flink/table/functions/python/PythonScalarFunction.java
@@ -44,6 +44,7 @@ public class PythonScalarFunction extends ScalarFunction
implements PythonFuncti
private final boolean deterministic;
private final PythonEnv pythonEnv;
private final boolean takesRowAsInput;
+ private final boolean returnsTableRow;
private DataType[] inputTypes;
private String[] inputTypesString;
@@ -62,9 +63,32 @@ public class PythonScalarFunction extends ScalarFunction
implements PythonFuncti
this(
name,
serializedScalarFunction,
+ inputTypes,
+ resultType,
pythonFunctionKind,
deterministic,
takesRowAsInput,
+ false,
+ pythonEnv);
+ }
+
+ public PythonScalarFunction(
+ String name,
+ byte[] serializedScalarFunction,
+ DataType[] inputTypes,
+ DataType resultType,
+ PythonFunctionKind pythonFunctionKind,
+ boolean deterministic,
+ boolean takesRowAsInput,
+ boolean returnsTableRow,
+ PythonEnv pythonEnv) {
+ this(
+ name,
+ serializedScalarFunction,
+ pythonFunctionKind,
+ deterministic,
+ takesRowAsInput,
+ returnsTableRow,
pythonEnv);
this.inputTypes = inputTypes;
this.resultType = resultType;
@@ -82,9 +106,32 @@ public class PythonScalarFunction extends ScalarFunction
implements PythonFuncti
this(
name,
serializedScalarFunction,
+ inputTypesString,
+ resultTypeString,
pythonFunctionKind,
deterministic,
takesRowAsInput,
+ false,
+ pythonEnv);
+ }
+
+ public PythonScalarFunction(
+ String name,
+ byte[] serializedScalarFunction,
+ String[] inputTypesString,
+ String resultTypeString,
+ PythonFunctionKind pythonFunctionKind,
+ boolean deterministic,
+ boolean takesRowAsInput,
+ boolean returnsTableRow,
+ PythonEnv pythonEnv) {
+ this(
+ name,
+ serializedScalarFunction,
+ pythonFunctionKind,
+ deterministic,
+ takesRowAsInput,
+ returnsTableRow,
pythonEnv);
this.inputTypesString = inputTypesString;
this.resultTypeString = resultTypeString;
@@ -97,12 +144,31 @@ public class PythonScalarFunction extends ScalarFunction
implements PythonFuncti
boolean deterministic,
boolean takesRowAsInput,
PythonEnv pythonEnv) {
+ this(
+ name,
+ serializedScalarFunction,
+ pythonFunctionKind,
+ deterministic,
+ takesRowAsInput,
+ false,
+ pythonEnv);
+ }
+
+ public PythonScalarFunction(
+ String name,
+ byte[] serializedScalarFunction,
+ PythonFunctionKind pythonFunctionKind,
+ boolean deterministic,
+ boolean takesRowAsInput,
+ boolean returnsTableRow,
+ PythonEnv pythonEnv) {
this.name = name;
this.serializedScalarFunction = serializedScalarFunction;
this.pythonFunctionKind = pythonFunctionKind;
this.deterministic = deterministic;
this.pythonEnv = pythonEnv;
this.takesRowAsInput = takesRowAsInput;
+ this.returnsTableRow = returnsTableRow;
}
public Object eval(Object... args) {
@@ -130,6 +196,11 @@ public class PythonScalarFunction extends ScalarFunction
implements PythonFuncti
return takesRowAsInput;
}
+ @Override
+ public boolean returnsTableRow() {
+ return returnsTableRow;
+ }
+
@Override
public boolean isDeterministic() {
return deterministic;
diff --git
a/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/plan/rules/logical/PythonMapMergeRule.java
b/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/plan/rules/logical/PythonMapMergeRule.java
index c93c643301e..27a953415ba 100644
---
a/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/plan/rules/logical/PythonMapMergeRule.java
+++
b/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/plan/rules/logical/PythonMapMergeRule.java
@@ -20,6 +20,7 @@ package org.apache.flink.table.planner.plan.rules.logical;
import org.apache.flink.table.functions.python.PythonFunctionKind;
import org.apache.flink.table.planner.plan.nodes.logical.FlinkLogicalCalc;
+import org.apache.flink.table.planner.plan.utils.AsyncUtil;
import org.apache.flink.table.planner.plan.utils.PythonUtil;
import org.apache.calcite.plan.RelOptRule;
@@ -87,6 +88,13 @@ public class PythonMapMergeRule extends RelOptRule {
return false;
}
+ // Async calls are split into separate operators. Removing the flatten
calc would
+ // make a subsequent row-input function receive the enclosing row
instead of its fields.
+ if (AsyncUtil.containsAsyncCall(topProjects.get(0))
+ || AsyncUtil.containsAsyncCall(bottomProjects.get(0))) {
+ return false;
+ }
+
// only Python functions with same Python function kind can be merged
together.
if (Arrays.stream(PythonFunctionKind.values())
.noneMatch(
diff --git
a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/rules/logical/PythonCalcSplitFunctionKindRuleTest.java
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/rules/logical/PythonCalcSplitFunctionKindRuleTest.java
index 09cc9d10dd8..4f52355d586 100644
---
a/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/rules/logical/PythonCalcSplitFunctionKindRuleTest.java
+++
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/rules/logical/PythonCalcSplitFunctionKindRuleTest.java
@@ -21,6 +21,7 @@ package org.apache.flink.table.planner.plan.rules.logical;
import org.apache.flink.table.api.DataTypes;
import org.apache.flink.table.api.Schema;
import org.apache.flink.table.api.Table;
+import org.apache.flink.table.functions.python.PythonAsyncScalarFunction;
import org.apache.flink.table.functions.python.PythonEnv;
import org.apache.flink.table.functions.python.PythonFunctionKind;
import org.apache.flink.table.functions.python.PythonScalarFunction;
@@ -59,6 +60,42 @@ class PythonCalcSplitFunctionKindRuleTest extends
TableTestBase {
verifyMapComposition(javaBatchTestUtil());
}
+ @Test
+ void testStreamingAsyncMapComposition() {
+ verifyAsyncMapComposition(javaStreamTestUtil());
+ }
+
+ @Test
+ void testBatchAsyncMapComposition() {
+ verifyAsyncMapComposition(javaBatchTestUtil());
+ }
+
+ private void verifyAsyncMapComposition(JavaTableTestUtil util) {
+ util.addTableSource("T", Schema.newBuilder().column("a",
DataTypes.INT()).build());
+ final DataType resultType = DataTypes.ROW(DataTypes.FIELD("a",
DataTypes.INT()));
+ registerFunctions(util, resultType, true);
+ util.tableEnv()
+ .createTemporarySystemFunction(
+ "async_udf",
+ new PythonAsyncScalarFunction(
+ "async_udf",
+ new byte[0],
+ new DataType[] {DataTypes.INT()},
+ resultType,
+ PythonFunctionKind.GENERAL,
+ true,
+ true,
+ new PythonEnv(PythonEnv.ExecType.PROCESS)));
+ final Table result =
+ util.tableEnv()
+ .from("T")
+ .map(call("general_udf", $("a")))
+ .map(call("async_udf", $("a")))
+ .map(call("async_udf", $("a")))
+ .map(call("general_udf", $("a")));
+ util.verifyRelPlan(result);
+ }
+
private void verifyComposition(JavaTableTestUtil util) {
util.addTableSource("T", Schema.newBuilder().column("a",
DataTypes.INT()).build());
registerFunctions(util, DataTypes.INT(), false);
diff --git
a/flink-table/flink-table-planner/src/test/resources/org/apache/flink/table/planner/plan/rules/logical/PythonCalcSplitFunctionKindRuleTest.xml
b/flink-table/flink-table-planner/src/test/resources/org/apache/flink/table/planner/plan/rules/logical/PythonCalcSplitFunctionKindRuleTest.xml
index 021138e012a..5cb697af821 100644
---
a/flink-table/flink-table-planner/src/test/resources/org/apache/flink/table/planner/plan/rules/logical/PythonCalcSplitFunctionKindRuleTest.xml
+++
b/flink-table/flink-table-planner/src/test/resources/org/apache/flink/table/planner/plan/rules/logical/PythonCalcSplitFunctionKindRuleTest.xml
@@ -16,6 +16,27 @@ See the License for the specific language governing
permissions and
limitations under the License.
-->
<Root>
+ <TestCase name="testBatchAsyncMapComposition">
+ <Resource name="ast">
+ <![CDATA[
+LogicalProject(a=[AS(general_udf(AS(async_udf(AS(async_udf(AS(general_udf($0).a,
_UTF-16LE'a')).a, _UTF-16LE'a')).a, _UTF-16LE'a')).a, _UTF-16LE'a')])
++- LogicalTableScan(table=[[default_catalog, default_database, T]])
+]]>
+ </Resource>
+ <Resource name="optimized rel plan">
+ <![CDATA[
+Calc(select=[f0.a AS a])
++- PythonCalc(select=[general_udf(a) AS f0])
+ +- Calc(select=[f0.a AS a])
+ +- PythonAsyncCalc(select=[async_udf(a) AS f0])
+ +- Calc(select=[f0.a AS a])
+ +- PythonAsyncCalc(select=[async_udf(a) AS f0])
+ +- Calc(select=[f0.a AS a])
+ +- PythonCalc(select=[general_udf(a) AS f0])
+ +- TableSourceScan(table=[[default_catalog,
default_database, T]], fields=[a])
+]]>
+ </Resource>
+ </TestCase>
<TestCase name="testBatchComposition">
<Resource name="sql">
<![CDATA[SELECT arrow_udf(a), pandas_udf(a), general_udf(a),
arrow_udf(arrow_udf(a)), arrow_udf(pandas_udf(a)), pandas_udf(arrow_udf(a)),
arrow_udf(general_udf(a)), general_udf(arrow_udf(a)) FROM T]]>
@@ -37,6 +58,27 @@ Calc(select=[f0 AS EXPR$0, f1 AS EXPR$1, f00 AS EXPR$2, f2
AS EXPR$3, f3 AS EXPR
+- PythonCalc(select=[a, f0, general_udf(a) AS f1])
+- PythonCalc(select=[a, pandas_udf(a) AS f0])
+- TableSourceScan(table=[[default_catalog,
default_database, T]], fields=[a])
+]]>
+ </Resource>
+ </TestCase>
+ <TestCase name="testStreamingAsyncMapComposition">
+ <Resource name="ast">
+ <![CDATA[
+LogicalProject(a=[AS(general_udf(AS(async_udf(AS(async_udf(AS(general_udf($0).a,
_UTF-16LE'a')).a, _UTF-16LE'a')).a, _UTF-16LE'a')).a, _UTF-16LE'a')])
++- LogicalTableScan(table=[[default_catalog, default_database, T]])
+]]>
+ </Resource>
+ <Resource name="optimized rel plan">
+ <![CDATA[
+Calc(select=[f0.a AS a])
++- PythonCalc(select=[general_udf(a) AS f0])
+ +- Calc(select=[f0.a AS a])
+ +- PythonAsyncCalc(select=[async_udf(a) AS f0])
+ +- Calc(select=[f0.a AS a])
+ +- PythonAsyncCalc(select=[async_udf(a) AS f0])
+ +- Calc(select=[f0.a AS a])
+ +- PythonCalc(select=[general_udf(a) AS f0])
+ +- TableSourceScan(table=[[default_catalog,
default_database, T]], fields=[a])
]]>
</Resource>
</TestCase>