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 a9d5b293f6a [FLINK-40197][python] Support UDF bindings in
DataFrame.sql (#29146)
a9d5b293f6a is described below
commit a9d5b293f6ab152bd80d46433a7b934b07620149
Author: Federico Dolce <[email protected]>
AuthorDate: Fri Sep 11 17:01:23 2026 +0200
[FLINK-40197][python] Support UDF bindings in DataFrame.sql (#29146)
---
.../docs/reference/pyflink.dataframe/sql.rst | 7 +-
flink-python/pyflink/dataframe/sql.py | 343 +++++++++++++------
flink-python/pyflink/dataframe/tests/test_sql.py | 364 ++++++++++++++++++++-
3 files changed, 603 insertions(+), 111 deletions(-)
diff --git a/flink-python/docs/reference/pyflink.dataframe/sql.rst
b/flink-python/docs/reference/pyflink.dataframe/sql.rst
index fdfb26ab796..b56e784031e 100644
--- a/flink-python/docs/reference/pyflink.dataframe/sql.rst
+++ b/flink-python/docs/reference/pyflink.dataframe/sql.rst
@@ -20,7 +20,7 @@
SQL
===
-Execute SQL SELECT queries against DataFrames.
+Execute SQL SELECT queries against DataFrames and DataFrame UDFs.
Example::
@@ -33,6 +33,11 @@ Example::
... auto_bind=False,
... src=df1,
... )
+ >>> @pf.udf
+ ... def add_one(value: int) -> int:
+ ... return value + 1
+ >>> pf.sql("SELECT add_one(a) AS a1 FROM df1")
+ >>> pf.sql("SELECT inc(a) FROM src", auto_bind=False, src=df1, inc=add_one)
>>> pf.sql("SELECT a, b FROM df1").filter(pf.col("a") > 1).to_pandas()
.. currentmodule:: pyflink.dataframe
diff --git a/flink-python/pyflink/dataframe/sql.py
b/flink-python/pyflink/dataframe/sql.py
index f244a81897b..879a060b29c 100644
--- a/flink-python/pyflink/dataframe/sql.py
+++ b/flink-python/pyflink/dataframe/sql.py
@@ -19,13 +19,16 @@
import inspect
import logging
import warnings
-from typing import Any, Dict, List
+from typing import Any, Callable, Dict, Iterable, List, Set, Union
from py4j.protocol import Py4JJavaError
from pyflink.dataframe.context import get_or_create_table_environment
from pyflink.dataframe.dataframe import DataFrame
+from pyflink.dataframe.udf import _DataFrameUDFWrapper
+from pyflink.java_gateway import get_gateway
from pyflink.table import Table, TableEnvironment
+from pyflink.table.expression import Expression
from pyflink.util.api_stability_decorators import PublicEvolving
from pyflink.util.java_utils import is_instance_of
@@ -33,28 +36,36 @@ __all__ = ["sql"]
_LOG = logging.getLogger(__name__)
+# UDFs are annotated with the declared return type of
:func:`pyflink.dataframe.udf`
+# so that its result type-checks as a binding; at runtime a binding must be an
actual
+# UDF object, which is what _BINDABLE_TYPES enforces.
+_Binding = Union[DataFrame, Callable[..., Expression]]
+_BINDABLE_TYPES = (DataFrame, _DataFrameUDFWrapper)
+
@PublicEvolving()
-def sql(query: str, *, auto_bind: bool = True, **bindings: DataFrame) ->
DataFrame:
+def sql(query: str, *, auto_bind: bool = True, **bindings: _Binding) ->
DataFrame:
"""
Execute a SQL query and return the result as a :class:`DataFrame`.
The query must be a single statement that returns a result, such as SELECT
or
VALUES (no INSERT / DDL; use :meth:`TableEnvironment.execute_sql` for
those).
- The referenced DataFrames are registered as temporary views for the
duration of
- the call and dropped afterwards. The result can be further transformed
with the
- DataFrame API.
+ The referenced DataFrames are registered as temporary views and the
referenced
+ UDFs (created with :func:`~pyflink.dataframe.udf`) as temporary system
functions
+ for the duration of the call, and both are dropped afterwards. The result
can be
+ further transformed with the DataFrame API.
When ``auto_bind`` is ``True`` (the default), the caller's local and
global variables
- are scanned for :class:`DataFrame` objects and each is registered under
its Python
- variable name. Auto-binding is best-effort: it warns and skips names that
are not
- valid SQL identifiers or that collide with an existing table or view, and
it never
- shadows permanent catalog objects.
+ are scanned for :class:`DataFrame` and UDF objects and each is registered
under its
+ Python variable name. Auto-binding is best-effort: it warns and skips
names that
+ are not valid SQL identifiers or that collide with an existing table, view
or
+ function, and it never shadows permanent catalog objects or built-in
functions.
Explicit keyword ``bindings`` define the SQL names directly. They are
strict
- (invalid names and conflicts with existing temporary views raise
- :class:`ValueError`), take precedence over auto-bind on name collisions,
and are
- required to intentionally shadow a permanent catalog table or view.
+ (invalid names and conflicts with existing temporary views or temporary
functions
+ raise :class:`ValueError`), take precedence over auto-bind on name
collisions, and
+ are required to intentionally shadow a permanent catalog table, view or
function
+ or a built-in function.
The query runs in the :class:`TableEnvironment` of the bound DataFrames:
the
environment shared by the explicit ``bindings`` when given, otherwise the
@@ -62,20 +73,25 @@ def sql(query: str, *, auto_bind: bool = True, **bindings:
DataFrame) -> DataFra
different environments raise :class:`ValueError`. Without explicit
bindings,
valid auto-bound candidates from different environments also raise
:class:`ValueError`; when explicit bindings determine the environment,
auto-bound
- candidates from other environments are skipped with a warning. The resolved
+ candidates from other environments are skipped with a warning. UDF
bindings are
+ not tied to an environment and do not take part in this resolution. The
resolved
environment is used only for this call and never replaces the global one.
+ Function names are case-insensitive in SQL, so a UDF bound as ``addOne`` is
+ referenced as ``addOne(...)`` or ``addone(...)`` alike.
+
:param query: The query to execute.
- :param auto_bind: Whether to scan the caller's variables for DataFrames.
- :param bindings: Explicit name to :class:`DataFrame` bindings.
+ :param auto_bind: Whether to scan the caller's variables for DataFrames
and UDFs.
+ :param bindings: Explicit name to :class:`DataFrame` or UDF bindings.
:return: The query result.
:raises ValueError: If the query is not a query statement, if an explicit
binding
is not a valid SQL identifier or conflicts with an
existing
- temporary view, or if explicit bindings belong to
different
- TableEnvironments, or if auto-bound candidates belong
to
- different TableEnvironments when there are no explicit
- bindings.
- :raises TypeError: If an explicit binding is not a :class:`DataFrame`.
+ temporary view or temporary function, or if explicit
+ bindings belong to different TableEnvironments, or if
+ auto-bound candidates belong to different
TableEnvironments
+ when there are no explicit bindings.
+ :raises TypeError: If an explicit binding is neither a :class:`DataFrame`
nor a
+ UDF created with :func:`~pyflink.dataframe.udf`.
Example::
@@ -90,6 +106,12 @@ def sql(query: str, *, auto_bind: bool = True, **bindings:
DataFrame) -> DataFra
... auto_bind=False,
... src=df1,
... )
+ >>> # UDFs are bound the same way, under their variable or keyword name
+ >>> @pf.udf
+ ... def add_one(value: int) -> int:
+ ... return value + 1
+ >>> pf.sql("SELECT add_one(a) AS a1 FROM df1")
+ >>> pf.sql("SELECT inc(a) FROM src", auto_bind=False, src=df1,
inc=add_one)
>>> # Mix SQL and the DataFrame API
>>> pf.sql("SELECT a, b FROM df1").filter(pf.col("a") > 1).to_pandas()
@@ -97,41 +119,71 @@ def sql(query: str, *, auto_bind: bool = True, **bindings:
DataFrame) -> DataFra
"""
if not isinstance(query, str):
raise TypeError("query must be a string")
- auto_bindings: Dict[str, DataFrame] = {}
+
+ # Collect auto bindings first.
+ variables = {}
+ # Gather the variables in the namespace
if auto_bind:
- frame = inspect.currentframe()
- caller = frame.f_back if frame is not None else None
- try:
- if caller is not None:
- # Locals take precedence over globals.
- namespace = {**caller.f_globals, **caller.f_locals}
- auto_bindings = {
- name: value
- for name, value in namespace.items()
- if isinstance(value, DataFrame)
- }
- finally:
- del frame, caller
- t_env = _resolve_table_environment(bindings, auto_bindings)
- registered: List[str] = []
+ if frame := inspect.currentframe():
+ if outer_frame := frame.f_back:
+ variables = {**outer_frame.f_globals, **outer_frame.f_locals}
+ # Suggested by python docs
+ del outer_frame
+ del frame
+ auto_frames = _get_dataframes(variables)
+ auto_udfs = _get_udfs(variables)
+
+ # Check that explicit bindings are the correct type first
+ for name, value in bindings.items():
+ if not isinstance(value, _BINDABLE_TYPES):
+ raise TypeError(
+ f"sql() binding '{name}' must be a DataFrame or a UDF created
with "
+ f"pyflink.dataframe.udf, got {type(value).__name__}"
+ )
+ explicit_frames = _get_dataframes(bindings)
+ explicit_udfs = _get_udfs(bindings)
+
+ t_env = _resolve_table_environment(explicit_frames, auto_frames)
+ # Each registration step is all-or-nothing: it rolls back its own partial
work on
+ # failure and only returns names on success, so a step that raises leaves
nothing
+ # of its own behind and the finally block only drops what earlier steps
returned.
+ views: List[str] = []
+ functions: List[str] = []
try:
- _register_bindings(t_env, bindings, auto_bindings, registered)
+ views = _register_views(t_env, explicit_frames, auto_frames)
+ functions = _register_functions(t_env, explicit_udfs, auto_udfs)
return DataFrame(_execute_query(t_env, query))
finally:
- for name in registered:
- # Best-effort cleanup: dropping must not mask an exception raised
by the
- # query itself, but only names registered by this call are
dropped, so a
- # failure is an anomaly the user should hear about.
- try:
- t_env.drop_temporary_view(name)
- except Exception:
- _LOG.warning(
- "sql() failed to drop temporary view '%s'", name,
exc_info=True
- )
+ _drop_functions(t_env, functions)
+ _drop_views(t_env, views)
+
+
+def _get_dataframes(namespace: Dict[str, Any]) -> Dict[str, DataFrame]:
+ return {k: v for k, v in namespace.items() if isinstance(v, DataFrame)}
+
+
+def _get_udfs(namespace: Dict[str, Any]) -> Dict[str, _DataFrameUDFWrapper]:
+ return {k: v for k, v in namespace.items() if isinstance(v,
_DataFrameUDFWrapper)}
+
+
+def _drop_views(t_env: TableEnvironment, names: List[str]) -> None:
+ for name in names:
+ try:
+ t_env.drop_temporary_view(name)
+ except Exception:
+ _LOG.warning("sql() failed to drop view '%s'", name, exc_info=True)
+
+
+def _drop_functions(t_env: TableEnvironment, names: List[str]) -> None:
+ for name in names:
+ try:
+ t_env.drop_temporary_system_function(name)
+ except Exception:
+ _LOG.warning("sql() failed to drop function '%s'", name,
exc_info=True)
def _resolve_table_environment(
- explicit: Dict[str, Any], auto: Dict[str, DataFrame]
+ explicit: Dict[str, DataFrame], auto: Dict[str, DataFrame]
) -> TableEnvironment:
"""
Pick the environment to run the query in: the environment shared by the
explicit
@@ -141,36 +193,36 @@ def _resolve_table_environment(
environment resolution. Mixed valid auto-bound candidates are also
ambiguous and
require explicit bindings, regardless of the configured global environment.
"""
- for name, value in explicit.items():
- if not isinstance(value, DataFrame):
- raise TypeError(
- f"sql() binding '{name}' must be a DataFrame, got
{type(value).__name__}"
- )
- # Deduplicate by identity: environments are not comparable by value.
- explicit_envs = {id(v._table._t_env): v._table._t_env for v in
explicit.values()}
+ explicit_envs = _distinct_environments(explicit.values())
if len(explicit_envs) > 1:
raise ValueError(
"sql() explicit bindings belong to different TableEnvironments; "
"bind DataFrames from a single environment"
)
if explicit_envs:
- return next(iter(explicit_envs.values()))
- auto_envs = {
- id(value._table._t_env): value._table._t_env
+ return explicit_envs[0]
+
+ auto_envs = _distinct_environments(
+ value
for name, value in auto.items()
if _is_simple_sql_identifier(value._table._t_env, name)
- }
- if len(auto_envs) == 1:
- return next(iter(auto_envs.values()))
+ )
if len(auto_envs) > 1:
raise ValueError(
"sql() auto-bound DataFrames belong to different
TableEnvironments; "
"set auto_bind=False and pass explicit bindings from a single "
"TableEnvironment"
)
+ if auto_envs:
+ return auto_envs[0]
return get_or_create_table_environment()
+def _distinct_environments(frames: Iterable[DataFrame]) ->
List[TableEnvironment]:
+ # Deduplicate by identity: environments are not comparable by value.
+ return list({id(f._table._t_env): f._table._t_env for f in
frames}.values())
+
+
def _execute_query(t_env: TableEnvironment, query: str) -> Table:
"""
Run ``query`` through :meth:`TableEnvironment.sql_query`, which parses the
statement
@@ -192,8 +244,8 @@ def _execute_query(t_env: TableEnvironment, query: str) ->
Table:
def _is_simple_sql_identifier(t_env: TableEnvironment, name: str) -> bool:
"""
Whether ``name`` is accepted verbatim as a single-part identifier by the
SQL parser,
- i.e. whether registering a temporary view under it can succeed. This is
the same
- validation :meth:`TableEnvironment.create_temporary_view` applies to its
path, so
+ i.e. whether registering a temporary view or function under it can
succeed. This is
+ the same validation :meth:`TableEnvironment.create_temporary_view`
applies, so
keywords like ``order`` pass (queries reference them with backticks) while
names
that would need quoting or resolve to a different or multi-part path do
not.
"""
@@ -212,51 +264,146 @@ def _is_simple_sql_identifier(t_env: TableEnvironment,
name: str) -> bool:
)
-def _register_bindings(
+def _register_views(
t_env: TableEnvironment,
explicit: Dict[str, DataFrame],
auto: Dict[str, DataFrame],
- registered: List[str],
-) -> None:
+) -> List[str]:
"""
- Register explicit and auto-collected bindings as temporary views,
appending each
- successful registration to ``registered``. The explicit bindings have
already been
- type-checked and share ``t_env`` (see :func:`_resolve_table_environment`).
+ Register explicit and auto-collected DataFrames as temporary views and
return the
+ registered names. Registration is all-or-nothing: if anything raises, the
views
+ registered so far are dropped again before re-raising. The explicit
bindings have
+ already been type-checked and share ``t_env`` (see
+ :func:`_resolve_table_environment`).
"""
temporary_tables = set(t_env.list_temporary_tables())
# list_tables() covers both permanent and temporary tables and views.
all_tables = set(t_env.list_tables())
+ registered: List[str] = []
- for name, value in explicit.items():
- if not _is_simple_sql_identifier(t_env, name):
- raise ValueError(f"cannot bind '{name}': it is not a valid SQL
identifier")
- if name in temporary_tables:
- raise ValueError(
- f"cannot bind '{name}': a temporary table or view with this
name "
- "already exists"
- )
- t_env.create_temporary_view(name, value.to_table())
- registered.append(name)
-
- for name, value in auto.items():
- if name in explicit:
- # Explicit bindings take precedence on name collisions.
- continue
- if not _is_simple_sql_identifier(t_env, name):
- _warn_skipped(name, "it is not a valid SQL identifier")
- continue
- if value._table._t_env is not t_env:
- _warn_skipped(name, "it belongs to a different TableEnvironment")
- continue
- if name in all_tables:
- _warn_skipped(name, "a table or view with this name already
exists")
- continue
- try:
+ try:
+ for name, value in explicit.items():
+ if not _is_simple_sql_identifier(t_env, name):
+ raise ValueError(
+ f"cannot bind '{name}': it is not a valid SQL identifier"
+ )
+ if name in temporary_tables:
+ raise ValueError(
+ f"cannot bind '{name}': a temporary table or view with
this name "
+ "already exists"
+ )
t_env.create_temporary_view(name, value.to_table())
- except Exception as e:
- _warn_skipped(name, f"registration failed: {e}")
- continue
- registered.append(name)
+ registered.append(name)
+
+ # Auto-bound candidates are not expected to raise: problems are
reported as
+ # warnings. Anything unexpected still rolls back the registered views.
+ for name, value in auto.items():
+ if name in explicit:
+ # Explicit bindings take precedence on name collisions.
+ continue
+ if not _is_simple_sql_identifier(t_env, name):
+ _warn_skipped(name, "it is not a valid SQL identifier")
+ continue
+ if value._table._t_env is not t_env:
+ _warn_skipped(name, "it belongs to a different
TableEnvironment")
+ continue
+ if name in all_tables:
+ _warn_skipped(name, "a table or view with this name already
exists")
+ continue
+ try:
+ t_env.create_temporary_view(name, value.to_table())
+ except Exception as e:
+ _warn_skipped(name, f"registration failed: {e}")
+ continue
+ registered.append(name)
+ except Exception:
+ _drop_views(t_env, registered)
+ raise
+ return registered
+
+
+def _register_functions(
+ t_env: TableEnvironment,
+ explicit: Dict[str, _DataFrameUDFWrapper],
+ auto: Dict[str, _DataFrameUDFWrapper],
+) -> List[str]:
+ """
+ Register explicit and auto-collected UDFs as temporary system functions
and return
+ the registered names. Registration is all-or-nothing: if anything raises,
the
+ functions registered so far are dropped again before re-raising.
+
+ System functions are looked up by bare name independently of the current
catalog
+ and database, which matches how a Python name is referenced in the query.
Function
+ names are case-insensitive: the catalog normalizes them to lower case, so
+ collisions are checked case-insensitively. Mirroring views, explicit
bindings may
+ shadow built-in and permanent catalog functions but never an existing
temporary
+ function; auto-bind shadows nothing.
+ """
+ if not explicit and not auto:
+ return []
+ # Explicit bindings take precedence on name collisions, so only the
remaining
+ # auto-bound candidates need the built-in function names. list_functions()
covers
+ # those as well, but is comparatively expensive, so skip it when nothing
needs it.
+ explicit_names = {name.lower() for name in explicit}
+ auto_candidates = {
+ name: value for name, value in auto.items() if name.lower() not in
explicit_names
+ }
+ all_functions: Set[str] = set()
+ if auto_candidates:
+ all_functions = {f.lower() for f in t_env.list_functions()}
+ registered: List[str] = []
+
+ try:
+ for name, value in explicit.items():
+ if not _is_simple_sql_identifier(t_env, name):
+ raise ValueError(
+ f"cannot bind '{name}': it is not a valid SQL identifier"
+ )
+ if _has_temporary_function(t_env, name):
+ raise ValueError(
+ f"cannot bind '{name}': a temporary function with this
name "
+ "already exists"
+ )
+ t_env.create_temporary_system_function(name,
value._table_udf_wrapper)
+ registered.append(name)
+
+ # Auto-bound candidates are not expected to raise: problems are
reported as
+ # warnings. Anything unexpected still rolls back the registered
functions.
+ for name, value in auto_candidates.items():
+ if not _is_simple_sql_identifier(t_env, name):
+ _warn_skipped(name, "it is not a valid SQL identifier")
+ continue
+ if name.lower() in all_functions or _has_temporary_function(t_env,
name):
+ _warn_skipped(name, "a function with this name already exists")
+ continue
+ try:
+ t_env.create_temporary_system_function(name,
value._table_udf_wrapper)
+ except Exception as e:
+ _warn_skipped(name, f"registration failed: {e}")
+ continue
+ registered.append(name)
+ except Exception:
+ _drop_functions(t_env, registered)
+ raise
+ return registered
+
+
+def _has_temporary_function(t_env: TableEnvironment, name: str) -> bool:
+ """
+ Whether a temporary system function or a temporary catalog function in the
current
+ catalog and database is registered under ``name``. The environment's
function
+ listings merge temporary and permanent functions, so a temporary function
that
+ shares its name with a permanent one cannot be told apart from them; the
function
+ catalog keeps them separate.
+ """
+ function_catalog =
t_env._j_tenv.getPlanner().getFlinkContext().getFunctionCatalog()
+ if function_catalog.hasTemporarySystemFunction(name.lower()):
+ return True
+ gateway = get_gateway()
+ identifier =
gateway.jvm.org.apache.flink.table.catalog.ObjectIdentifier.of(
+ t_env.get_current_catalog(), t_env.get_current_database(), name
+ )
+ return function_catalog.hasTemporaryCatalogFunction(identifier)
def _warn_skipped(name: str, reason: str) -> None:
diff --git a/flink-python/pyflink/dataframe/tests/test_sql.py
b/flink-python/pyflink/dataframe/tests/test_sql.py
index bed0eecae5b..bcc7b1190b7 100644
--- a/flink-python/pyflink/dataframe/tests/test_sql.py
+++ b/flink-python/pyflink/dataframe/tests/test_sql.py
@@ -17,16 +17,27 @@
################################################################################
import unittest
+import warnings
from py4j.protocol import Py4JJavaError
import pyflink.dataframe as pf
from pyflink.common import Row
from pyflink.table import DataTypes, EnvironmentSettings, TableEnvironment
-from pyflink.table.udf import udf
+from pyflink.table.udf import udf as table_udf
from pyflink.testing.test_case_utils import PyFlinkDataFrameUTTestCase
[email protected]
+def module_level_add_one(value: int) -> int:
+ return value + 1
+
+
+def _collect_sorted(df: pf.DataFrame) -> list:
+ """Collect rows in a deterministic order: query results are unordered."""
+ return sorted(df.collect(), key=lambda row: row[0])
+
+
class SqlValidationTests(unittest.TestCase):
def setUp(self):
previous_environment = pf.get_table_environment()
@@ -78,7 +89,7 @@ class SqlTests(PyFlinkDataFrameUTTestCase):
)
self.assertEqual(
- sorted(joined.collect(), key=lambda row: row[0]),
+ _collect_sorted(joined),
[Row(1, "x", "p"), Row(2, "y", "q"), Row(3, "z", "r")],
)
@@ -94,7 +105,7 @@ class SqlTests(PyFlinkDataFrameUTTestCase):
]:
with self.subTest(query=query):
self.assertEqual(
- sorted(pf.sql(query).collect(), key=lambda row: row[0]),
+ _collect_sorted(pf.sql(query)),
[Row(1), Row(2)],
)
@@ -109,7 +120,7 @@ class SqlTests(PyFlinkDataFrameUTTestCase):
)
self.assertEqual(
- sorted(result.collect(), key=lambda row: row[0]),
+ _collect_sorted(result),
[Row(10), Row(20)],
)
@@ -282,21 +293,350 @@ class SqlTests(PyFlinkDataFrameUTTestCase):
with self.assertRaisesRegex(TypeError, "'x' must be a DataFrame"):
pf.sql("SELECT * FROM x", auto_bind=False, x=table)
- def test_udfs_are_not_bindable(self):
- # UDF support will come in a separate change once the DataFrame API
grows
- # UDF support in general: sql() must reject them rather than
half-support them.
- add_one = udf(lambda i: i + 1, result_type=DataTypes.BIGINT())
+ def test_table_udfs_are_not_bindable(self):
+ # Only DataFrame UDFs (pf.udf) are supported, mirroring the rejection
of raw
+ # Tables in favour of DataFrames.
+ add_one = table_udf(lambda i: i + 1, result_type=DataTypes.BIGINT())
- with self.assertRaisesRegex(TypeError, "'add_one' must be a
DataFrame"):
+ with self.assertRaisesRegex(
+ TypeError, "'add_one' must be a DataFrame or a UDF created with"
+ ):
pf.sql("SELECT add_one(a) FROM df", auto_bind=False,
add_one=add_one)
- def test_auto_bind_ignores_udfs(self):
+ def test_auto_bind_ignores_table_udfs(self):
df = pf.from_dict({"a": [1]}) # noqa: F841
- add_one = udf(lambda i: i + 1, result_type=DataTypes.BIGINT()) #
noqa: F841
+ add_one = table_udf( # noqa: F841
+ lambda i: i + 1, result_type=DataTypes.BIGINT()
+ )
with self.assertRaisesRegex(Py4JJavaError, "No match found for
function"):
pf.sql("SELECT add_one(a) FROM df")
+ def test_auto_bind_registers_udfs_by_variable_name(self):
+ df = pf.from_dict({"a": [1, 2]}) # noqa: F841
+
+ @pf.udf
+ def add_one(value: int) -> int:
+ return value + 1
+
+ result = pf.sql("SELECT add_one(a) AS b FROM df")
+
+ self.assertEqual(
+ _collect_sorted(result), [Row(2), Row(3)]
+ )
+ self.assertNotIn("add_one", self.t_env.list_user_defined_functions())
+
+ def test_auto_bind_finds_module_level_udfs(self):
+ df = pf.from_dict({"a": [1, 2]}) # noqa: F841
+
+ result = pf.sql("SELECT module_level_add_one(a) AS b FROM df")
+
+ self.assertEqual(
+ _collect_sorted(result), [Row(2), Row(3)]
+ )
+
+ def test_explicit_udf_binding_picks_the_sql_name(self):
+ src = pf.from_dict({"a": [1, 2]})
+
+ @pf.udf
+ def add_one(value: int) -> int:
+ return value + 1
+
+ result = pf.sql(
+ "SELECT inc(a) AS b FROM src", auto_bind=False, src=src,
inc=add_one
+ )
+
+ self.assertEqual(
+ _collect_sorted(result), [Row(2), Row(3)]
+ )
+ self.assertNotIn("inc", self.t_env.list_user_defined_functions())
+
+ def test_auto_bind_disabled_ignores_caller_udfs(self):
+ df = pf.from_dict({"a": [1]}) # noqa: F841
+ add_one = pf.udf(lambda value: value + 1, return_dtype=int) # noqa:
F841
+
+ with self.assertRaisesRegex(Py4JJavaError, "No match found for
function"):
+ pf.sql("SELECT add_one(a) FROM df", auto_bind=False, df=df)
+
+ def test_explicit_udf_binding_takes_precedence_over_auto_bind(self):
+ df = pf.from_dict({"a": [1]}) # noqa: F841
+ add_one = pf.udf(lambda value: value + 1, return_dtype=int) # noqa:
F841
+ add_ten = pf.udf(lambda value: value + 10, return_dtype=int)
+
+ result = pf.sql("SELECT add_one(a) FROM df", add_one=add_ten)
+
+ self.assertEqual(result.collect(), [Row(11)])
+
+ def test_udf_names_are_case_insensitive(self):
+ df = pf.from_dict({"a": [1]}) # noqa: F841
+ addOne = pf.udf(lambda value: value + 1, return_dtype=int) # noqa:
F841
+
+ for query in ["SELECT addOne(a) FROM df", "SELECT ADDONE(a) FROM df"]:
+ with self.subTest(query=query):
+ self.assertEqual(pf.sql(query).collect(), [Row(2)])
+ self.assertNotIn("addone", self.t_env.list_user_defined_functions())
+
+ def test_pandas_udfs_are_bindable(self):
+ df = pf.from_dict({"a": [1, 2]}) # noqa: F841
+ add_one = pf.udf( # noqa: F841
+ lambda values: values + 1, return_dtype=int, func_type="pandas"
+ )
+
+ result = pf.sql("SELECT add_one(a) AS b FROM df")
+
+ self.assertEqual(
+ _collect_sorted(result), [Row(2), Row(3)]
+ )
+
+ def test_udf_bindings_compose_with_the_dataframe_api(self):
+ df = pf.from_dict({"a": [1, 2, 3]}) # noqa: F841
+ add_one = pf.udf(lambda value: value + 1, return_dtype=int) # noqa:
F841
+
+ result = (
+ pf.sql("SELECT add_one(a) AS b FROM df")
+ .filter(pf.col("b") > 2)
+ .with_columns(c=add_one(pf.col("b")))
+ )
+
+ self.assertEqual(
+ _collect_sorted(result),
+ [Row(3, 4), Row(4, 5)],
+ )
+
+ def test_udf_bindings_are_dropped_after_failure(self):
+ df = pf.from_dict({"a": [1]}) # noqa: F841
+ add_one = pf.udf(lambda value: value + 1, return_dtype=int) # noqa:
F841
+
+ with self.assertRaises(Py4JJavaError):
+ pf.sql("SELECT add_one(nonexistent_column) FROM df")
+
+ self.assertNotIn("add_one", self.t_env.list_user_defined_functions())
+
+ def
test_auto_bind_warns_and_skips_udf_colliding_with_existing_function(self):
+ self.t_env.create_temporary_system_function(
+ "add_one", table_udf(lambda i: i + 100,
result_type=DataTypes.BIGINT())
+ )
+ self.addCleanup(self.t_env.drop_temporary_system_function, "add_one")
+ df = pf.from_dict({"a": [1]}) # noqa: F841
+ add_one = pf.udf(lambda value: value + 1, return_dtype=int) # noqa:
F841
+
+ with self.assertWarnsRegex(UserWarning, "skipped 'add_one'.*already
exists"):
+ result = pf.sql("SELECT add_one(a) FROM df")
+
+ # The pre-existing function wins and survives the call.
+ self.assertEqual(result.collect(), [Row(101)])
+ self.assertIn("add_one", self.t_env.list_user_defined_functions())
+
+ def
test_auto_bind_warns_and_skips_udf_colliding_with_builtin_function(self):
+ df = pf.from_dict({"a": [-1]}) # noqa: F841
+ abs = pf.udf(lambda value: value + 100, return_dtype=int) # noqa: F841
+
+ with self.assertWarnsRegex(UserWarning, "skipped 'abs'.*already
exists"):
+ result = pf.sql("SELECT abs(a) FROM df")
+
+ # The built-in function is never shadowed.
+ self.assertEqual(result.collect(), [Row(1)])
+
+ def test_explicit_udf_binding_shadows_builtin_function(self):
+ df = pf.from_dict({"a": [-1]})
+ my_abs = pf.udf(lambda value: value + 100, return_dtype=int)
+
+ result = pf.sql("SELECT abs(a) FROM df", auto_bind=False, df=df,
abs=my_abs)
+
+ self.assertEqual(result.collect(), [Row(99)])
+ # The built-in function is restored after the call.
+ self.assertEqual(pf.sql("SELECT abs(a) FROM df", auto_bind=False,
df=df)
+ .collect(), [Row(1)])
+
+ def test_explicit_udf_binding_shadows_permanent_function(self):
+ # RichFunc0 adds one to its argument.
+ self.t_env.create_java_function(
+ "perm", "org.apache.flink.table.utils.TestingFunctions$RichFunc0"
+ )
+ self.addCleanup(self.t_env.drop_function, "perm")
+ df = pf.from_dict({"a": [1]})
+ my_perm = pf.udf(lambda value: value + 100, return_dtype=int)
+
+ result = pf.sql("SELECT perm(CAST(a AS INT)) FROM df",
auto_bind=False, df=df, perm=my_perm)
+
+ self.assertEqual(result.collect(), [Row(101)])
+ # The permanent function is restored after the call.
+ self.assertEqual(
+ pf.sql("SELECT perm(CAST(a AS INT)) FROM df", auto_bind=False,
df=df).collect(),
+ [Row(2)],
+ )
+ self.assertIn("perm", self.t_env.list_user_defined_functions())
+
+ def
test_auto_bind_warns_and_skips_udf_colliding_with_permanent_function(self):
+ self.t_env.create_java_function(
+ "perm", "org.apache.flink.table.utils.TestingFunctions$RichFunc0"
+ )
+ self.addCleanup(self.t_env.drop_function, "perm")
+ df = pf.from_dict({"a": [1]}) # noqa: F841
+ perm = pf.udf(lambda value: value + 100, return_dtype=int) # noqa:
F841
+
+ with self.assertWarnsRegex(UserWarning, "skipped 'perm'.*already
exists"):
+ result = pf.sql("SELECT perm(CAST(a AS INT)) FROM df")
+
+ # The permanent function wins.
+ self.assertEqual(result.collect(), [Row(2)])
+
+ def
test_explicit_udf_binding_collision_with_temporary_function_raises(self):
+ self.t_env.create_temporary_system_function(
+ "taken", table_udf(lambda i: i + 100,
result_type=DataTypes.BIGINT())
+ )
+ self.addCleanup(self.t_env.drop_temporary_system_function, "taken")
+ df = pf.from_dict({"a": [1]})
+
+ with self.assertRaisesRegex(
+ ValueError, "'taken'.*temporary function.*already exists"
+ ):
+ pf.sql(
+ "SELECT taken(a) FROM df",
+ auto_bind=False,
+ df=df,
+ taken=pf.udf(lambda value: value + 1, return_dtype=int),
+ )
+
+ # The view registered before the failure is cleaned up.
+ self.assertNotIn("df", self.t_env.list_temporary_views())
+
+ def
test_explicit_udf_binding_collision_with_temporary_catalog_function_raises(self):
+ self.t_env.create_temporary_function(
+ "taken", table_udf(lambda i: i + 100,
result_type=DataTypes.BIGINT())
+ )
+ self.addCleanup(self.t_env.drop_temporary_function, "taken")
+
+ with self.assertRaisesRegex(
+ ValueError, "'taken'.*temporary function.*already exists"
+ ):
+ pf.sql(
+ "SELECT 1",
+ auto_bind=False,
+ taken=pf.udf(lambda value: value + 1, return_dtype=int),
+ )
+
+ def
test_explicit_udf_binding_collision_with_temporary_function_shadowing_permanent_raises(
+ self,
+ ):
+ # A temporary function with the same name as a permanent one is not
visible
+ # in the environment's merged function listings; it must still block
the binding.
+ self.t_env.create_java_function(
+ "taken", "org.apache.flink.table.utils.TestingFunctions$RichFunc0"
+ )
+ self.addCleanup(self.t_env.drop_function, "taken")
+ self.t_env.create_temporary_system_function(
+ "taken", table_udf(lambda i: i + 100,
result_type=DataTypes.BIGINT())
+ )
+ self.addCleanup(self.t_env.drop_temporary_system_function, "taken")
+
+ with self.assertRaisesRegex(
+ ValueError, "'taken'.*temporary function.*already exists"
+ ):
+ pf.sql(
+ "SELECT 1",
+ auto_bind=False,
+ taken=pf.udf(lambda value: value + 1, return_dtype=int),
+ )
+
+ def
test_explicit_udf_binding_collision_with_temporary_catalog_function_shadowing_permanent_raises(
# noqa: E501
+ self,
+ ):
+ self.t_env.create_java_function(
+ "taken", "org.apache.flink.table.utils.TestingFunctions$RichFunc0"
+ )
+ self.addCleanup(self.t_env.drop_function, "taken")
+ self.t_env.create_temporary_function(
+ "taken", table_udf(lambda i: i + 100,
result_type=DataTypes.BIGINT())
+ )
+ self.addCleanup(self.t_env.drop_temporary_function, "taken")
+
+ with self.assertRaisesRegex(
+ ValueError, "'taken'.*temporary function.*already exists"
+ ):
+ pf.sql(
+ "SELECT 1",
+ auto_bind=False,
+ taken=pf.udf(lambda value: value + 1, return_dtype=int),
+ )
+
+ def
test_auto_bound_registrations_are_rolled_back_when_auto_bind_raises(self):
+ # With warnings turned into errors, the skipped auto-bound candidate
raises
+ # after earlier candidates were already registered; nothing must be
left behind.
+ df = pf.from_dict({"a": [-1]}) # noqa: F841
+ add_one = pf.udf(lambda value: value + 1, return_dtype=int) # noqa:
F841
+ abs = pf.udf(lambda value: value + 100, return_dtype=int) # noqa: F841
+
+ with warnings.catch_warnings():
+ warnings.simplefilter("error")
+ with self.assertRaisesRegex(UserWarning, "skipped 'abs'"):
+ pf.sql("SELECT add_one(a), abs(a) FROM df")
+
+ self.assertNotIn("df", self.t_env.list_temporary_views())
+ self.assertNotIn("add_one", self.t_env.list_user_defined_functions())
+
+ def
test_explicit_udf_binding_precedence_over_auto_bind_is_case_insensitive(self):
+ df = pf.from_dict({"a": [1]}) # noqa: F841
+ add_one = pf.udf(lambda value: value + 1, return_dtype=int) # noqa:
F841
+ override = pf.udf(lambda value: value + 100, return_dtype=int)
+
+ with warnings.catch_warnings():
+ warnings.simplefilter("error")
+ result = pf.sql("SELECT ADD_ONE(a) FROM df", ADD_ONE=override)
+
+ self.assertEqual(result.collect(), [Row(101)])
+
+ def
test_explicit_udf_bindings_are_rolled_back_when_a_later_one_fails(self):
+ self.t_env.create_temporary_system_function(
+ "taken", table_udf(lambda i: i + 100,
result_type=DataTypes.BIGINT())
+ )
+ self.addCleanup(self.t_env.drop_temporary_system_function, "taken")
+
+ with self.assertRaisesRegex(ValueError, "'taken'.*already exists"):
+ pf.sql(
+ "SELECT 1",
+ auto_bind=False,
+ first=pf.udf(lambda value: value + 1, return_dtype=int),
+ taken=pf.udf(lambda value: value + 1, return_dtype=int),
+ )
+
+ self.assertNotIn("first", self.t_env.list_user_defined_functions())
+
+ def test_explicit_udf_binding_collision_is_case_insensitive(self):
+ self.t_env.create_temporary_system_function(
+ "taken", table_udf(lambda i: i + 100,
result_type=DataTypes.BIGINT())
+ )
+ self.addCleanup(self.t_env.drop_temporary_system_function, "taken")
+
+ with self.assertRaisesRegex(ValueError, "'TAKEN'.*already exists"):
+ pf.sql(
+ "SELECT 1",
+ auto_bind=False,
+ TAKEN=pf.udf(lambda value: value + 1, return_dtype=int),
+ )
+
+ def test_explicit_udf_binding_with_invalid_sql_identifier_raises(self):
+ with self.assertRaisesRegex(
+ ValueError, "'my udf'.*not a valid SQL identifier"
+ ):
+ pf.sql(
+ "SELECT 1",
+ auto_bind=False,
+ **{"my udf": pf.udf(lambda value: value + 1,
return_dtype=int)},
+ )
+
+ def test_udf_bindings_do_not_take_part_in_environment_resolution(self):
+ other_env =
TableEnvironment.create(EnvironmentSettings.in_batch_mode())
+ source = pf.DataFrame(other_env.from_elements([(1,)], ["a"]))
+ add_one = pf.udf(lambda value: value + 1, return_dtype=int) # noqa:
F841
+
+ # The explicit binding selects other_env; the auto-bound UDF follows
it.
+ result = pf.sql("SELECT add_one(a) FROM src", src=source)
+
+ self.assertEqual(result.collect(), [Row(2)])
+ self.assertNotIn("add_one", other_env.list_user_defined_functions())
+ self.assertNotIn("add_one", self.t_env.list_user_defined_functions())
+
def test_explicit_bindings_resolve_the_environment(self):
other_env =
TableEnvironment.create(EnvironmentSettings.in_batch_mode())
source = pf.DataFrame(other_env.from_elements([(1,), (2,)], ["a"]))
@@ -304,7 +644,7 @@ class SqlTests(PyFlinkDataFrameUTTestCase):
result = pf.sql("SELECT a FROM src", auto_bind=False, src=source)
self.assertEqual(
- sorted(result.collect(), key=lambda row: row[0]), [Row(1), Row(2)]
+ _collect_sorted(result), [Row(1), Row(2)]
)
# The environment is resolved per call; the global one is untouched.
self.assertIs(pf.get_table_environment(), self.t_env)