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 4eb8666832f [FLINK-40472][python] Support arrow vectorized UDFs in
DataFrame API (#29210)
4eb8666832f is described below
commit 4eb8666832f7fb6fd0a08c01ac93ed135b1dc11d
Author: Liu Liu <[email protected]>
AuthorDate: Fri Sep 18 21:43:39 2026 +0800
[FLINK-40472][python] Support arrow vectorized UDFs in DataFrame API
(#29210)
---
.../docs/reference/pyflink.dataframe/udf.rst | 6 +-
flink-python/pyflink/dataframe/tests/test_udf.py | 136 +++++++++-
flink-python/pyflink/dataframe/udf.py | 78 ++++--
.../pyflink/fn_execution/coder_impl_fast.pxd | 3 +-
.../pyflink/fn_execution/coder_impl_fast.pyx | 19 +-
.../pyflink/fn_execution/coder_impl_slow.py | 19 +-
flink-python/pyflink/fn_execution/coders.py | 31 ++-
.../pyflink/fn_execution/flink_fn_execution_pb2.py | 222 +++++++--------
.../fn_execution/flink_fn_execution_pb2.pyi | 18 +-
.../pyflink/fn_execution/table/operations.py | 17 +-
.../pyflink/fn_execution/tests/test_coders.py | 300 +++++++++++++++++++++
.../pyflink/fn_execution/utils/operation_utils.py | 38 +++
.../pyflink/proto/flink-fn-execution.proto | 8 +
.../pyflink/table/tests/test_pandas_udf.py | 2 +-
flink-python/pyflink/table/tests/test_udf.py | 169 +++++++++++-
flink-python/pyflink/table/types.py | 30 ++-
flink-python/pyflink/table/udf.py | 22 +-
flink-python/pyflink/table/utils.py | 132 +++++++++
.../org/apache/flink/python/util/ProtoUtils.java | 16 ++
.../arrow/ArrowPythonScalarFunctionOperator.java | 12 +-
.../flink/streaming/api/utils/ProtoUtilsTest.java | 51 ++++
.../table/functions/python/PythonFunctionKind.java | 4 +-
.../nodes/exec/common/CommonExecPythonCalc.java | 32 ++-
.../logical/PythonCalcSplitFunctionKindRule.java | 75 ++++++
.../plan/rules/logical/PythonMapMergeRule.java | 8 +-
.../plan/rules/logical/PythonCalcSplitRule.scala | 51 +---
.../PythonCalcSplitFunctionKindRuleTest.java | 112 ++++++++
.../PythonCalcSplitFunctionKindRuleTest.xml | 67 +++++
28 files changed, 1432 insertions(+), 246 deletions(-)
diff --git a/flink-python/docs/reference/pyflink.dataframe/udf.rst
b/flink-python/docs/reference/pyflink.dataframe/udf.rst
index 73d1ff2abe7..c0084bee45d 100644
--- a/flink-python/docs/reference/pyflink.dataframe/udf.rst
+++ b/flink-python/docs/reference/pyflink.dataframe/udf.rst
@@ -26,9 +26,9 @@ columns. A scalar UDF produces one logical output column and
can be used in
:meth:`~pyflink.dataframe.DataFrame.with_columns`, and
:meth:`~pyflink.dataframe.DataFrame.select`.
-DataFrame scalar UDFs support synchronous, asynchronous, and pandas-vectorized
-callables. See :func:`pyflink.dataframe.udf` for declaration forms, type
-inference, execution modes, and examples.
+DataFrame scalar UDFs support general synchronous and asynchronous callables,
+and synchronous pandas or Arrow vectorized callables. See
:func:`pyflink.dataframe.udf`
+for declaration forms, type inference, execution modes, and examples.
API Reference
=============
diff --git a/flink-python/pyflink/dataframe/tests/test_udf.py
b/flink-python/pyflink/dataframe/tests/test_udf.py
index 12be3306aeb..dfd0a396551 100644
--- a/flink-python/pyflink/dataframe/tests/test_udf.py
+++ b/flink-python/pyflink/dataframe/tests/test_udf.py
@@ -61,6 +61,86 @@ def _call_module_alias_function(value):
class DataFrameUDFDeclarationTests(unittest.TestCase):
+ def test_arrow_annotation_inference_and_overrides(self):
+ def arrow_identity(values: pa.Array) -> pa.ChunkedArray:
+ return pa.chunked_array([values])
+
+ def integer_array(values: pa.Int64Array) -> pa.Int64Array:
+ return values
+
+ def list_array(values: pa.ListArray):
+ return values.flatten()
+
+ def struct_array(values: "pa.StructArray"):
+ return values.field("value")
+
+ def concrete_return(values) -> pa.Int64Array:
+ return values
+
+ def mixed(values: pd.Series) -> pa.Int64Array:
+ return pa.array(values)
+
+ def captured(context: pd.Series, values: pa.Array) -> pa.Array:
+ return values
+
+ class ArrowCallable:
+ def __call__(self, values: "pa.Array") -> "pa.Array":
+ return values
+
+ class ArrowScalar(ScalarFunction):
+ def eval(self, values: pa.Array) -> pa.Array:
+ return values
+
+ for func in (arrow_identity, integer_array, list_array, struct_array,
concrete_return,
+ ArrowCallable, ArrowCallable(), ArrowScalar,
ArrowScalar(),
+ functools.partial(captured, pd.Series([1]))):
+ with self.subTest(func=func):
+ declaration = pf.udf(func, return_dtype=pf.DataType.int64())
+ self.assertEqual(cast(Any, declaration)._func_type, "arrow")
+
+ for mode in ("general", "pandas", "arrow"):
+ with self.subTest(mode=mode):
+ declaration = pf.udf(mixed, return_dtype=pf.DataType.int64(),
func_type=mode)
+ self.assertEqual(cast(Any, declaration)._func_type, mode)
+
+ with self.assertRaisesRegex(ValueError, "pandas.*Arrow.*func_type"):
+ pf.udf(mixed, return_dtype=pf.DataType.int64())
+ with self.assertRaisesRegex(TypeError, "return_dtype is required for
arrow"):
+ pf.udf(arrow_identity)
+
+ def test_explicit_arrow_declarations(self):
+ from pyflink.table.udf import udf as table_udf
+
+ def identity(values):
+ return values
+
+ declaration = pf.udf(identity, return_dtype=pf.DataType.string(),
func_type="arrow")
+ self.assertEqual(_return_dtype(declaration), pf.DataType.string())
+ table_udf(identity, result_type=TableDataTypes.STRING(),
func_type="arrow")
+
+ with self.assertRaisesRegex(TypeError, "return_dtype is required for
arrow"):
+ pf.udf(identity, func_type="arrow")
+
+ async def async_identity(values):
+ return values
+
+ class AsyncCallable:
+ async def __call__(self, values):
+ return values
+
+ for declare in (
+ lambda: pf.udf(async_identity, return_dtype=pf.DataType.string(),
func_type="arrow"),
+ lambda: table_udf(async_identity,
result_type=TableDataTypes.STRING(),
+ func_type="arrow"),
+ lambda: table_udf(AsyncCallable(),
result_type=TableDataTypes.STRING(),
+ func_type="arrow"),
+ lambda: table_udf(functools.partial(AsyncCallable()),
+ result_type=TableDataTypes.STRING(),
func_type="arrow"),
+ ):
+ with self.subTest(declare=declare):
+ with self.assertRaisesRegex(ValueError, "Async.*arrow"):
+ declare()
+
def test_function_declarations_return_types_and_metadata(self):
class Details(TypedDict):
label: str
@@ -547,9 +627,9 @@ class DataFrameUDFDeclarationTests(unittest.TestCase):
False,
),
(
- "pyarrow annotations remain general",
+ "inferred arrow",
lambda: pf.udf(arrow_add_one,
return_dtype=pf.DataType.int64()),
- "general",
+ "arrow",
False,
),
(
@@ -1178,11 +1258,11 @@ class DataFrameUDFDeclarationTests(unittest.TestCase):
"name must not be empty",
),
(
- "arrow func type",
+ "unsupported func type",
lambda: pf.udf(
missing_return,
return_dtype=pf.DataType.int64(),
- func_type="arrow",
+ func_type="unsupported",
),
ValueError,
"func_type must be one of",
@@ -1480,6 +1560,21 @@ class DataFrameUDFAdapterTests(unittest.TestCase):
class DataFrameUDFPlannerTests(PyFlinkDataFrameUTTestCase):
+ 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"])
+ for args in ((), (1,), (pf.lit(1),), (identity(),)):
+ with self.subTest(args=args):
+ with self.assertRaisesRegex(Exception, "at least one
column-valued argument"):
+ result = dataframe.with_columns(
+ valid=identity(pf.col("id")), invalid=identity(*args))
+
result.to_table().explain(ExplainDetail.JSON_EXECUTION_PLAN)
+
def test_with_columns_binds_expressions_and_resolves_output_schema(self):
@pf.udf(name="render_value")
def render(value: int, suffix: str) -> str:
@@ -1521,6 +1616,25 @@ class
DataFrameUDFPlannerTests(PyFlinkDataFrameUTTestCase):
class DataFrameUDFITCase(PyFlinkStreamDataFrameTestCase):
def test_supported_scalar_udfs_in_one_job(self):
+ import pyarrow.compute as pc
+
+ 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")
+
+ @pf.udf(return_dtype=pf.DataType.string())
+ def normalize_name(names: pa.Array) -> pa.Array:
+ return pc.utf8_upper(names)
+
+ @pf.udf(return_dtype=pf.DataType.struct({"value":
pf.DataType.int64().not_null()}))
+ def describe(values: pa.Array) -> pa.ChunkedArray:
+ result = pa.StructArray.from_arrays([pc.multiply(values, 2)],
names=["value"])
+ return pa.chunked_array([result.slice(0, 1), result.slice(1)])
+
+ @pf.udf(return_dtype=pf.DataType.int64())
+ def struct_value(values: pa.Array) -> pa.Array:
+ return pc.struct_field(values, "value")
+
@dataclass
class Details:
doubled: int
@@ -1561,19 +1675,27 @@ class
DataFrameUDFITCase(PyFlinkStreamDataFrameTestCase):
opened_scalar_class = pf.udf(OpenedScalarFunction)
result = (
- pf.from_records([(1,)], schema=["id"])
+ pf.from_records([(1, "alice"), (2, None), (3, "Bob")],
schema=["id", "name"])
.with_columns(async_value=add_two(pf.col("id")))
.with_columns(
pandas_value=add_three(pf.col("id")),
details=details(pf.col("id")),
deferred_value=deferred(pf.col("id")),
scalar_value=opened_scalar_class(pf.col("id")),
+ normalized_name=normalize_name(pf.col("name")),
+ arrow_details=describe(pf.col("id")),
+
arrow_after_pandas=struct_value(describe(add_three(pf.col("id")))),
+
pandas_after_arrow=add_three(struct_value(describe(pf.col("id")))),
)
)
self.assertEqual(
- result.collect(),
- [Row(1, 3, 4, Row(2, ["1"]), 5, 6)],
+ sorted(result.collect(), key=lambda row: row[0]),
+ [
+ Row(1, "alice", 3, 4, Row(2, ["1"]), 5, 6, "ALICE", Row(2), 8,
5),
+ Row(2, None, 4, 5, Row(4, ["2"]), 6, 7, None, Row(4), 10, 7),
+ Row(3, "Bob", 5, 6, Row(6, ["3"]), 7, 8, "BOB", Row(6), 12, 9),
+ ],
)
diff --git a/flink-python/pyflink/dataframe/udf.py
b/flink-python/pyflink/dataframe/udf.py
index b5e55c3c14c..06ad69cd6dc 100644
--- a/flink-python/pyflink/dataframe/udf.py
+++ b/flink-python/pyflink/dataframe/udf.py
@@ -410,6 +410,27 @@ def udf(
... def inferred_pandas_add_one(values: pd.Series) -> pd.Series:
... return values + 1
+ Arrow UDFs always require an explicit logical ``return_dtype`` and support
+ synchronous functions. Each column argument is received as a
``pyarrow.Array``;
+ a ``ROW``-typed column is received as a ``pyarrow.StructArray`` with one
child
+ array per field. Results should be returned as a ``pyarrow.Array`` or
+ ``pyarrow.ChunkedArray`` of the declared logical type, with the same
number of
+ rows as the input batch. A ``ROW``-typed result uses a
``pyarrow.StructArray``
+ or a chunked array of structs. Arrow mode can be selected explicitly, or
+ inferred from an Arrow container annotation on any unbound parameter or the
+ return value::
+
+ >>> import pyarrow as pa
+ >>> import pyarrow.compute as pc
+
+ >>> @pf.udf(return_dtype=pf.DataType.int64(), func_type="arrow")
+ ... def arrow_add_one(values):
+ ... return pc.add(values, 1)
+
+ >>> @pf.udf(return_dtype=pf.DataType.string())
+ ... def normalize_name(names: pa.Array) -> pa.Array:
+ ... return pc.utf8_upper(names)
+
A declared UDF is called with DataFrame expressions or Python literals to
produce a single-column expression::
@@ -424,12 +445,13 @@ def udf(
callable/scalar-UDF class.
:param return_dtype: DataFrame logical type, Python type, or SQL type
string.
General UDFs may infer it from a return annotation;
- pandas UDFs require it.
+ pandas and Arrow UDFs require it.
:param deterministic: Whether equal inputs always produce equal results.
Must agree with scalar-function metadata.
:param name: Non-empty function identity used by the Table planner.
- :param func_type: ``"general"`` or ``"pandas"``. If omitted, any unbound
- pandas container annotation selects pandas mode.
+ :param func_type: ``"general"``, ``"pandas"``, or ``"arrow"``. If omitted,
+ unbound container annotations select pandas or Arrow
mode;
+ otherwise general mode is used.
:return: A callable that accepts DataFrame expressions or Python literals
and
returns an :class:`~pyflink.table.expression.Expression`, or a
decorator
producing such a callable when ``func`` is omitted.
@@ -487,18 +509,18 @@ def _validate_scalar_udf_options(
return_dtype: Optional[_DataTypeLike],
is_async: bool,
) -> None:
- if func_type not in ("general", "pandas"):
+ if func_type not in ("general", "pandas", "arrow"):
raise ValueError(
- f"The func_type must be one of 'general, pandas', got {func_type}."
+ f"The func_type must be one of 'general, pandas, arrow', got
{func_type}."
)
- if return_dtype is None and func_type == "pandas":
+ if return_dtype is None and func_type in ("pandas", "arrow"):
raise TypeError(
- "return_dtype is required for pandas UDFs because pandas container
"
+ f"return_dtype is required for {func_type} UDFs because
{func_type} container "
"annotations do not describe the logical result type."
)
- if is_async and func_type == "pandas":
+ if is_async and func_type in ("pandas", "arrow"):
raise ValueError(
- "Async scalar functions do not support pandas func_type. "
+ f"Async scalar functions do not support {func_type} func_type. "
"Use func_type='general'."
)
@@ -1003,30 +1025,44 @@ def _data_type_from_type_hint(type_hint: Any) ->
DataType:
def _detect_func_type(declaration_context: _UDFDeclarationContext) -> str:
- """Detect pandas mode from an unbound pandas container annotation."""
+ """Detect a unique vectorized mode from unbound container annotations."""
hint_func = declaration_context.annotation_target
+ container_types: Dict[str, Tuple[Type, ...]] = {}
+ container_globalns: Dict[str, Any] = {}
try:
import pandas as pd
+ container_types["pandas"] = (pd.Series, pd.DataFrame)
+ container_globalns.update(pandas=pd, pd=pd)
except ImportError:
- return "general"
+ pass
+ try:
+ import pyarrow as pa
+ container_types["arrow"] = (pa.Array, pa.ChunkedArray)
+ container_globalns.update(pyarrow=pa, pa=pa)
+ except ImportError:
+ pass
- pandas_types = (pd.Series, pd.DataFrame)
- pandas_globalns = {
- "pandas": pd,
- "pd": pd,
- **declaration_context.globalns,
- }
+ modes: set[str] = set()
for name in getattr(hint_func, "__annotations__", {}):
if name in declaration_context.ignored_hint_names:
continue
hint = _resolve_callable_annotation(
declaration_context,
name,
- globalns=pandas_globalns,
+ globalns={**container_globalns, **declaration_context.globalns},
+ )
+ modes.update(
+ mode for mode, types in container_types.items()
+ if hint in types or (
+ mode == "arrow" and isinstance(hint, type) and
issubclass(hint, types)
+ )
+ )
+ if len(modes) > 1:
+ raise ValueError(
+ "UDF annotations contain both pandas and Arrow containers; "
+ "specify func_type explicitly."
)
- if hint in pandas_types:
- return "pandas"
- return "general"
+ return next(iter(modes), "general")
# ======================== Worker Adapters ========================
diff --git a/flink-python/pyflink/fn_execution/coder_impl_fast.pxd
b/flink-python/pyflink/fn_execution/coder_impl_fast.pxd
index 05d3ba5fd6b..aeacced5dc5 100644
--- a/flink-python/pyflink/fn_execution/coder_impl_fast.pxd
+++ b/flink-python/pyflink/fn_execution/coder_impl_fast.pxd
@@ -90,13 +90,14 @@ cdef class RowCoderImpl(FieldCoderImpl):
cdef MaskUtils _mask_utils
cdef class ArrowCoderImpl(FieldCoderImpl):
+ cdef object _batch_format
cdef object _schema
cdef list _field_types
cdef object _timezone
cdef object _resettable_io
cdef object _batch_reader
- cdef list decode_one_batch_from_stream(self, InputStream in_stream, size_t
size)
+ cdef decode_one_batch_from_stream(self, InputStream in_stream, size_t size)
cdef class OverWindowArrowCoderImpl(FieldCoderImpl):
cdef ArrowCoderImpl _arrow_coder
diff --git a/flink-python/pyflink/fn_execution/coder_impl_fast.pyx
b/flink-python/pyflink/fn_execution/coder_impl_fast.pyx
index 92dff893fe9..c25ac8cfdd7 100644
--- a/flink-python/pyflink/fn_execution/coder_impl_fast.pyx
+++ b/flink-python/pyflink/fn_execution/coder_impl_fast.pyx
@@ -36,7 +36,7 @@ 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
+from pyflink.table.utils import pandas_to_arrow, arrow_to_pandas,
validate_arrow_batch
ROW_KIND_BIT_SIZE = 2
@@ -431,7 +431,8 @@ cdef class ArrowCoderImpl(FieldCoderImpl):
A coder for arrow format data.
"""
- def __init__(self, schema, row_type, timezone):
+ def __init__(self, schema, row_type, timezone, batch_format="PANDAS"):
+ self._batch_format = batch_format
self._schema = schema
self._field_types = row_type.field_types()
self._timezone = timezone
@@ -443,16 +444,22 @@ cdef class ArrowCoderImpl(FieldCoderImpl):
self._resettable_io.set_output_stream(out_stream)
batch_writer = pa.RecordBatchStreamWriter(self._resettable_io,
self._schema)
- batch_writer.write_batch(
- pandas_to_arrow(self._schema, self._timezone, self._field_types,
cols))
+ if self._batch_format == "ARROW":
+ batch = validate_arrow_batch(cols, self._schema, self._field_types)
+ else:
+ batch = pandas_to_arrow(self._schema, self._timezone,
self._field_types, cols)
+ batch_writer.write_batch(batch)
cpdef decode_from_stream(self, InputStream in_stream, size_t size):
return self.decode_one_batch_from_stream(in_stream, size)
- cdef list decode_one_batch_from_stream(self, InputStream in_stream, size_t
size):
+ cdef decode_one_batch_from_stream(self, InputStream in_stream, size_t
size):
self._resettable_io.set_input_bytes(in_stream.read(size))
# there is only one arrow batch in the underlying input stream
- return arrow_to_pandas(self._timezone, self._field_types,
[next(self._batch_reader)])
+ batch = next(self._batch_reader)
+ if self._batch_format == "ARROW":
+ return batch
+ return arrow_to_pandas(self._timezone, self._field_types, [batch])
def _load_from_stream(self, stream):
import pyarrow as pa
diff --git a/flink-python/pyflink/fn_execution/coder_impl_slow.py
b/flink-python/pyflink/fn_execution/coder_impl_slow.py
index 769720dc277..a7df600d8fb 100644
--- a/flink-python/pyflink/fn_execution/coder_impl_slow.py
+++ b/flink-python/pyflink/fn_execution/coder_impl_slow.py
@@ -31,7 +31,7 @@ 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
+from pyflink.table.utils import pandas_to_arrow, arrow_to_pandas,
validate_arrow_batch
ROW_KIND_BIT_SIZE = 2
@@ -278,7 +278,8 @@ class ArrowCoderImpl(FieldCoderImpl):
A coder for arrow format data.
"""
- def __init__(self, schema, row_type, timezone):
+ def __init__(self, schema, row_type, timezone, batch_format="PANDAS"):
+ self._batch_format = batch_format
self._schema = schema
self._field_types = row_type.field_types()
self._timezone = timezone
@@ -290,16 +291,22 @@ class ArrowCoderImpl(FieldCoderImpl):
self._resettable_io.set_output_stream(out_stream)
batch_writer = pa.RecordBatchStreamWriter(self._resettable_io,
self._schema)
- batch_writer.write_batch(
- pandas_to_arrow(self._schema, self._timezone, self._field_types,
cols))
+ if self._batch_format == "ARROW":
+ batch = validate_arrow_batch(cols, self._schema, self._field_types)
+ else:
+ batch = pandas_to_arrow(self._schema, self._timezone,
self._field_types, cols)
+ batch_writer.write_batch(batch)
def decode_from_stream(self, in_stream: InputStream, length=0):
return self.decode_one_batch_from_stream(in_stream, length)
- def decode_one_batch_from_stream(self, in_stream: InputStream, size: int)
-> List:
+ def decode_one_batch_from_stream(self, in_stream: InputStream, size: int):
self._resettable_io.set_input_bytes(in_stream.read(size))
# there is only one arrow batch in the underlying input stream
- return arrow_to_pandas(self._timezone, self._field_types,
[next(self._batch_reader)])
+ batch = next(self._batch_reader)
+ if self._batch_format == "ARROW":
+ return batch
+ return arrow_to_pandas(self._timezone, self._field_types, [batch])
@staticmethod
def _load_from_stream(stream):
diff --git a/flink-python/pyflink/fn_execution/coders.py
b/flink-python/pyflink/fn_execution/coders.py
index 838528f24d1..d75afcc1ff9 100644
--- a/flink-python/pyflink/fn_execution/coders.py
+++ b/flink-python/pyflink/fn_execution/coders.py
@@ -36,8 +36,8 @@ from pyflink.common.typeinfo import TypeInformation,
BasicTypeInfo, BasicType, D
ExternalTypeInfo
from pyflink.table.types import TinyIntType, SmallIntType, IntType,
BigIntType, BooleanType, \
FloatType, DoubleType, VarCharType, VarBinaryType, DecimalType, DateType,
TimeType, \
- LocalZonedTimestampType, RowType, RowField, to_arrow_type, TimestampType,
ArrayType, MapType, \
- BinaryType, NullType, CharType
+ LocalZonedTimestampType, RowType, RowField, create_arrow_schema,
TimestampType, ArrayType, \
+ MapType, BinaryType, NullType, CharType
__all__ = ['FlattenRowCoder', 'RowCoder', 'BigIntCoder', 'TinyIntCoder',
'BooleanCoder',
'SmallIntCoder', 'IntCoder', 'FloatCoder', 'DoubleCoder',
'BinaryCoder', 'CharCoder',
@@ -84,16 +84,24 @@ class LengthPrefixBaseCoder(ABC):
field_names = [f.name for f in schema_proto.fields]
return RowCoder(field_coders, field_names)
elif coder_info_descriptor_proto.HasField('arrow_type'):
- timezone = pytz.timezone(os.environ['TABLE_LOCAL_TIME_ZONE'])
schema_proto = coder_info_descriptor_proto.arrow_type.schema
row_type = cls._to_row_type(schema_proto)
- return ArrowCoder(cls._to_arrow_schema(row_type), row_type,
timezone)
+ batch_format =
coder_info_descriptor_proto.arrow_type.BatchFormat.Name(
+ coder_info_descriptor_proto.arrow_type.batch_format)
+ # Native Arrow does not use pandas timezone conversion. Some valid
JVM zone IDs
+ # 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)
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
row_type = cls._to_row_type(schema_proto)
return OverWindowArrowCoder(
- cls._to_arrow_schema(row_type), row_type, timezone)
+ create_arrow_schema(row_type.field_names(),
row_type.field_types()),
+ row_type, timezone)
elif coder_info_descriptor_proto.HasField('raw_type'):
type_info_proto = coder_info_descriptor_proto.raw_type.type_info
field_coder = from_type_info_proto(type_info_proto)
@@ -101,13 +109,6 @@ class LengthPrefixBaseCoder(ABC):
else:
raise ValueError("Unexpected coder type %s" %
coder_info_descriptor_proto)
- @classmethod
- def _to_arrow_schema(cls, row_type):
- import pyarrow as pa
-
- return pa.schema([pa.field(n, to_arrow_type(t), t._nullable)
- for n, t in zip(row_type.field_names(),
row_type.field_types())])
-
@classmethod
def _to_data_type(cls, field_type):
from pyflink.fn_execution import flink_fn_execution_pb2
@@ -242,13 +243,15 @@ class ArrowCoder(FieldCoder):
Coder for Arrow.
"""
- def __init__(self, schema, row_type, timezone):
+ def __init__(self, schema, row_type, timezone, batch_format="PANDAS"):
+ self._batch_format = batch_format
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)
+ return coder_impl.ArrowCoderImpl(
+ self._schema, self._row_type, self._timezone, self._batch_format)
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 6fabbefcc7c..54016508148 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)
@@ -54,113 +54,115 @@ if not _descriptor._USE_C_DESCRIPTORS:
_globals['_INPUT']._serialized_start=107
_globals['_INPUT']._serialized_end=261
_globals['_USERDEFINEDFUNCTION']._serialized_start=264
- _globals['_USERDEFINEDFUNCTION']._serialized_end=432
- _globals['_ASYNCOPTIONS']._serialized_start=435
- _globals['_ASYNCOPTIONS']._serialized_end=579
- _globals['_USERDEFINEDFUNCTIONS']._serialized_start=582
- _globals['_USERDEFINEDFUNCTIONS']._serialized_end=1057
- _globals['_OVERWINDOW']._serialized_start=1060
- _globals['_OVERWINDOW']._serialized_end=1409
- _globals['_OVERWINDOW_WINDOWTYPE']._serialized_start=1201
- _globals['_OVERWINDOW_WINDOWTYPE']._serialized_end=1409
- _globals['_USERDEFINEDAGGREGATEFUNCTION']._serialized_start=1412
- _globals['_USERDEFINEDAGGREGATEFUNCTION']._serialized_end=2191
- _globals['_USERDEFINEDAGGREGATEFUNCTION_DATAVIEWSPEC']._serialized_start=1677
- _globals['_USERDEFINEDAGGREGATEFUNCTION_DATAVIEWSPEC']._serialized_end=2191
-
_globals['_USERDEFINEDAGGREGATEFUNCTION_DATAVIEWSPEC_LISTVIEW']._serialized_start=1940
-
_globals['_USERDEFINEDAGGREGATEFUNCTION_DATAVIEWSPEC_LISTVIEW']._serialized_end=2024
-
_globals['_USERDEFINEDAGGREGATEFUNCTION_DATAVIEWSPEC_MAPVIEW']._serialized_start=2027
-
_globals['_USERDEFINEDAGGREGATEFUNCTION_DATAVIEWSPEC_MAPVIEW']._serialized_end=2178
- _globals['_GROUPWINDOW']._serialized_start=2194
- _globals['_GROUPWINDOW']._serialized_end=2750
- _globals['_GROUPWINDOW_WINDOWTYPE']._serialized_start=2558
- _globals['_GROUPWINDOW_WINDOWTYPE']._serialized_end=2649
- _globals['_GROUPWINDOW_WINDOWPROPERTY']._serialized_start=2651
- _globals['_GROUPWINDOW_WINDOWPROPERTY']._serialized_end=2750
- _globals['_USERDEFINEDAGGREGATEFUNCTIONS']._serialized_start=2753
- _globals['_USERDEFINEDAGGREGATEFUNCTIONS']._serialized_end=3464
- _globals['_SCHEMA']._serialized_start=3467
- _globals['_SCHEMA']._serialized_end=5505
- _globals['_SCHEMA_MAPINFO']._serialized_start=3542
- _globals['_SCHEMA_MAPINFO']._serialized_end=3693
- _globals['_SCHEMA_TIMEINFO']._serialized_start=3695
- _globals['_SCHEMA_TIMEINFO']._serialized_end=3724
- _globals['_SCHEMA_TIMESTAMPINFO']._serialized_start=3726
- _globals['_SCHEMA_TIMESTAMPINFO']._serialized_end=3760
- _globals['_SCHEMA_LOCALZONEDTIMESTAMPINFO']._serialized_start=3762
- _globals['_SCHEMA_LOCALZONEDTIMESTAMPINFO']._serialized_end=3806
- _globals['_SCHEMA_ZONEDTIMESTAMPINFO']._serialized_start=3808
- _globals['_SCHEMA_ZONEDTIMESTAMPINFO']._serialized_end=3847
- _globals['_SCHEMA_DECIMALINFO']._serialized_start=3849
- _globals['_SCHEMA_DECIMALINFO']._serialized_end=3896
- _globals['_SCHEMA_BINARYINFO']._serialized_start=3898
- _globals['_SCHEMA_BINARYINFO']._serialized_end=3926
- _globals['_SCHEMA_VARBINARYINFO']._serialized_start=3928
- _globals['_SCHEMA_VARBINARYINFO']._serialized_end=3959
- _globals['_SCHEMA_CHARINFO']._serialized_start=3961
- _globals['_SCHEMA_CHARINFO']._serialized_end=3987
- _globals['_SCHEMA_VARCHARINFO']._serialized_start=3989
- _globals['_SCHEMA_VARCHARINFO']._serialized_end=4018
- _globals['_SCHEMA_FIELDTYPE']._serialized_start=4021
- _globals['_SCHEMA_FIELDTYPE']._serialized_end=5093
- _globals['_SCHEMA_FIELD']._serialized_start=5095
- _globals['_SCHEMA_FIELD']._serialized_end=5203
- _globals['_SCHEMA_TYPENAME']._serialized_start=5206
- _globals['_SCHEMA_TYPENAME']._serialized_end=5505
- _globals['_TYPEINFO']._serialized_start=5508
- _globals['_TYPEINFO']._serialized_end=6855
- _globals['_TYPEINFO_MAPTYPEINFO']._serialized_start=6002
- _globals['_TYPEINFO_MAPTYPEINFO']._serialized_end=6141
- _globals['_TYPEINFO_ROWTYPEINFO']._serialized_start=6144
- _globals['_TYPEINFO_ROWTYPEINFO']._serialized_end=6328
- _globals['_TYPEINFO_ROWTYPEINFO_FIELD']._serialized_start=6237
- _globals['_TYPEINFO_ROWTYPEINFO_FIELD']._serialized_end=6328
- _globals['_TYPEINFO_TUPLETYPEINFO']._serialized_start=6330
- _globals['_TYPEINFO_TUPLETYPEINFO']._serialized_end=6410
- _globals['_TYPEINFO_AVROTYPEINFO']._serialized_start=6412
- _globals['_TYPEINFO_AVROTYPEINFO']._serialized_end=6442
- _globals['_TYPEINFO_TYPENAME']._serialized_start=6445
- _globals['_TYPEINFO_TYPENAME']._serialized_end=6842
- _globals['_USERDEFINEDDATASTREAMFUNCTION']._serialized_start=6858
- _globals['_USERDEFINEDDATASTREAMFUNCTION']._serialized_end=7835
-
_globals['_USERDEFINEDDATASTREAMFUNCTION_RUNTIMECONTEXT']._serialized_start=7353
-
_globals['_USERDEFINEDDATASTREAMFUNCTION_RUNTIMECONTEXT']._serialized_end=7659
-
_globals['_USERDEFINEDDATASTREAMFUNCTION_FUNCTIONTYPE']._serialized_start=7662
- _globals['_USERDEFINEDDATASTREAMFUNCTION_FUNCTIONTYPE']._serialized_end=7835
- _globals['_STATEDESCRIPTOR']._serialized_start=7838
- _globals['_STATEDESCRIPTOR']._serialized_end=9730
- _globals['_STATEDESCRIPTOR_STATETTLCONFIG']._serialized_start=7970
- _globals['_STATEDESCRIPTOR_STATETTLCONFIG']._serialized_end=9730
-
_globals['_STATEDESCRIPTOR_STATETTLCONFIG_CLEANUPSTRATEGIES']._serialized_start=8441
-
_globals['_STATEDESCRIPTOR_STATETTLCONFIG_CLEANUPSTRATEGIES']._serialized_end=9539
-
_globals['_STATEDESCRIPTOR_STATETTLCONFIG_CLEANUPSTRATEGIES_INCREMENTALCLEANUPSTRATEGY']._serialized_start=8619
-
_globals['_STATEDESCRIPTOR_STATETTLCONFIG_CLEANUPSTRATEGIES_INCREMENTALCLEANUPSTRATEGY']._serialized_end=8707
-
_globals['_STATEDESCRIPTOR_STATETTLCONFIG_CLEANUPSTRATEGIES_ROCKSDBCOMPACTFILTERCLEANUPSTRATEGY']._serialized_start=8709
-
_globals['_STATEDESCRIPTOR_STATETTLCONFIG_CLEANUPSTRATEGIES_ROCKSDBCOMPACTFILTERCLEANUPSTRATEGY']._serialized_end=8784
-
_globals['_STATEDESCRIPTOR_STATETTLCONFIG_CLEANUPSTRATEGIES_MAPSTRATEGIESENTRY']._serialized_start=8787
-
_globals['_STATEDESCRIPTOR_STATETTLCONFIG_CLEANUPSTRATEGIES_MAPSTRATEGIESENTRY']._serialized_end=9395
-
_globals['_STATEDESCRIPTOR_STATETTLCONFIG_CLEANUPSTRATEGIES_STRATEGIES']._serialized_start=9397
-
_globals['_STATEDESCRIPTOR_STATETTLCONFIG_CLEANUPSTRATEGIES_STRATEGIES']._serialized_end=9495
-
_globals['_STATEDESCRIPTOR_STATETTLCONFIG_CLEANUPSTRATEGIES_EMPTYCLEANUPSTRATEGY']._serialized_start=9497
-
_globals['_STATEDESCRIPTOR_STATETTLCONFIG_CLEANUPSTRATEGIES_EMPTYCLEANUPSTRATEGY']._serialized_end=9539
- _globals['_STATEDESCRIPTOR_STATETTLCONFIG_UPDATETYPE']._serialized_start=9541
- _globals['_STATEDESCRIPTOR_STATETTLCONFIG_UPDATETYPE']._serialized_end=9609
-
_globals['_STATEDESCRIPTOR_STATETTLCONFIG_STATEVISIBILITY']._serialized_start=9611
-
_globals['_STATEDESCRIPTOR_STATETTLCONFIG_STATEVISIBILITY']._serialized_end=9685
-
_globals['_STATEDESCRIPTOR_STATETTLCONFIG_TTLTIMECHARACTERISTIC']._serialized_start=9687
-
_globals['_STATEDESCRIPTOR_STATETTLCONFIG_TTLTIMECHARACTERISTIC']._serialized_end=9730
- _globals['_CODERINFODESCRIPTOR']._serialized_start=9733
- _globals['_CODERINFODESCRIPTOR']._serialized_end=10742
- _globals['_CODERINFODESCRIPTOR_FLATTENROWTYPE']._serialized_start=10326
- _globals['_CODERINFODESCRIPTOR_FLATTENROWTYPE']._serialized_end=10400
- _globals['_CODERINFODESCRIPTOR_ROWTYPE']._serialized_start=10402
- _globals['_CODERINFODESCRIPTOR_ROWTYPE']._serialized_end=10469
- _globals['_CODERINFODESCRIPTOR_ARROWTYPE']._serialized_start=10471
- _globals['_CODERINFODESCRIPTOR_ARROWTYPE']._serialized_end=10540
- _globals['_CODERINFODESCRIPTOR_OVERWINDOWARROWTYPE']._serialized_start=10542
- _globals['_CODERINFODESCRIPTOR_OVERWINDOWARROWTYPE']._serialized_end=10621
- _globals['_CODERINFODESCRIPTOR_RAWTYPE']._serialized_start=10623
- _globals['_CODERINFODESCRIPTOR_RAWTYPE']._serialized_end=10695
- _globals['_CODERINFODESCRIPTOR_MODE']._serialized_start=10697
- _globals['_CODERINFODESCRIPTOR_MODE']._serialized_end=10729
+ _globals['_USERDEFINEDFUNCTION']._serialized_end=454
+ _globals['_ASYNCOPTIONS']._serialized_start=457
+ _globals['_ASYNCOPTIONS']._serialized_end=601
+ _globals['_USERDEFINEDFUNCTIONS']._serialized_start=604
+ _globals['_USERDEFINEDFUNCTIONS']._serialized_end=1079
+ _globals['_OVERWINDOW']._serialized_start=1082
+ _globals['_OVERWINDOW']._serialized_end=1431
+ _globals['_OVERWINDOW_WINDOWTYPE']._serialized_start=1223
+ _globals['_OVERWINDOW_WINDOWTYPE']._serialized_end=1431
+ _globals['_USERDEFINEDAGGREGATEFUNCTION']._serialized_start=1434
+ _globals['_USERDEFINEDAGGREGATEFUNCTION']._serialized_end=2213
+ _globals['_USERDEFINEDAGGREGATEFUNCTION_DATAVIEWSPEC']._serialized_start=1699
+ _globals['_USERDEFINEDAGGREGATEFUNCTION_DATAVIEWSPEC']._serialized_end=2213
+
_globals['_USERDEFINEDAGGREGATEFUNCTION_DATAVIEWSPEC_LISTVIEW']._serialized_start=1962
+
_globals['_USERDEFINEDAGGREGATEFUNCTION_DATAVIEWSPEC_LISTVIEW']._serialized_end=2046
+
_globals['_USERDEFINEDAGGREGATEFUNCTION_DATAVIEWSPEC_MAPVIEW']._serialized_start=2049
+
_globals['_USERDEFINEDAGGREGATEFUNCTION_DATAVIEWSPEC_MAPVIEW']._serialized_end=2200
+ _globals['_GROUPWINDOW']._serialized_start=2216
+ _globals['_GROUPWINDOW']._serialized_end=2772
+ _globals['_GROUPWINDOW_WINDOWTYPE']._serialized_start=2580
+ _globals['_GROUPWINDOW_WINDOWTYPE']._serialized_end=2671
+ _globals['_GROUPWINDOW_WINDOWPROPERTY']._serialized_start=2673
+ _globals['_GROUPWINDOW_WINDOWPROPERTY']._serialized_end=2772
+ _globals['_USERDEFINEDAGGREGATEFUNCTIONS']._serialized_start=2775
+ _globals['_USERDEFINEDAGGREGATEFUNCTIONS']._serialized_end=3486
+ _globals['_SCHEMA']._serialized_start=3489
+ _globals['_SCHEMA']._serialized_end=5527
+ _globals['_SCHEMA_MAPINFO']._serialized_start=3564
+ _globals['_SCHEMA_MAPINFO']._serialized_end=3715
+ _globals['_SCHEMA_TIMEINFO']._serialized_start=3717
+ _globals['_SCHEMA_TIMEINFO']._serialized_end=3746
+ _globals['_SCHEMA_TIMESTAMPINFO']._serialized_start=3748
+ _globals['_SCHEMA_TIMESTAMPINFO']._serialized_end=3782
+ _globals['_SCHEMA_LOCALZONEDTIMESTAMPINFO']._serialized_start=3784
+ _globals['_SCHEMA_LOCALZONEDTIMESTAMPINFO']._serialized_end=3828
+ _globals['_SCHEMA_ZONEDTIMESTAMPINFO']._serialized_start=3830
+ _globals['_SCHEMA_ZONEDTIMESTAMPINFO']._serialized_end=3869
+ _globals['_SCHEMA_DECIMALINFO']._serialized_start=3871
+ _globals['_SCHEMA_DECIMALINFO']._serialized_end=3918
+ _globals['_SCHEMA_BINARYINFO']._serialized_start=3920
+ _globals['_SCHEMA_BINARYINFO']._serialized_end=3948
+ _globals['_SCHEMA_VARBINARYINFO']._serialized_start=3950
+ _globals['_SCHEMA_VARBINARYINFO']._serialized_end=3981
+ _globals['_SCHEMA_CHARINFO']._serialized_start=3983
+ _globals['_SCHEMA_CHARINFO']._serialized_end=4009
+ _globals['_SCHEMA_VARCHARINFO']._serialized_start=4011
+ _globals['_SCHEMA_VARCHARINFO']._serialized_end=4040
+ _globals['_SCHEMA_FIELDTYPE']._serialized_start=4043
+ _globals['_SCHEMA_FIELDTYPE']._serialized_end=5115
+ _globals['_SCHEMA_FIELD']._serialized_start=5117
+ _globals['_SCHEMA_FIELD']._serialized_end=5225
+ _globals['_SCHEMA_TYPENAME']._serialized_start=5228
+ _globals['_SCHEMA_TYPENAME']._serialized_end=5527
+ _globals['_TYPEINFO']._serialized_start=5530
+ _globals['_TYPEINFO']._serialized_end=6877
+ _globals['_TYPEINFO_MAPTYPEINFO']._serialized_start=6024
+ _globals['_TYPEINFO_MAPTYPEINFO']._serialized_end=6163
+ _globals['_TYPEINFO_ROWTYPEINFO']._serialized_start=6166
+ _globals['_TYPEINFO_ROWTYPEINFO']._serialized_end=6350
+ _globals['_TYPEINFO_ROWTYPEINFO_FIELD']._serialized_start=6259
+ _globals['_TYPEINFO_ROWTYPEINFO_FIELD']._serialized_end=6350
+ _globals['_TYPEINFO_TUPLETYPEINFO']._serialized_start=6352
+ _globals['_TYPEINFO_TUPLETYPEINFO']._serialized_end=6432
+ _globals['_TYPEINFO_AVROTYPEINFO']._serialized_start=6434
+ _globals['_TYPEINFO_AVROTYPEINFO']._serialized_end=6464
+ _globals['_TYPEINFO_TYPENAME']._serialized_start=6467
+ _globals['_TYPEINFO_TYPENAME']._serialized_end=6864
+ _globals['_USERDEFINEDDATASTREAMFUNCTION']._serialized_start=6880
+ _globals['_USERDEFINEDDATASTREAMFUNCTION']._serialized_end=7857
+
_globals['_USERDEFINEDDATASTREAMFUNCTION_RUNTIMECONTEXT']._serialized_start=7375
+
_globals['_USERDEFINEDDATASTREAMFUNCTION_RUNTIMECONTEXT']._serialized_end=7681
+
_globals['_USERDEFINEDDATASTREAMFUNCTION_FUNCTIONTYPE']._serialized_start=7684
+ _globals['_USERDEFINEDDATASTREAMFUNCTION_FUNCTIONTYPE']._serialized_end=7857
+ _globals['_STATEDESCRIPTOR']._serialized_start=7860
+ _globals['_STATEDESCRIPTOR']._serialized_end=9752
+ _globals['_STATEDESCRIPTOR_STATETTLCONFIG']._serialized_start=7992
+ _globals['_STATEDESCRIPTOR_STATETTLCONFIG']._serialized_end=9752
+
_globals['_STATEDESCRIPTOR_STATETTLCONFIG_CLEANUPSTRATEGIES']._serialized_start=8463
+
_globals['_STATEDESCRIPTOR_STATETTLCONFIG_CLEANUPSTRATEGIES']._serialized_end=9561
+
_globals['_STATEDESCRIPTOR_STATETTLCONFIG_CLEANUPSTRATEGIES_INCREMENTALCLEANUPSTRATEGY']._serialized_start=8641
+
_globals['_STATEDESCRIPTOR_STATETTLCONFIG_CLEANUPSTRATEGIES_INCREMENTALCLEANUPSTRATEGY']._serialized_end=8729
+
_globals['_STATEDESCRIPTOR_STATETTLCONFIG_CLEANUPSTRATEGIES_ROCKSDBCOMPACTFILTERCLEANUPSTRATEGY']._serialized_start=8731
+
_globals['_STATEDESCRIPTOR_STATETTLCONFIG_CLEANUPSTRATEGIES_ROCKSDBCOMPACTFILTERCLEANUPSTRATEGY']._serialized_end=8806
+
_globals['_STATEDESCRIPTOR_STATETTLCONFIG_CLEANUPSTRATEGIES_MAPSTRATEGIESENTRY']._serialized_start=8809
+
_globals['_STATEDESCRIPTOR_STATETTLCONFIG_CLEANUPSTRATEGIES_MAPSTRATEGIESENTRY']._serialized_end=9417
+
_globals['_STATEDESCRIPTOR_STATETTLCONFIG_CLEANUPSTRATEGIES_STRATEGIES']._serialized_start=9419
+
_globals['_STATEDESCRIPTOR_STATETTLCONFIG_CLEANUPSTRATEGIES_STRATEGIES']._serialized_end=9517
+
_globals['_STATEDESCRIPTOR_STATETTLCONFIG_CLEANUPSTRATEGIES_EMPTYCLEANUPSTRATEGY']._serialized_start=9519
+
_globals['_STATEDESCRIPTOR_STATETTLCONFIG_CLEANUPSTRATEGIES_EMPTYCLEANUPSTRATEGY']._serialized_end=9561
+ _globals['_STATEDESCRIPTOR_STATETTLCONFIG_UPDATETYPE']._serialized_start=9563
+ _globals['_STATEDESCRIPTOR_STATETTLCONFIG_UPDATETYPE']._serialized_end=9631
+
_globals['_STATEDESCRIPTOR_STATETTLCONFIG_STATEVISIBILITY']._serialized_start=9633
+
_globals['_STATEDESCRIPTOR_STATETTLCONFIG_STATEVISIBILITY']._serialized_end=9707
+
_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_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
# @@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 554c04492d9..520077f395b 100644
--- a/flink-python/pyflink/fn_execution/flink_fn_execution_pb2.pyi
+++ b/flink-python/pyflink/fn_execution/flink_fn_execution_pb2.pyi
@@ -45,18 +45,20 @@ class Input(_message.Message):
def __init__(self, udf: _Optional[_Union[UserDefinedFunction, _Mapping]] =
..., inputOffset: _Optional[int] = ..., inputConstant: _Optional[bytes] = ...,
refIndex: _Optional[int] = ...) -> None: ...
class UserDefinedFunction(_message.Message):
- __slots__ = ("payload", "inputs", "window_index", "takes_row_as_input",
"is_pandas_udf")
+ __slots__ = ("payload", "inputs", "window_index", "takes_row_as_input",
"is_pandas_udf", "is_arrow_udf")
PAYLOAD_FIELD_NUMBER: _ClassVar[int]
INPUTS_FIELD_NUMBER: _ClassVar[int]
WINDOW_INDEX_FIELD_NUMBER: _ClassVar[int]
TAKES_ROW_AS_INPUT_FIELD_NUMBER: _ClassVar[int]
IS_PANDAS_UDF_FIELD_NUMBER: _ClassVar[int]
+ IS_ARROW_UDF_FIELD_NUMBER: _ClassVar[int]
payload: bytes
inputs: _containers.RepeatedCompositeFieldContainer[Input]
window_index: int
takes_row_as_input: bool
is_pandas_udf: bool
- def __init__(self, payload: _Optional[bytes] = ..., inputs:
_Optional[_Iterable[_Union[Input, _Mapping]]] = ..., window_index:
_Optional[int] = ..., takes_row_as_input: bool = ..., is_pandas_udf: bool =
...) -> None: ...
+ is_arrow_udf: bool
+ def __init__(self, payload: _Optional[bytes] = ..., inputs:
_Optional[_Iterable[_Union[Input, _Mapping]]] = ..., window_index:
_Optional[int] = ..., takes_row_as_input: bool = ..., is_pandas_udf: bool =
..., is_arrow_udf: bool = ...) -> None: ...
class AsyncOptions(_message.Message):
__slots__ = ("max_concurrent_operations", "timeout_ms", "retry_enabled",
"retry_max_attempts", "retry_delay_ms")
@@ -645,10 +647,18 @@ class CoderInfoDescriptor(_message.Message):
schema: Schema
def __init__(self, schema: _Optional[_Union[Schema, _Mapping]] = ...)
-> None: ...
class ArrowType(_message.Message):
- __slots__ = ("schema",)
+ __slots__ = ("schema", "batch_format")
+ class BatchFormat(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
+ __slots__ = ()
+ PANDAS: _ClassVar[CoderInfoDescriptor.ArrowType.BatchFormat]
+ ARROW: _ClassVar[CoderInfoDescriptor.ArrowType.BatchFormat]
+ PANDAS: CoderInfoDescriptor.ArrowType.BatchFormat
+ ARROW: CoderInfoDescriptor.ArrowType.BatchFormat
SCHEMA_FIELD_NUMBER: _ClassVar[int]
+ BATCH_FORMAT_FIELD_NUMBER: _ClassVar[int]
schema: Schema
- def __init__(self, schema: _Optional[_Union[Schema, _Mapping]] = ...)
-> None: ...
+ batch_format: CoderInfoDescriptor.ArrowType.BatchFormat
+ def __init__(self, schema: _Optional[_Union[Schema, _Mapping]] = ...,
batch_format: _Optional[_Union[CoderInfoDescriptor.ArrowType.BatchFormat, str]]
= ...) -> None: ...
class OverWindowArrowType(_message.Message):
__slots__ = ("schema",)
SCHEMA_FIELD_NUMBER: _ClassVar[int]
diff --git a/flink-python/pyflink/fn_execution/table/operations.py
b/flink-python/pyflink/fn_execution/table/operations.py
index 35ff6a09d68..6459602aa31 100644
--- a/flink-python/pyflink/fn_execution/table/operations.py
+++ b/flink-python/pyflink/fn_execution/table/operations.py
@@ -141,9 +141,11 @@ class ScalarFunctionOperation(BaseOperation):
Generates a UDF execution function. Uses sequential execution with
result references
when refIndex is present (CSE mode), otherwise uses lambda-based
approach.
"""
+ is_arrow = all(udf.is_arrow_udf for udf in serialized_fn.udfs)
+ one_arg_optimization = self._one_arg_optimization and not is_arrow
udf_infos = [
operation_utils.extract_user_defined_function(
- udf, one_arg_optimization=self._one_arg_optimization)
+ udf, one_arg_optimization=one_arg_optimization)
for udf in serialized_fn.udfs]
variable_dict = {}
@@ -154,6 +156,9 @@ class ScalarFunctionOperation(BaseOperation):
user_defined_funcs.extend(funcs)
func_strs.append(func_str)
+ if is_arrow:
+ variable_dict['create_record_batch'] =
operation_utils.create_record_batch
+
output_indices = list(serialized_fn.output_indices)
# Result references require sequential evaluation. A non-empty
output_indices does too:
# it may repeat or reorder results even when none of the UDFs
references another result.
@@ -163,7 +168,10 @@ class ScalarFunctionOperation(BaseOperation):
if not requires_sequential_execution:
# Keep original lambda-based approach for backward compatibility
scalar_functions = ','.join(func_strs)
- if self._one_result_optimization:
+ if is_arrow:
+ func_str = (f'lambda value: create_record_batch('
+ f'[{scalar_functions}], value.num_rows)')
+ elif self._one_result_optimization:
func_str = 'lambda value: %s' % scalar_functions
else:
func_str = 'lambda value: [%s]' % scalar_functions
@@ -186,7 +194,10 @@ class ScalarFunctionOperation(BaseOperation):
code_lines.append(' results = [None] * %d' % len(func_strs))
for i, fn in enumerate(func_strs):
code_lines.append(' results[%d] = %s' % (i, fn))
- if self._one_result_optimization:
+ if is_arrow:
+ outputs = ','.join('results[%d]' % i for i in output_indices)
+ code_lines.append(f' return create_record_batch([{outputs}],
value.num_rows)')
+ elif self._one_result_optimization:
code_lines.append(' return results[%d]' % output_indices[0])
else:
code_lines.append(
diff --git a/flink-python/pyflink/fn_execution/tests/test_coders.py
b/flink-python/pyflink/fn_execution/tests/test_coders.py
index f04a33f1649..8cdb473b623 100644
--- a/flink-python/pyflink/fn_execution/tests/test_coders.py
+++ b/flink-python/pyflink/fn_execution/tests/test_coders.py
@@ -19,7 +19,12 @@
"""Tests common to all coder implementations."""
import decimal
import logging
+import os
import unittest
+from unittest import mock
+
+import pyarrow as pa
+import pytz
from pyflink.fn_execution.coders import BigIntCoder, TinyIntCoder,
BooleanCoder, \
SmallIntCoder, IntCoder, FloatCoder, DoubleCoder, BinaryCoder, CharCoder,
DateCoder, \
@@ -30,6 +35,301 @@ from pyflink.datastream.window import TimeWindow,
CountWindow
from pyflink.testing.test_case_utils import PyFlinkTestCase
+class ArrowSchemaTests(unittest.TestCase):
+ def test_nested_types_require_arrow_mode(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'))])),
+ ):
+ 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)
+
+ def test_pandas_collection_schema_and_round_trip(self):
+ import pandas as pd
+ from pyflink.table import DataTypes
+ from pyflink.table.types import create_arrow_schema
+ from pyflink.table.utils import arrow_to_pandas, pandas_to_arrow
+
+ names = ["values", "lookup", "record"]
+ types = [
+ DataTypes.ARRAY(DataTypes.INT().not_null()),
+ DataTypes.MAP(DataTypes.STRING(),
DataTypes.ARRAY(DataTypes.INT()).not_null()),
+ DataTypes.ROW([DataTypes.FIELD("values",
DataTypes.ARRAY(DataTypes.INT().not_null()))])]
+ schema = create_arrow_schema(names, types)
+ self.assertEqual(schema.field("values").type.value_field.name, "item")
+ self.assertFalse(schema.field("values").type.value_field.nullable)
+ if hasattr(pa.MapType, 'item_field'):
+ self.assertFalse(schema.field("lookup").type.key_field.nullable)
+ self.assertFalse(schema.field("lookup").type.item_field.nullable)
+
self.assertTrue(schema.field("lookup").type.item_type.value_field.nullable)
+
self.assertFalse(schema.field("record").type[0].type.value_field.nullable)
+
+ # Correcting schema metadata must not add null checks to the pandas
conversion path.
+ columns = [pd.Series([[1, 2], [], None, [None]]),
+ pd.Series([[('a', [1])], [], None, [('b', None)]]),
+ pd.DataFrame({"values": [[3], [], None, [None]]})]
+ batch = pandas_to_arrow(schema, pytz.UTC, types, columns)
+ with pa.BufferOutputStream() as output:
+ with pa.ipc.new_stream(output, schema) as writer:
+ writer.write_batch(batch)
+ decoded = pa.ipc.open_stream(output.getvalue()).read_next_batch()
+ expected = [
+ {"values": [1, 2], "lookup": [('a', [1])], "record": {"values":
[3]}},
+ {"values": [], "lookup": [], "record": {"values": []}},
+ {"values": None, "lookup": None, "record": {"values": None}},
+ {"values": [None], "lookup": [('b', None)], "record": {"values":
[None]}}]
+ expected = {name: [row[name] for row in expected] for name in names}
+ self.assertEqual(decoded.to_pydict(), expected)
+ restored = arrow_to_pandas(pytz.UTC, types, [decoded])
+ self.assertEqual(pandas_to_arrow(schema, pytz.UTC, types,
restored).to_pydict(), expected)
+
+ def test_arrow_descriptor_preserves_pandas_default(self):
+ from pyflink.fn_execution import flink_fn_execution_pb2 as proto
+ from pyflink.fn_execution.coders import LengthPrefixBaseCoder
+ import pandas as pd
+
+ arrow_type =
proto.CoderInfoDescriptor.ArrowType(schema=proto.Schema(fields=[
+ proto.Schema.Field(name="name", type=proto.Schema.FieldType(
+ type_name=proto.Schema.VARCHAR, nullable=True,
+ var_char_info=proto.Schema.VarCharInfo(length=2147483647)))]))
+ descriptor = proto.CoderInfoDescriptor(arrow_type=arrow_type)
+ with mock.patch.dict(os.environ, {"TABLE_LOCAL_TIME_ZONE": "UTC"}):
+ pandas_coder =
LengthPrefixBaseCoder._to_field_coder(descriptor).get_impl()
+ result = pandas_coder.decode(pandas_coder.encode([pd.Series(["a",
None])]))
+ self.assertIsInstance(result[0], pd.Series)
+ self.assertEqual(result[0].tolist(), ["a", None])
+ descriptor.arrow_type.batch_format =
proto.CoderInfoDescriptor.ArrowType.ARROW
+ arrow_coder =
LengthPrefixBaseCoder._to_field_coder(descriptor).get_impl()
+ batch = pa.record_batch([pa.array(["a", None])], names=["name"])
+ self.assertEqual(arrow_coder.decode(arrow_coder.encode(batch)),
batch)
+
+
+class ArrowCodersTests(unittest.TestCase):
+ from pyflink.fn_execution import coder_impl_slow as implementation
+
+ def arrow_coder(self, schema, row_type):
+ return self.implementation.ArrowCoderImpl(schema, row_type, pytz.UTC,
"ARROW")
+
+ @staticmethod
+ def with_parent_nulls(column, nulls):
+ validity = pa.array([not value for value in nulls]).buffers()[1]
+ if pa.types.is_struct(column.type):
+ buffers = [validity]
+ children = [column.field(index) for index in
range(column.type.num_fields)]
+ else:
+ buffers = [validity, column.buffers()[1]]
+ if pa.types.is_map(column.type):
+ children = [pa.StructArray.from_arrays(
+ [column.keys, column.items],
+ fields=[pa.field("key", column.type.key_type,
nullable=False),
+ pa.field("value", column.type.item_type)])]
+ else:
+ children = [column.values]
+ return pa.Array.from_buffers(column.type, len(column), buffers,
children=children)
+
+ def test_native_arrow_timezone(self):
+ from pyflink.fn_execution import flink_fn_execution_pb2 as proto
+ from pyflink.fn_execution.coders import LengthPrefixBaseCoder
+
+ schema_proto = proto.Schema(fields=[
+ proto.Schema.Field(name="number", type=proto.Schema.FieldType(
+ type_name=proto.Schema.BIGINT, nullable=True)),
+ proto.Schema.Field(name="timestamp", type=proto.Schema.FieldType(
+ type_name=proto.Schema.TIMESTAMP, nullable=True,
+ timestamp_info=proto.Schema.TimestampInfo(precision=3))),
+ proto.Schema.Field(name="local_timestamp",
type=proto.Schema.FieldType(
+ type_name=proto.Schema.LOCAL_ZONED_TIMESTAMP, nullable=True,
+
local_zoned_timestamp_info=proto.Schema.LocalZonedTimestampInfo(precision=3)))])
+ descriptor =
proto.CoderInfoDescriptor(arrow_type=proto.CoderInfoDescriptor.ArrowType(
+ schema=schema_proto,
batch_format=proto.CoderInfoDescriptor.ArrowType.ARROW))
+ batch = pa.record_batch([pa.array([1, None]), pa.array([0, None],
type=pa.timestamp('ms')),
+ pa.array([0, None], type=pa.timestamp('ms'))],
+ names=["number", "timestamp",
"local_timestamp"])
+ for timezone in ("UTC", "GMT+08:00", "SystemV/PST8PDT"):
+ with self.subTest(timezone=timezone):
+ with mock.patch.dict(os.environ, {"TABLE_LOCAL_TIME_ZONE":
timezone}), \
+ mock.patch('pyflink.fn_execution.coders.coder_impl',
self.implementation):
+ coder =
LengthPrefixBaseCoder._to_field_coder(descriptor).get_impl()
+ self.assertEqual(coder.decode(coder.encode(batch)), batch)
+
+ def test_arrow_nested_nullability(self):
+ from pyflink.table import DataTypes
+ from pyflink.table.types import to_arrow_type
+
+ row_type = DataTypes.ROW([
+ DataTypes.FIELD("values",
DataTypes.ARRAY(DataTypes.INT().not_null()))])
+ schema = pa.schema([pa.field("values",
to_arrow_type(row_type.field_types()[0]))])
+ coder = self.arrow_coder(schema, row_type)
+ batch = pa.record_batch([pa.array([[1, None]],
type=pa.list_(pa.int32()))], schema=schema)
+ with self.assertRaisesRegex(ValueError, "values.*not nullable"):
+ coder.encode(batch)
+
+ valid = pa.record_batch([pa.array([[1, 2], None],
type=pa.list_(pa.int32()))],
+ schema=schema)
+ self.assertEqual(coder.decode(coder.encode(valid)), valid)
+
+ def test_struct_map_and_temporal_results(self):
+ import datetime
+ from pyflink.table import DataTypes
+ from pyflink.table.types import create_arrow_schema
+
+ row_type = DataTypes.ROW([
+ DataTypes.FIELD("record", DataTypes.ROW([
+ DataTypes.FIELD("inner", DataTypes.ROW([
+ DataTypes.FIELD("value", DataTypes.INT().not_null())]))])),
+ DataTypes.FIELD("lookup", DataTypes.MAP(
+ DataTypes.STRING().not_null(), DataTypes.INT().not_null())),
+ DataTypes.FIELD("amount", DataTypes.DECIMAL(6, 2)),
+ DataTypes.FIELD("time", DataTypes.TIMESTAMP(3))])
+ schema = create_arrow_schema(row_type.field_names(),
row_type.field_types(),
+ allow_nested=True)
+ coder = self.arrow_coder(schema, row_type)
+ rows = [
+ {"record": {"inner": {"value": 7}}, "lookup": [("a", 1)],
+ "amount": decimal.Decimal("12.34"), "time":
datetime.datetime(2020, 1, 2)},
+ {"record": None, "lookup": None, "amount": None, "time": None},
+ {"record": {"inner": None}, "lookup": [],
+ "amount": decimal.Decimal("-0.50"), "time":
datetime.datetime(2021, 3, 4)}]
+ expected = {name: [row[name] for row in rows] for name in schema.names}
+ batch = pa.record_batch([pa.array(expected[field.name],
type=field.type)
+ for field in schema], schema=schema)
+ self.assertEqual(coder.decode(coder.encode(batch)).to_pydict(),
expected)
+
self.assertEqual(coder.decode(coder.encode(batch.slice(1))).to_pydict(),
+ {name: values[1:] for name, values in
expected.items()})
+
+ for field, value, message in (
+ ("record", {"inner": {"value": None}}, "record.inner.value.*not
nullable"),
+ ("lookup", [("a", None)], "lookup.value.*not nullable"),
+ ):
+ with self.subTest(field=field):
+ invalid_values = {**rows[0], field: value}
+ invalid = pa.record_batch([
+ pa.array([invalid_values[child.name]], type=child.type)
for child in schema],
+ schema=schema)
+ with self.assertRaisesRegex(ValueError, message):
+ coder.encode(invalid)
+
+ wrong = pa.StructArray.from_arrays([pa.array([1], type=pa.int64())],
names=["value"])
+ invalid = pa.record_batch([pa.StructArray.from_arrays([wrong],
names=["inner"]),
+ batch.column(1).slice(0, 1),
batch.column(2).slice(0, 1),
+ batch.column(3).slice(0, 1)],
names=schema.names)
+ with self.assertRaisesRegex(TypeError,
"record.inner.value.*int64.*int32"):
+ coder.encode(invalid)
+
+ def test_native_arrow_round_trip(self):
+ from pyflink.table import DataTypes
+
+ row_type = DataTypes.ROW([DataTypes.FIELD("name", DataTypes.STRING())])
+ schema = pa.schema([pa.field("name", pa.string())])
+ coder = self.arrow_coder(schema, row_type)
+ batch = pa.record_batch([pa.array(["ALICE", None, "BOB"])],
schema=schema)
+ self.assertEqual(coder.decode(coder.encode(batch)), batch)
+
+ with self.assertRaisesRegex(TypeError, "name.*string"):
+ coder.encode(pa.record_batch([pa.array([1, 2])], names=["name"]))
+
+ def test_pandas_map_round_trip(self):
+ import pandas as pd
+ from pyflink.table import DataTypes
+ from pyflink.table.types import create_arrow_schema
+
+ row_type = DataTypes.ROW([
+ DataTypes.FIELD("lookup", DataTypes.MAP(DataTypes.STRING(),
DataTypes.BIGINT()))])
+ schema = create_arrow_schema(row_type.field_names(),
row_type.field_types())
+ coder = self.implementation.ArrowCoderImpl(schema, row_type, pytz.UTC)
+ values = pd.Series([[("value", 10)], [("value", 20)], None, []])
+ result = coder.decode(coder.encode([values]))
+ self.assertEqual(result[0].tolist(), [[("value", 10)], [("value",
20)], None, []])
+
+ def test_inferred_struct_nullability(self):
+ from pyflink.table import DataTypes
+ from pyflink.table.types import create_arrow_schema
+
+ row_type = DataTypes.ROW([DataTypes.FIELD("wrapped", DataTypes.ROW([
+ DataTypes.FIELD("v", DataTypes.BIGINT().not_null())]))])
+ schema = create_arrow_schema(row_type.field_names(),
row_type.field_types(),
+ allow_nested=True)
+ coder = self.arrow_coder(schema, row_type)
+ column = pa.StructArray.from_arrays([pa.array([0, 1, 2])], names=["v"])
+ with_nulls = pa.StructArray.from_arrays([pa.array([None, 1, 2])],
names=["v"])
+ for values in (column, column.slice(1), column.slice(1, 0),
+ with_nulls.slice(1), with_nulls.slice(1, 0)):
+ with self.subTest(offset=values.offset, length=len(values)):
+ result = coder.decode(coder.encode(pa.record_batch([values],
names=["wrapped"])))
+ self.assertEqual(result.schema, schema)
+ self.assertEqual(result.column(0).to_pylist(),
values.to_pylist())
+ invalid = pa.StructArray.from_arrays([pa.array([None],
type=pa.int64())], names=["v"])
+ with self.assertRaisesRegex(ValueError, "wrapped.v.*not nullable"):
+ coder.encode(pa.record_batch([invalid], names=["wrapped"]))
+
+ def test_sliced_container_nullability(self):
+ from pyflink.table import DataTypes
+ from pyflink.table.types import create_arrow_schema
+
+ item_type = DataTypes.ROW([DataTypes.FIELD("required",
DataTypes.INT().not_null())])
+ items = pa.array([{"required": None}, {"required": 1}, {"required":
None},
+ {"required": None}, None, {"required": None}],
+ type=pa.struct([pa.field("required", pa.int32())]))
+ booleans = pa.array([None, True, None, None, False, None])
+ nulls = [False, False, True, False, False, False]
+ offsets = pa.array([0, 1, 2, 2, 4, 5, 6], type=pa.int32())
+ for data_type, column, first, last in (
+ (DataTypes.ROW([DataTypes.FIELD("value", item_type)]),
+ pa.StructArray.from_arrays([items], names=["value"]),
+ {"value": {"required": 1}}, {"value": None}),
+ (DataTypes.ARRAY(item_type), pa.ListArray.from_arrays(offsets,
items),
+ [{"required": 1}], [None]),
+ (DataTypes.MAP(DataTypes.STRING().not_null(), item_type),
+ pa.MapArray.from_arrays(offsets, pa.array(['k'] * 6), items),
+ [('k', {"required": 1})], [('k', None)]),
+ (DataTypes.ARRAY(DataTypes.BOOLEAN().not_null()),
+ pa.ListArray.from_arrays(offsets, booleans), [True], [False]),
+ (DataTypes.MAP(DataTypes.STRING().not_null(),
DataTypes.BOOLEAN().not_null()),
+ pa.MapArray.from_arrays(offsets, pa.array(['k'] * 6), booleans),
+ [('k', True)], [('k', False)]),
+ ):
+ with self.subTest(data_type=data_type):
+ column = self.with_parent_nulls(column, nulls)
+ row_type = DataTypes.ROW([DataTypes.FIELD("record",
DataTypes.ROW([
+ DataTypes.FIELD("container", data_type)]))])
+ schema = create_arrow_schema(row_type.field_names(),
row_type.field_types(),
+ allow_nested=True)
+ outer = self.with_parent_nulls(
+ pa.StructArray.from_arrays([column], names=["container"]),
+ [False, False, False, True, False, False])
+ batch = pa.record_batch([outer], names=["record"])
+ coder = self.arrow_coder(schema, row_type)
+ self.assertEqual(coder.decode(coder.encode(batch.slice(1,
4))).to_pydict(), {
+ "record": [{"container": first}, {"container": None},
+ None, {"container": last}]})
+ self.assertEqual(coder.decode(coder.encode(batch.slice(0,
0))).num_rows, 0)
+ with self.assertRaisesRegex(ValueError, "not nullable"):
+ coder.encode(batch.slice(0, 4))
+
+
+try:
+ from pyflink.fn_execution import coder_impl_fast
+except ImportError:
+ coder_impl_fast = None
+
+
[email protected](coder_impl_fast is None, "Compiled coders are not installed")
+class FastArrowCodersTests(ArrowCodersTests):
+ implementation = coder_impl_fast
+
+
class CodersTest(PyFlinkTestCase):
def check_coder(self, coder, *values):
diff --git a/flink-python/pyflink/fn_execution/utils/operation_utils.py
b/flink-python/pyflink/fn_execution/utils/operation_utils.py
index ff96585e4c2..5bb4ff18226 100644
--- a/flink-python/pyflink/fn_execution/utils/operation_utils.py
+++ b/flink-python/pyflink/fn_execution/utils/operation_utils.py
@@ -93,6 +93,35 @@ def check_pandas_udf_result(f, *input_args):
return output
+def check_arrow_udf_result(func, *args):
+ import pyarrow as pa
+
+ result = func(*args)
+ name = getattr(func, "__qualname__", type(func).__name__)
+ if not isinstance(result, (pa.Array, pa.ChunkedArray)):
+ raise TypeError(
+ f"Arrow UDF '{name}' must return a pyarrow.Array or
pyarrow.ChunkedArray, "
+ f"got {type(result).__name__}.")
+ for arg in args:
+ if isinstance(arg, (pa.Array, pa.ChunkedArray)) and len(result) !=
len(arg):
+ raise ValueError(
+ f"Arrow UDF '{name}' returned {len(result)} rows, expected
{len(arg)}.")
+ if isinstance(result, pa.ChunkedArray):
+ result = result.chunk(0) if result.num_chunks == 1 else
result.combine_chunks()
+ return result
+
+
+def create_record_batch(results, row_count):
+ import pyarrow as pa
+
+ columns = []
+ for result in results:
+ if len(result) != row_count:
+ raise ValueError(f"Arrow UDF returned {len(result)} rows, expected
{row_count}.")
+ columns.append(result)
+ return pa.RecordBatch.from_arrays(columns, names=[f"f{i}" for i in
range(len(columns))])
+
+
def extract_over_window_user_defined_function(user_defined_function_proto):
window_index = user_defined_function_proto.window_index
return (*extract_user_defined_function(user_defined_function_proto, True),
window_index)
@@ -160,6 +189,8 @@ def
extract_user_defined_function(user_defined_function_proto, pandas_udaf=False
variable_dict[func_name] = user_defined_func.func
else:
variable_dict[func_name] = user_defined_func.eval
+ if user_defined_function_proto.is_arrow_udf:
+ variable_dict[func_name] = partial(check_arrow_udf_result,
variable_dict[func_name])
user_defined_funcs.append(user_defined_func)
func_args, input_variable_dict, input_funcs =
_extract_input(user_defined_function_proto.inputs)
@@ -179,6 +210,13 @@ def
extract_user_defined_function(user_defined_function_proto, pandas_udaf=False
# receives a previously computed intermediate result rather than
# the original input row. We must use func_args instead of `value`.
func_str = "%s(%s)" % (func_name, func_args)
+ elif user_defined_function_proto.is_arrow_udf:
+ import pyarrow as pa
+
+ variable_dict['create_struct_array'] = pa.StructArray.from_arrays
+ offsets = [arg.inputOffset for arg in
user_defined_function_proto.inputs]
+ func_str = (f"{func_name}(create_struct_array([{func_args}], "
+ f"fields=[value.schema[i] for i in {offsets}]))")
else:
# directly use `value` as input argument
# e.g.
diff --git a/flink-python/pyflink/proto/flink-fn-execution.proto
b/flink-python/pyflink/proto/flink-fn-execution.proto
index 4644909bad9..a9300ba4949 100644
--- a/flink-python/pyflink/proto/flink-fn-execution.proto
+++ b/flink-python/pyflink/proto/flink-fn-execution.proto
@@ -64,6 +64,9 @@ message UserDefinedFunction {
// Whether it's pandas UDF
bool is_pandas_udf = 5;
+
+ // Whether the scalar UDF consumes and returns Arrow arrays directly
+ bool is_arrow_udf = 6;
}
// Async execution configuration for async functions
@@ -526,6 +529,11 @@ message CoderInfoDescriptor {
message ArrowType {
Schema schema = 1;
+ enum BatchFormat {
+ PANDAS = 0;
+ ARROW = 1;
+ }
+ BatchFormat batch_format = 2;
}
// only used in batch over window
diff --git a/flink-python/pyflink/table/tests/test_pandas_udf.py
b/flink-python/pyflink/table/tests/test_pandas_udf.py
index 192ce9c65b4..84d25d4307d 100644
--- a/flink-python/pyflink/table/tests/test_pandas_udf.py
+++ b/flink-python/pyflink/table/tests/test_pandas_udf.py
@@ -32,7 +32,7 @@ class PandasUDFTests(PyFlinkTestCase):
def test_non_exist_func_type(self):
with self.assertRaisesRegex(ValueError,
- 'The func_type must be one of \'general,
pandas\''):
+ "The func_type must be one of 'general,
pandas, arrow'"):
udf(lambda i: i + 1, result_type=DataTypes.BIGINT(),
func_type="non-exist")
diff --git a/flink-python/pyflink/table/tests/test_udf.py
b/flink-python/pyflink/table/tests/test_udf.py
index eb3e1447c94..3a5d42107e1 100644
--- a/flink-python/pyflink/table/tests/test_udf.py
+++ b/flink-python/pyflink/table/tests/test_udf.py
@@ -17,15 +17,21 @@
################################################################################
import datetime
import os
+import pickle
import unittest
import uuid
+import cloudpickle
+import pyarrow as pa
+import pyarrow.compute as pc
import pytz
from pyflink.common import Row
+from pyflink.fn_execution import flink_fn_execution_pb2 as proto
+from pyflink.fn_execution.table.operations import ScalarFunctionOperation
from pyflink.table import DataTypes, expressions as expr
from pyflink.table.expressions import call
-from pyflink.table.udf import ScalarFunction, udf, FunctionContext
+from pyflink.table.udf import DelegatingScalarFunction, ScalarFunction, udf,
FunctionContext
from pyflink.testing import source_sink_utils
from pyflink.testing.test_case_utils import PyFlinkStreamTableTestCase, \
PyFlinkBatchTableTestCase
@@ -35,6 +41,167 @@ def generate_random_table_name():
return "Table{0}".format(str(uuid.uuid1()).replace("-", "_"))
+class ArrowScalarOperationTests(unittest.TestCase):
+ def operation(self, func, inputs, takes_row_as_input=False, preceding=(),
output_indices=()):
+ function = proto.UserDefinedFunction(
+ payload=cloudpickle.dumps(DelegatingScalarFunction(func)),
+ is_arrow_udf=True, inputs=inputs,
takes_row_as_input=takes_row_as_input)
+ operation = ScalarFunctionOperation(proto.UserDefinedFunctions(
+ udfs=[*preceding, function], output_indices=output_indices))
+ operation.open()
+ self.addCleanup(operation.close)
+ return operation
+
+ def test_literals_columns_and_chunked_results(self):
+ def add(offset, left, right):
+ if not isinstance(offset, int) or not isinstance(left, pa.Array) \
+ or not isinstance(right, pa.Array):
+ raise TypeError("Expected a scalar literal followed by two
Arrow arrays.")
+ values = pc.add(pc.add(left, right), offset)
+ return pa.chunked_array([values.slice(0, 1), values.slice(1)])
+
+ operation = self.operation(add, [
+ proto.Input(inputConstant=b"\x00" + pickle.dumps(10)),
+ proto.Input(inputOffset=0), proto.Input(inputOffset=1)])
+ result = operation.process_element(pa.record_batch(
+ [pa.array([1, None, 3]), pa.array([4, 5, 6])], names=["a", "b"]))
+ self.assertEqual(result.column(0).to_pylist(), [15, None, 19])
+
+ def test_single_chunk_result_preserves_buffers(self):
+ values = pa.array([0, 1, None, 3]).slice(1, 2)
+ operation = self.operation(lambda column: pa.chunked_array([column]),
+ [proto.Input(inputOffset=0)])
+ result = operation.process_element(pa.record_batch([values],
names=["value"]))
+ column = result.column(0)
+ self.assertEqual(column.to_pylist(), [1, None])
+ self.assertEqual(column.offset, values.offset)
+ self.assertEqual([buffer.address for buffer in column.buffers()],
+ [buffer.address for buffer in values.buffers()])
+
+ def test_empty_chunked_results(self):
+ values = pa.array([], type=pa.int64())
+ for chunks in ([], [values]):
+ with self.subTest(chunks=len(chunks)):
+ operation = self.operation(
+ lambda column: pa.chunked_array(chunks, type=pa.int64()),
+ [proto.Input(inputOffset=0)])
+ result = operation.process_element(pa.record_batch([values],
names=["value"]))
+ self.assertEqual(result.column(0), values)
+
+ def test_nested_chunked_results(self):
+ values = pa.StructArray.from_arrays([pa.array([0, 1, None, 3])],
names=["value"])
+ for chunks in (0, 1, 2):
+ with self.subTest(chunks=chunks):
+ column = values.slice(1, 0 if chunks == 0 else 3)
+
+ def chunk_result(array):
+ parts = [] if chunks == 0 else [array] if chunks == 1 else
[
+ array.slice(0, 1), array.slice(1)]
+ return pa.chunked_array(parts, type=array.type)
+
+ inner = proto.UserDefinedFunction(
+
payload=cloudpickle.dumps(DelegatingScalarFunction(chunk_result)),
+ is_arrow_udf=True, inputs=[proto.Input(inputOffset=0)])
+ for shared in (False, True):
+ with self.subTest(shared=shared):
+ operation = self.operation(
+ lambda row: row.field("value"),
+ [proto.Input(refIndex=0)] if shared else
[proto.Input(udf=inner)],
+ preceding=[inner] if shared else [],
+ output_indices=[1, 0, 1] if shared else [])
+ result = operation.process_element(
+ pa.record_batch([column], names=["record"]))
+ self.assertEqual(result.num_columns, 3 if shared else
1)
+ self.assertEqual(result.column(0).to_pylist(),
+ [] if chunks == 0 else [1, None, 3])
+ if shared:
+ self.assertEqual(result.column(1), column)
+ self.assertEqual(result.column(2),
result.column(0))
+ if chunks == 1:
+ self.assertEqual(result.column(0).offset,
column.field(0).offset)
+ self.assertEqual(
+ [buffer.address for buffer in
result.column(0).buffers()],
+ [buffer.address for buffer in
column.field(0).buffers()])
+
+ def test_whole_row_input(self):
+ batch = pa.record_batch([pa.array(["alice", None]), pa.array([1, 2])],
+ names=["name", "count"])
+
+ def increment(row):
+ return pa.StructArray.from_arrays(
+ [pc.struct_field(row, "name"), pc.add(pc.struct_field(row,
"count"), 1)],
+ names=["name", "count"])
+
+ inputs = [proto.Input(inputOffset=0), proto.Input(inputOffset=1)]
+ identity = proto.UserDefinedFunction(
+ payload=cloudpickle.dumps(DelegatingScalarFunction(lambda row:
row)),
+ is_arrow_udf=True, inputs=inputs, takes_row_as_input=True)
+ for arguments in (inputs, [proto.Input(udf=identity)],
[proto.Input(refIndex=0)]):
+ with self.subTest(input_kind=arguments[0].WhichOneof("input")):
+ shared = arguments[0].HasField("refIndex")
+ operation = self.operation(
+ increment, arguments, takes_row_as_input=True,
+ preceding=[identity] if shared else [], output_indices=[1]
if shared else [])
+ result = operation.process_element(batch)
+ self.assertEqual(result.num_columns, 1)
+ self.assertEqual(result.column(0).to_pylist(),
+ [{"name": "alice", "count": 2}, {"name":
None, "count": 3}])
+
+ def test_whole_row_argument_offsets(self):
+ batch = pa.record_batch([pa.array([0, 1, None, 3]), pa.array([0, 100,
200, 300])],
+ names=["a", "b"]).slice(1)
+ for offsets in ([0, 0, 1], [1, 0]):
+ with self.subTest(offsets=offsets):
+ operation = self.operation(
+ lambda row: row.field(1),
+ [proto.Input(inputOffset=offset) for offset in offsets],
+ takes_row_as_input=True)
+ result = operation.process_element(batch)
+ self.assertEqual(result.column(0).to_pylist(), [1, None, 3])
+
+ def test_invalid_scalar_results(self):
+ batch = pa.record_batch([pa.array([1, 2, 3])], names=["value"])
+ for result, error, message in (
+ ([1, 2, 3], TypeError, "Array or pyarrow.ChunkedArray"),
+ (None, TypeError, "NoneType"),
+ (pa.scalar(1), TypeError, "Scalar"),
+ (batch, TypeError, "RecordBatch"),
+ (pa.Table.from_batches([batch]), TypeError, "Table"),
+ (pa.array([1]), ValueError, "returned 1 rows, expected 3"),
+ (pa.chunked_array([[1], [2]]), ValueError, "returned 2 rows,
expected 3"),
+ ):
+ with self.subTest(result=result):
+ operation = self.operation(lambda values: result,
[proto.Input(inputOffset=0)])
+ with self.assertRaisesRegex(error, message):
+ operation.process_element(batch)
+
+ def test_invalid_intermediate_result_is_not_consumed(self):
+ inner = proto.UserDefinedFunction(
+ payload=cloudpickle.dumps(DelegatingScalarFunction(lambda values:
values.slice(0, 1))),
+ is_arrow_udf=True, inputs=[proto.Input(inputOffset=0)])
+ # The outer result has the correct batch length, but must not hide the
invalid inner result.
+ operation = self.operation(lambda values: pa.array([1, 2, 3]),
[proto.Input(udf=inner)])
+ with self.assertRaisesRegex(ValueError, "returned 1 rows, expected 3"):
+ operation.process_element(pa.record_batch([pa.array([1, 2, 3])],
names=["value"]))
+
+ def test_arrow_scalar_operation(self):
+ class Uppercase(ScalarFunction):
+ def eval(self, values):
+ return pc.utf8_upper(values)
+
+ function = proto.UserDefinedFunction(
+ payload=cloudpickle.dumps(Uppercase()), is_arrow_udf=True,
+ inputs=[proto.Input(inputOffset=0)])
+ operation = ScalarFunctionOperation(
+ proto.UserDefinedFunctions(udfs=[function]),
one_arg_optimization=True)
+ operation.open()
+ self.addCleanup(operation.close)
+ result = operation.process_element(pa.record_batch(
+ [pa.array(["alice", None, "Bob"])], names=["name"]))
+ self.assertIsInstance(result, pa.RecordBatch)
+ self.assertEqual(result.column(0).to_pylist(), ["ALICE", None, "BOB"])
+
+
class UserDefinedFunctionTests(object):
def test_scalar_function(self):
diff --git a/flink-python/pyflink/table/types.py
b/flink-python/pyflink/table/types.py
index 16d92c3c1ff..ac8397e007d 100644
--- a/flink-python/pyflink/table/types.py
+++ b/flink-python/pyflink/table/types.py
@@ -2213,12 +2213,16 @@ def _create_type_verifier(data_type: DataType, name:
str = None):
return verify
-def create_arrow_schema(field_names: List[str], field_types: List[DataType]):
+def create_arrow_schema(field_names: List[str], field_types: List[DataType],
*, allow_nested=False):
"""
- Create an Arrow schema with the specified filed names and types.
+ Create an Arrow schema with the specified field names and types.
+
+ By default, retain the nested-type restrictions of the pandas conversion
path.
+ Arrow-native transport can set ``allow_nested`` to include nested rows and
timestamps.
"""
import pyarrow as pa
- fields = [pa.field(field_name, to_arrow_type(field_type),
field_type._nullable)
+ fields = [pa.field(field_name, to_arrow_type(field_type,
allow_nested=allow_nested),
+ field_type._nullable)
for field_name, field_type in zip(field_names, field_types)]
return pa.schema(fields)
@@ -2296,7 +2300,7 @@ def from_arrow_type(arrow_type, nullable: bool = True) ->
DataType:
raise TypeError("Unsupported data type to convert from Arrow type: " +
str(arrow_type))
-def to_arrow_type(data_type: DataType):
+def to_arrow_type(data_type: DataType, *, allow_nested=False):
"""
Converts the specified Flink data type to pyarrow data type.
"""
@@ -2344,18 +2348,26 @@ def to_arrow_type(data_type: DataType):
else:
return pa.timestamp('ns')
elif isinstance(data_type, MapType):
- return pa.map_(to_arrow_type(data_type.key_type),
to_arrow_type(data_type.value_type))
+ key_type = to_arrow_type(data_type.key_type, allow_nested=allow_nested)
+ value_type = to_arrow_type(data_type.value_type,
allow_nested=allow_nested)
+ # PyArrow 5 only accepts data types and always makes map values
nullable.
+ if hasattr(pa.MapType, 'item_field'):
+ value_type = pa.field("value", value_type,
nullable=data_type.value_type._nullable)
+ return pa.map_(key_type, value_type)
elif isinstance(data_type, ArrayType):
- if type(data_type.element_type) in [LocalZonedTimestampType, RowType]:
+ if not allow_nested and type(data_type.element_type) in
[LocalZonedTimestampType, RowType]:
raise ValueError("%s is not supported to be used as the element
type of ArrayType." %
data_type.element_type)
- return pa.list_(to_arrow_type(data_type.element_type))
+ return pa.list_(pa.field(
+ "item", to_arrow_type(data_type.element_type,
allow_nested=allow_nested),
+ nullable=data_type.element_type._nullable))
elif isinstance(data_type, RowType):
for field in data_type:
- if type(field.data_type) in [LocalZonedTimestampType, RowType]:
+ if not allow_nested and type(field.data_type) in
[LocalZonedTimestampType, RowType]:
raise TypeError("%s is not supported to be used as the field
type of RowType" %
field.data_type)
- fields = [pa.field(field.name, to_arrow_type(field.data_type),
field.data_type._nullable)
+ fields = [pa.field(field.name, to_arrow_type(field.data_type,
allow_nested=allow_nested),
+ field.data_type._nullable)
for field in data_type]
return pa.struct(fields)
elif isinstance(data_type, NullType):
diff --git a/flink-python/pyflink/table/udf.py
b/flink-python/pyflink/table/udf.py
index 92bd180d189..733b8a79178 100644
--- a/flink-python/pyflink/table/udf.py
+++ b/flink-python/pyflink/table/udf.py
@@ -507,6 +507,8 @@ class UserDefinedFunctionWrapper(object):
return JPythonFunctionKind.GENERAL
elif self._func_type == "pandas":
return JPythonFunctionKind.PANDAS
+ elif self._func_type == "arrow":
+ return JPythonFunctionKind.ARROW
else:
raise TypeError("Unsupported func_type: %s." %
self._func_type)
@@ -760,10 +762,19 @@ def _get_python_env():
def _create_udf(f, input_types, result_type, func_type, deterministic, name):
+ if func_type in ('pandas', 'arrow'):
+ target = f
+ while isinstance(target, functools.partial):
+ target = target.func
+ if isinstance(target, ScalarFunction):
+ target = target.eval
+ if inspect.iscoroutinefunction(target) or inspect.iscoroutinefunction(
+ getattr(target, '__call__', None)):
+ raise ValueError(f"Async scalar functions do not support
{func_type} func_type.")
if isinstance(f, AsyncScalarFunction) or inspect.iscoroutinefunction(f):
- if func_type == 'pandas':
+ if func_type in ('pandas', 'arrow'):
raise ValueError(
- "Async scalar functions do not support pandas func_type. "
+ f"Async scalar functions do not support {func_type} func_type.
"
"Please use func_type='general' (default) for async
functions.")
return UserDefinedAsyncScalarFunctionWrapper(
f, input_types, result_type, func_type, deterministic, name)
@@ -838,16 +849,15 @@ def udf(f: Union[Callable, ScalarFunction,
AsyncScalarFunction, Type] = None,
this function is guaranteed to always return the
same result given the
same parameters. (default True)
:param name: the function name.
- :param func_type: the type of the python function, available value:
general, pandas,
+ :param func_type: the type of the python function, available value:
general, pandas, arrow,
(default: general)
:return: UserDefinedScalarFunctionWrapper,
UserDefinedAsyncScalarFunctionWrapper, or function.
.. versionadded:: 1.10.0
"""
- if func_type not in ('general', 'pandas'):
- raise ValueError("The func_type must be one of 'general, pandas', got
%s."
- % func_type)
+ if func_type not in ('general', 'pandas', 'arrow'):
+ raise ValueError(f"The func_type must be one of 'general, pandas,
arrow', got {func_type}.")
# decorator
if f is None:
diff --git a/flink-python/pyflink/table/utils.py
b/flink-python/pyflink/table/utils.py
index bc4444e66eb..be1e71f64b2 100644
--- a/flink-python/pyflink/table/utils.py
+++ b/flink-python/pyflink/table/utils.py
@@ -16,6 +16,7 @@
# limitations under the License.
################################################################################
import ast
+from functools import cache
from pyflink.common.types import RowKind
@@ -28,6 +29,137 @@ import datetime
import pickle
+@Internal()
+def validate_arrow_batch(batch, schema, field_types):
+ import pyarrow as pa
+
+ if not isinstance(batch, pa.RecordBatch):
+ raise TypeError("Arrow transport expects a pyarrow.RecordBatch.")
+ if batch.num_columns != len(schema):
+ raise ValueError(f"Arrow result has {batch.num_columns} columns,
expected {len(schema)}.")
+ # PyArrow 5 cannot express map-value nullability, so retain the logical
constraint there.
+ legacy_map_fields = not hasattr(pa.MapType, 'item_field')
+ columns = []
+ for index, (column, field) in enumerate(zip(batch.columns, schema)):
+ _validate_array(column, field, field.name,
+ data_type=field_types[index] if legacy_map_fields else
None)
+ columns.append(_apply_arrow_type(column, field.type))
+ return pa.RecordBatch.from_arrays(columns, schema=schema)
+
+
+def _apply_arrow_type(column, expected_type):
+ import pyarrow as pa
+
+ if column.type == expected_type:
+ return column
+ # Old Arrow casts reject valid nullability changes, and views inspect
unsliced children.
+ # After validation, rebuild only container metadata while keeping the
payload buffers.
+ if pa.types.is_struct(expected_type):
+ return pa.StructArray.from_arrays(
+ [_apply_arrow_type(column.field(index), field.type)
+ for index, field in enumerate(expected_type)],
+ fields=list(expected_type), mask=column.is_null() if
column.null_count else None)
+ if pa.types.is_list(expected_type):
+ children = [_apply_arrow_type(column.values, expected_type.value_type)]
+ else:
+ value_field = (expected_type.item_field if hasattr(expected_type,
'item_field')
+ else pa.field("value", expected_type.item_type))
+ children = [pa.StructArray.from_arrays(
+ [_apply_arrow_type(column.keys, expected_type.key_type),
+ _apply_arrow_type(column.items, expected_type.item_type)],
+ fields=[pa.field("key", expected_type.key_type, nullable=False),
value_field])]
+ return pa.Array.from_buffers(
+ expected_type, len(column),
column.buffers()[:expected_type.num_buffers],
+ null_count=column.null_count, offset=column.offset, children=children)
+
+
+def _validate_array(column, field, path, parent_validity=None, data_type=None):
+ import pyarrow as pa
+ import pyarrow.compute as pc
+ expected_type = field.type
+
+ def wrong_type():
+ raise TypeError(
+ f"Arrow result field '{path}' has type {column.type}, expected
{expected_type}.")
+
+ if not field.nullable and column.null_count:
+ validity = parent_validity() if parent_validity is not None else None
+ if validity is None or pc.any(pc.and_(validity,
column.is_null())).as_py():
+ raise ValueError(f"Arrow result field '{path}' is not nullable.")
+ if pa.types.is_struct(expected_type):
+ if (not pa.types.is_struct(column.type)
+ or column.type.num_fields != expected_type.num_fields):
+ wrong_type()
+ validity = cache(lambda: _get_validity(column, parent_validity))
+ for index, child_field in enumerate(expected_type):
+ if column.type[index].name != child_field.name:
+ wrong_type()
+ _validate_array(column.field(index), child_field,
+ f"{path}.{child_field.name}", validity,
+ data_type.fields[index].data_type if data_type is
not None else None)
+ elif pa.types.is_list(expected_type):
+ if not pa.types.is_list(column.type):
+ wrong_type()
+ start, end = column.offsets[0].as_py(), column.offsets[-1].as_py()
+ _validate_array(column.values.slice(start, end - start),
+ expected_type.value_field, f"{path}[]",
+ cache(lambda: _get_child_validity(column,
parent_validity)),
+ data_type.element_type if data_type is not None else
None)
+ elif pa.types.is_map(expected_type):
+ if not pa.types.is_map(column.type):
+ wrong_type()
+ validity = cache(lambda: _get_child_validity(column, parent_validity))
+ offsets = _get_offsets(column)
+ start, end = offsets[0].as_py(), offsets[-1].as_py()
+ key_field = (expected_type.key_field if data_type is None
+ else pa.field("key", expected_type.key_type,
nullable=False))
+ value_field = (expected_type.item_field if data_type is None
+ else pa.field("value", expected_type.item_type,
+ nullable=data_type.value_type._nullable))
+ _validate_array(column.keys.slice(start, end - start), key_field,
+ f"{path}.key", validity,
+ data_type.key_type if data_type is not None else None)
+ _validate_array(column.items.slice(start, end - start), value_field,
+ f"{path}.value", validity,
+ data_type.value_type if data_type is not None else
None)
+ elif column.type != expected_type:
+ wrong_type()
+
+
+def _get_validity(column, parent_validity):
+ import pyarrow.compute as pc
+
+ # Resolve ancestor visibility only when a NOT NULL descendant contains
physical nulls.
+ parent_validity = parent_validity() if parent_validity is not None else
None
+ if column.null_count:
+ validity = column.is_valid()
+ return validity if parent_validity is None else
pc.and_(parent_validity, validity)
+ return parent_validity
+
+
+def _get_child_validity(column, parent_validity):
+ import numpy as np
+ import pyarrow as pa
+
+ validity = _get_validity(column, parent_validity)
+ if validity is None:
+ return None
+ # Expand visibility directly instead of allocating an integer parent index
for every child.
+ offsets = _get_offsets(column).to_numpy(zero_copy_only=True)
+ visible = validity.to_numpy(zero_copy_only=False)
+ return pa.array(np.repeat(visible, np.diff(offsets)), type=pa.bool_())
+
+
+def _get_offsets(column):
+ import pyarrow as pa
+
+ if hasattr(column, 'offsets'):
+ return column.offsets
+ # PyArrow 5 does not expose MapArray.offsets, but uses the same int32
offsets as lists.
+ return pa.Array.from_buffers(pa.int32(), len(column) + 1,
+ [None, column.buffers()[1]],
offset=column.offset)
+
+
@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 a4cee4f9ebc..6da72d75bfc 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
@@ -109,11 +109,24 @@ public enum ProtoUtils {
RowType rowType,
FlinkFnApi.CoderInfoDescriptor.Mode mode,
boolean separatedWithEndMessage) {
+ return createArrowTypeCoderInfoDescriptorProto(
+ rowType,
+ mode,
+ separatedWithEndMessage,
+ FlinkFnApi.CoderInfoDescriptor.ArrowType.BatchFormat.PANDAS);
+ }
+
+ public static FlinkFnApi.CoderInfoDescriptor
createArrowTypeCoderInfoDescriptorProto(
+ RowType rowType,
+ FlinkFnApi.CoderInfoDescriptor.Mode mode,
+ boolean separatedWithEndMessage,
+ FlinkFnApi.CoderInfoDescriptor.ArrowType.BatchFormat batchFormat) {
return createCoderInfoDescriptorProto(
null,
null,
FlinkFnApi.CoderInfoDescriptor.ArrowType.newBuilder()
.setSchema(toProtoType(rowType).getRowSchema())
+ .setBatchFormat(batchFormat)
.build(),
null,
null,
@@ -229,6 +242,9 @@ public enum ProtoUtils {
builder.setIsPandasUdf(
pythonFunctionInfo.getPythonFunction().getPythonFunctionKind()
== PythonFunctionKind.PANDAS);
+ builder.setIsArrowUdf(
+ pythonFunctionInfo.getPythonFunction().getPythonFunctionKind()
+ == PythonFunctionKind.ARROW);
return builder.build();
}
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 ef5bd046390..bee19392571 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
@@ -25,6 +25,7 @@ import org.apache.flink.fnexecution.v1.FlinkFnApi;
import org.apache.flink.table.data.RowData;
import org.apache.flink.table.functions.ScalarFunction;
import org.apache.flink.table.functions.python.PythonFunctionInfo;
+import org.apache.flink.table.functions.python.PythonFunctionKind;
import org.apache.flink.table.runtime.arrow.serializers.ArrowSerializer;
import org.apache.flink.table.runtime.generated.GeneratedProjection;
import
org.apache.flink.table.runtime.operators.python.scalar.AbstractPythonScalarFunctionOperator;
@@ -49,6 +50,8 @@ public class ArrowPythonScalarFunctionOperator extends
AbstractPythonScalarFunct
private transient ArrowSerializer arrowSerializer;
+ private final FlinkFnApi.CoderInfoDescriptor.ArrowType.BatchFormat
batchFormat;
+
public ArrowPythonScalarFunctionOperator(
Configuration config,
PythonFunctionInfo[] scalarFunctions,
@@ -86,6 +89,11 @@ public class ArrowPythonScalarFunctionOperator extends
AbstractPythonScalarFunct
udfOutputType,
udfInputGeneratedProjection,
forwardedFieldGeneratedProjection);
+ batchFormat =
+ scalarFunctions[0].getPythonFunction().getPythonFunctionKind()
+ == PythonFunctionKind.ARROW
+ ?
FlinkFnApi.CoderInfoDescriptor.ArrowType.BatchFormat.ARROW
+ :
FlinkFnApi.CoderInfoDescriptor.ArrowType.BatchFormat.PANDAS;
}
@Override
@@ -100,14 +108,14 @@ public class ArrowPythonScalarFunctionOperator extends
AbstractPythonScalarFunct
@Override
public FlinkFnApi.CoderInfoDescriptor
createInputCoderInfoDescriptor(RowType runnerInputType) {
return createArrowTypeCoderInfoDescriptorProto(
- runnerInputType, FlinkFnApi.CoderInfoDescriptor.Mode.MULTIPLE,
false);
+ runnerInputType, FlinkFnApi.CoderInfoDescriptor.Mode.MULTIPLE,
false, batchFormat);
}
@Override
public FlinkFnApi.CoderInfoDescriptor createOutputCoderInfoDescriptor(
RowType runnerOutputType) {
return createArrowTypeCoderInfoDescriptorProto(
- runnerOutputType, FlinkFnApi.CoderInfoDescriptor.Mode.SINGLE,
false);
+ runnerOutputType, FlinkFnApi.CoderInfoDescriptor.Mode.SINGLE,
false, batchFormat);
}
@Override
diff --git
a/flink-python/src/test/java/org/apache/flink/streaming/api/utils/ProtoUtilsTest.java
b/flink-python/src/test/java/org/apache/flink/streaming/api/utils/ProtoUtilsTest.java
index 78a10489b31..1331226b200 100644
---
a/flink-python/src/test/java/org/apache/flink/streaming/api/utils/ProtoUtilsTest.java
+++
b/flink-python/src/test/java/org/apache/flink/streaming/api/utils/ProtoUtilsTest.java
@@ -21,8 +21,18 @@ package org.apache.flink.streaming.api.utils;
import org.apache.flink.api.common.state.StateTtlConfig;
import org.apache.flink.fnexecution.v1.FlinkFnApi;
import org.apache.flink.python.util.ProtoUtils;
+import org.apache.flink.table.api.DataTypes;
+import org.apache.flink.table.functions.python.InputRef;
+import org.apache.flink.table.functions.python.PythonEnv;
+import org.apache.flink.table.functions.python.PythonFunctionInfo;
+import org.apache.flink.table.functions.python.PythonFunctionInput;
+import org.apache.flink.table.functions.python.PythonFunctionKind;
+import org.apache.flink.table.functions.python.PythonScalarFunction;
+import org.apache.flink.table.types.logical.RowType;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
import java.time.Duration;
@@ -33,6 +43,47 @@ import static org.assertj.core.api.Assertions.assertThat;
* protobuf objects.
*/
class ProtoUtilsTest {
+ @Test
+ void testArrowCoderBatchFormat() {
+ final RowType rowType = RowType.of(DataTypes.INT().getLogicalType());
+
assertThat(FlinkFnApi.CoderInfoDescriptor.ArrowType.getDefaultInstance().getBatchFormat())
+
.isEqualTo(FlinkFnApi.CoderInfoDescriptor.ArrowType.BatchFormat.PANDAS);
+ assertThat(
+ ProtoUtils.createArrowTypeCoderInfoDescriptorProto(
+ rowType,
FlinkFnApi.CoderInfoDescriptor.Mode.SINGLE, false)
+ .getArrowType()
+ .getBatchFormat())
+
.isEqualTo(FlinkFnApi.CoderInfoDescriptor.ArrowType.BatchFormat.PANDAS);
+ assertThat(
+ ProtoUtils.createArrowTypeCoderInfoDescriptorProto(
+ rowType,
+
FlinkFnApi.CoderInfoDescriptor.Mode.SINGLE,
+ false,
+
FlinkFnApi.CoderInfoDescriptor.ArrowType.BatchFormat.ARROW)
+ .getArrowType()
+ .getBatchFormat())
+
.isEqualTo(FlinkFnApi.CoderInfoDescriptor.ArrowType.BatchFormat.ARROW);
+ }
+
+ @ParameterizedTest
+ @EnumSource(PythonFunctionKind.class)
+ void testScalarFunctionKind(PythonFunctionKind kind) {
+ final PythonScalarFunction function =
+ new PythonScalarFunction(
+ "identity",
+ new byte[0],
+ kind,
+ true,
+ false,
+ new PythonEnv(PythonEnv.ExecType.PROCESS));
+ final FlinkFnApi.UserDefinedFunction proto =
+ ProtoUtils.createUserDefinedFunctionProto(
+ new PythonFunctionInfo(
+ function, new PythonFunctionInput[] {new
InputRef(0)}));
+ assertThat(proto.getIsArrowUdf()).isEqualTo(kind ==
PythonFunctionKind.ARROW);
+ assertThat(proto.getIsPandasUdf()).isEqualTo(kind ==
PythonFunctionKind.PANDAS);
+ }
+
@Test
void testParseStateTtlConfigFromProto() {
FlinkFnApi.StateDescriptor.StateTTLConfig.CleanupStrategies
cleanupStrategiesProto =
diff --git
a/flink-table/flink-table-common/src/main/java/org/apache/flink/table/functions/python/PythonFunctionKind.java
b/flink-table/flink-table-common/src/main/java/org/apache/flink/table/functions/python/PythonFunctionKind.java
index cd7a27ef8a8..e8afcdfaaa1 100644
---
a/flink-table/flink-table-common/src/main/java/org/apache/flink/table/functions/python/PythonFunctionKind.java
+++
b/flink-table/flink-table-common/src/main/java/org/apache/flink/table/functions/python/PythonFunctionKind.java
@@ -25,5 +25,7 @@ import org.apache.flink.annotation.Internal;
public enum PythonFunctionKind {
GENERAL,
- PANDAS
+ PANDAS,
+
+ ARROW
}
diff --git
a/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/plan/nodes/exec/common/CommonExecPythonCalc.java
b/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/plan/nodes/exec/common/CommonExecPythonCalc.java
index b0fcd6e463a..8a15da8d3c5 100644
---
a/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/plan/nodes/exec/common/CommonExecPythonCalc.java
+++
b/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/plan/nodes/exec/common/CommonExecPythonCalc.java
@@ -28,8 +28,11 @@ import
org.apache.flink.streaming.api.transformations.OneInputTransformation;
import org.apache.flink.table.api.TableException;
import org.apache.flink.table.connector.Projection;
import org.apache.flink.table.data.RowData;
+import org.apache.flink.table.functions.python.InputRef;
import org.apache.flink.table.functions.python.PythonFunctionInfo;
+import org.apache.flink.table.functions.python.PythonFunctionInput;
import org.apache.flink.table.functions.python.PythonFunctionKind;
+import org.apache.flink.table.functions.python.ResultRef;
import org.apache.flink.table.planner.calcite.FlinkTypeFactory;
import org.apache.flink.table.planner.codegen.CodeGeneratorContext;
import org.apache.flink.table.planner.codegen.ProjectionCodeGenerator;
@@ -156,7 +159,10 @@ public abstract class CommonExecPythonCalc extends
ExecNodeBase<RowData>
extractPythonScalarFunctionInfos(cseResult, classLoader);
int[] pythonUdfInputOffsets = extractResult.f0;
PythonFunctionInfo[] pythonFunctionInfos = extractResult.f1;
-
+ boolean[] hasColumn = new boolean[pythonFunctionInfos.length];
+ for (int i = 0; i < pythonFunctionInfos.length; i++) {
+ hasColumn[i] = validateArrowInputs(pythonFunctionInfos[i],
hasColumn);
+ }
LogicalType[] inputLogicalTypes =
((InternalTypeInfo<RowData>)
inputTransform.getOutputType()).toRowFieldTypes();
InternalTypeInfo<RowData> pythonOperatorInputTypeInfo =
@@ -192,7 +198,9 @@ public abstract class CommonExecPythonCalc extends
ExecNodeBase<RowData>
.anyMatch(
x ->
PythonUtil.containsPythonCall(
- x,
PythonFunctionKind.PANDAS)));
+ x,
PythonFunctionKind.PANDAS)
+ ||
PythonUtil.containsPythonCall(
+ x,
PythonFunctionKind.ARROW)));
return ExecNodeUtil.createOneInputTransformation(
inputTransform,
@@ -203,6 +211,26 @@ public abstract class CommonExecPythonCalc extends
ExecNodeBase<RowData>
false);
}
+ private static boolean validateArrowInputs(
+ PythonFunctionInfo function, boolean[] resultHasColumn) {
+ boolean hasColumn = false;
+ for (PythonFunctionInput input : function.getInputs()) {
+ if (input instanceof PythonFunctionInfo) {
+ hasColumn |= validateArrowInputs((PythonFunctionInfo) input,
resultHasColumn);
+ } else if (input instanceof InputRef) {
+ hasColumn = true;
+ } else if (input instanceof ResultRef) {
+ hasColumn |= resultHasColumn[((ResultRef) input).getIndex()];
+ }
+ }
+ if (function.getPythonFunction().getPythonFunctionKind() ==
PythonFunctionKind.ARROW
+ && !hasColumn) {
+ throw new TableException(
+ "Arrow scalar UDFs require at least one column-valued
argument.");
+ }
+ return hasColumn;
+ }
+
private Tuple2<int[], PythonFunctionInfo[]>
extractPythonScalarFunctionInfos(
PythonCallCseResult cseResult, ClassLoader classLoader) {
List<RexCall> rexCalls = cseResult.getDeduplicatedCalls();
diff --git
a/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/plan/rules/logical/PythonCalcSplitFunctionKindRule.java
b/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/plan/rules/logical/PythonCalcSplitFunctionKindRule.java
new file mode 100644
index 00000000000..0e42290ed70
--- /dev/null
+++
b/flink-table/flink-table-planner/src/main/java/org/apache/flink/table/planner/plan/rules/logical/PythonCalcSplitFunctionKindRule.java
@@ -0,0 +1,75 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+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.PythonUtil;
+
+import org.apache.calcite.plan.RelOptRuleCall;
+import org.apache.calcite.rex.RexNode;
+import org.apache.calcite.rex.RexProgram;
+
+import java.util.Arrays;
+
+import scala.Option;
+
+/** Separates scalar Python functions whose user-facing batch representations
differ. */
+public class PythonCalcSplitFunctionKindRule extends
RemoteCalcSplitProjectionRuleBase<Void> {
+
+ public PythonCalcSplitFunctionKindRule(RemoteCallFinder callFinder) {
+ super("PythonCalcSplitFunctionKindRule", callFinder);
+ }
+
+ @Override
+ public boolean matches(RelOptRuleCall call) {
+ final FlinkLogicalCalc calc = call.rel(0);
+ final RexProgram program = calc.getProgram();
+ return Arrays.stream(PythonFunctionKind.values())
+ .filter(
+ kind ->
+ program.getProjectList().stream()
+ .map(program::expandLocalRef)
+ .anyMatch(
+ node ->
+
PythonUtil.containsPythonCall(
+ node,
kind)))
+ .count()
+ > 1;
+ }
+
+ @Override
+ public boolean needConvert(RexProgram program, RexNode node, Option<Void>
matchState) {
+ // Keep one top-level kind above the split and extract calls of every
other kind.
+ final PythonFunctionKind topLevelKind =
+ Arrays.stream(PythonFunctionKind.values())
+ .filter(
+ kind ->
+ program.getProjectList().stream()
+ .map(program::expandLocalRef)
+ .anyMatch(
+ project ->
+
PythonUtil.isPythonCall(
+
project, kind)))
+ .findFirst()
+ .orElseThrow(
+ () -> new IllegalStateException("Missing
top-level Python call."));
+ return PythonUtil.isPythonCall(node) && !PythonUtil.isPythonCall(node,
topLevelKind);
+ }
+}
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 57d2f2cea68..c93c643301e 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
@@ -32,6 +32,7 @@ import org.apache.calcite.rex.RexNode;
import org.apache.calcite.rex.RexProgram;
import org.apache.calcite.rex.RexProgramBuilder;
+import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
@@ -87,8 +88,11 @@ public class PythonMapMergeRule extends RelOptRule {
}
// only Python functions with same Python function kind can be merged
together.
- if (PythonUtil.isPythonCall(topProjects.get(0),
PythonFunctionKind.GENERAL)
- ^ PythonUtil.isPythonCall(bottomProjects.get(0),
PythonFunctionKind.GENERAL)) {
+ if (Arrays.stream(PythonFunctionKind.values())
+ .noneMatch(
+ kind ->
+ PythonUtil.isPythonCall(topProjects.get(0),
kind)
+ &&
PythonUtil.isPythonCall(bottomProjects.get(0), kind))) {
return false;
}
diff --git
a/flink-table/flink-table-planner/src/main/scala/org/apache/flink/table/planner/plan/rules/logical/PythonCalcSplitRule.scala
b/flink-table/flink-table-planner/src/main/scala/org/apache/flink/table/planner/plan/rules/logical/PythonCalcSplitRule.scala
index 642ca5f5691..7983ea96a1a 100644
---
a/flink-table/flink-table-planner/src/main/scala/org/apache/flink/table/planner/plan/rules/logical/PythonCalcSplitRule.scala
+++
b/flink-table/flink-table-planner/src/main/scala/org/apache/flink/table/planner/plan/rules/logical/PythonCalcSplitRule.scala
@@ -17,53 +17,10 @@
*/
package org.apache.flink.table.planner.plan.rules.logical
-import org.apache.flink.table.functions.ScalarFunction
-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.{InputRefVisitor, PythonUtil,
RexDefaultVisitor}
-import
org.apache.flink.table.planner.plan.utils.PythonUtil.{containsNonPythonCall,
containsPythonCall, isNonPythonCall, isPythonCall}
+import org.apache.flink.table.planner.plan.utils.PythonUtil
-import org.apache.calcite.plan.{RelOptRule, RelOptRuleCall}
-import org.apache.calcite.plan.RelOptRule.{any, operand}
-import org.apache.calcite.rex.{RexBuilder, RexCall, RexCorrelVariable,
RexFieldAccess, RexInputRef, RexLocalRef, RexNode, RexProgram}
-import org.apache.calcite.sql.validate.SqlValidatorUtil
-
-import java.util.function.Function
-
-import scala.collection.JavaConversions._
-import scala.collection.JavaConverters._
-import scala.collection.mutable
-
-/**
- * Rule that splits [[FlinkLogicalCalc]]s which contain both general Python
functions and pandas
- * Python functions in the projection into multiple [[FlinkLogicalCalc]]s.
After this rule is
- * applied, it will only contain general Python functions or pandas Python
functions in the
- * projection of each [[FlinkLogicalCalc]].
- */
-class PythonCalcSplitPandasInProjectionRule(callFinder: RemoteCallFinder)
- extends
RemoteCalcSplitProjectionRuleBase("PythonCalcSplitPandasInProjectionRule",
callFinder) {
-
- override def matches(call: RelOptRuleCall): Boolean = {
- val calc: FlinkLogicalCalc = call.rel(0).asInstanceOf[FlinkLogicalCalc]
- val projects =
calc.getProgram.getProjectList.map(calc.getProgram.expandLocalRef)
-
- // matches if it contains both general Python functions and
- // pandas Python functions in the projection
- projects.exists(containsPythonCall(_, PythonFunctionKind.GENERAL)) &&
- projects.exists(containsPythonCall(_, PythonFunctionKind.PANDAS))
- }
-
- override def needConvert(
- program: RexProgram,
- node: RexNode,
- matchState: Option[Nothing]): Boolean = {
- program.getProjectList
- .map(program.expandLocalRef)
- .exists(isPythonCall(_, PythonFunctionKind.GENERAL)) == isPythonCall(
- node,
- PythonFunctionKind.PANDAS)
- }
-}
+import org.apache.calcite.plan.RelOptRule
+import org.apache.calcite.rex.RexNode
class PythonRemoteCallFinder extends RemoteCallFinder {
override def containsRemoteCall(node: RexNode): Boolean = {
@@ -105,7 +62,7 @@ object PythonCalcSplitRule {
val CONDITION_PROJECTION_CSE: RelOptRule =
RemoteCalcConditionProjectionCseRule.Config.DEFAULT.withRemoteCallFinder(callFinder).toRule()
val SPLIT_PROJECT: RelOptRule = new RemoteCalcSplitProjectionRule(callFinder)
- val SPLIT_PANDAS_IN_PROJECT: RelOptRule = new
PythonCalcSplitPandasInProjectionRule(callFinder)
+ val SPLIT_PANDAS_IN_PROJECT: RelOptRule = new
PythonCalcSplitFunctionKindRule(callFinder)
val SPLIT_PROJECTION_REX_FIELD: RelOptRule = new
RemoteCalcSplitProjectionRexFieldRule(callFinder)
val SPLIT_CONDITION_REX_FIELD: RelOptRule = new
RemoteCalcSplitConditionRexFieldRule(callFinder)
val EXPAND_PROJECT: RelOptRule = new RemoteCalcExpandProjectRule(callFinder)
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
new file mode 100644
index 00000000000..09cc9d10dd8
--- /dev/null
+++
b/flink-table/flink-table-planner/src/test/java/org/apache/flink/table/planner/plan/rules/logical/PythonCalcSplitFunctionKindRuleTest.java
@@ -0,0 +1,112 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+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.PythonEnv;
+import org.apache.flink.table.functions.python.PythonFunctionKind;
+import org.apache.flink.table.functions.python.PythonScalarFunction;
+import org.apache.flink.table.planner.utils.JavaScalaConversionUtil;
+import org.apache.flink.table.planner.utils.JavaTableTestUtil;
+import org.apache.flink.table.planner.utils.TableTestBase;
+import org.apache.flink.table.types.DataType;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+
+import static org.apache.flink.table.api.Expressions.$;
+import static org.apache.flink.table.api.Expressions.call;
+
+/** Plans compositions of scalar UDFs with different Python argument
representations. */
+class PythonCalcSplitFunctionKindRuleTest extends TableTestBase {
+
+ @Test
+ void testStreamingComposition() {
+ verifyComposition(javaStreamTestUtil());
+ }
+
+ @Test
+ void testBatchComposition() {
+ verifyComposition(javaBatchTestUtil());
+ }
+
+ @Test
+ void testStreamingMapComposition() {
+ verifyMapComposition(javaStreamTestUtil());
+ }
+
+ @Test
+ void testBatchMapComposition() {
+ verifyMapComposition(javaBatchTestUtil());
+ }
+
+ private void verifyComposition(JavaTableTestUtil util) {
+ util.addTableSource("T", Schema.newBuilder().column("a",
DataTypes.INT()).build());
+ registerFunctions(util, DataTypes.INT(), false);
+ util.verifyExecPlan(
+ "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");
+ }
+
+ private void verifyMapComposition(JavaTableTestUtil util) {
+ util.addTableSource("T", Schema.newBuilder().column("a",
DataTypes.INT()).build());
+ registerFunctions(util, DataTypes.ROW(DataTypes.FIELD("a",
DataTypes.INT())), true);
+ final Table result =
+ util.tableEnv()
+ .from("T")
+ .map(call("general_udf", $("a")))
+ .map(call("arrow_udf", $("a")))
+ .map(call("arrow_udf", $("a")))
+ .map(call("pandas_udf", $("a")))
+ .map(call("pandas_udf", $("a")))
+ .map(call("arrow_udf", $("a")))
+ .map(call("general_udf", $("a")));
+ util.verifyRelPlanExpected(
+ result,
+ JavaScalaConversionUtil.toScala(
+ Arrays.asList(
+ "PythonCalc(select=[arrow_udf(arrow_udf(a)) AS
f0])",
+ "PythonCalc(select=[pandas_udf(pandas_udf(a))
AS f0])",
+ "PythonCalc(select=[arrow_udf(a) AS f0])",
+ "PythonCalc(select=[general_udf(a) AS f0])")));
+ }
+
+ private void registerFunctions(
+ JavaTableTestUtil util, DataType resultType, boolean
takesRowAsInput) {
+ for (PythonFunctionKind kind : PythonFunctionKind.values()) {
+ util.tableEnv()
+ .createTemporarySystemFunction(
+ kind.name().toLowerCase() + "_udf",
+ new PythonScalarFunction(
+ kind.name(),
+ new byte[0],
+ new DataType[] {DataTypes.INT()},
+ resultType,
+ kind,
+ true,
+ takesRowAsInput,
+ new
PythonEnv(PythonEnv.ExecType.PROCESS)));
+ }
+ }
+}
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
new file mode 100644
index 00000000000..021138e012a
--- /dev/null
+++
b/flink-table/flink-table-planner/src/test/resources/org/apache/flink/table/planner/plan/rules/logical/PythonCalcSplitFunctionKindRuleTest.xml
@@ -0,0 +1,67 @@
+<?xml version="1.0" ?>
+<!--
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to you under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<Root>
+ <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]]>
+ </Resource>
+ <Resource name="ast">
+ <![CDATA[
+LogicalProject(EXPR$0=[arrow_udf($0)], EXPR$1=[pandas_udf($0)],
EXPR$2=[general_udf($0)], EXPR$3=[arrow_udf(arrow_udf($0))],
EXPR$4=[arrow_udf(pandas_udf($0))], EXPR$5=[pandas_udf(arrow_udf($0))],
EXPR$6=[arrow_udf(general_udf($0))], EXPR$7=[general_udf(arrow_udf($0))])
++- LogicalTableScan(table=[[default_catalog, default_database, T]])
+]]>
+ </Resource>
+ <Resource name="optimized exec plan">
+ <![CDATA[
+Calc(select=[f0 AS EXPR$0, f1 AS EXPR$1, f00 AS EXPR$2, f2 AS EXPR$3, f3 AS
EXPR$4, f4 AS EXPR$5, f5 AS EXPR$6, f10 AS EXPR$7])
++- PythonCalc(select=[f0, f1, f2, f3, f4, f5, general_udf(a) AS f00,
general_udf(f6) AS f10])
+ +- Calc(select=[a, f0, f00 AS f1, f1 AS f2, f2 AS f3, f10 AS f4, f4 AS f5,
f5 AS f6])
+ +- PythonCalc(select=[a, f0, f1, f2, f4, f5, pandas_udf(a) AS f00,
pandas_udf(f3) AS f10])
+ +- Calc(select=[f0 AS a, f1 AS f0, f2 AS f1, f3 AS f2, f1 AS f3, f4,
f1 AS f5])
+ +- PythonCalc(select=[a AS f0, arrow_udf(a) AS f1,
arrow_udf(arrow_udf(a)) AS f2, arrow_udf(f0) AS f3, arrow_udf(f1) AS f4])
+ +- 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="testStreamingComposition">
+ <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]]>
+ </Resource>
+ <Resource name="ast">
+ <![CDATA[
+LogicalProject(EXPR$0=[arrow_udf($0)], EXPR$1=[pandas_udf($0)],
EXPR$2=[general_udf($0)], EXPR$3=[arrow_udf(arrow_udf($0))],
EXPR$4=[arrow_udf(pandas_udf($0))], EXPR$5=[pandas_udf(arrow_udf($0))],
EXPR$6=[arrow_udf(general_udf($0))], EXPR$7=[general_udf(arrow_udf($0))])
++- LogicalTableScan(table=[[default_catalog, default_database, T]])
+]]>
+ </Resource>
+ <Resource name="optimized exec plan">
+ <![CDATA[
+Calc(select=[f0 AS EXPR$0, f1 AS EXPR$1, f00 AS EXPR$2, f2 AS EXPR$3, f3 AS
EXPR$4, f4 AS EXPR$5, f5 AS EXPR$6, f10 AS EXPR$7])
++- PythonCalc(select=[f0, f1, f2, f3, f4, f5, general_udf(a) AS f00,
general_udf(f6) AS f10])
+ +- Calc(select=[a, f0, f00 AS f1, f1 AS f2, f2 AS f3, f10 AS f4, f4 AS f5,
f5 AS f6])
+ +- PythonCalc(select=[a, f0, f1, f2, f4, f5, pandas_udf(a) AS f00,
pandas_udf(f3) AS f10])
+ +- Calc(select=[f0 AS a, f1 AS f0, f2 AS f1, f3 AS f2, f1 AS f3, f4,
f1 AS f5])
+ +- PythonCalc(select=[a AS f0, arrow_udf(a) AS f1,
arrow_udf(arrow_udf(a)) AS f2, arrow_udf(f0) AS f3, arrow_udf(f1) AS f4])
+ +- 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>
+</Root>