[FLINK-40197][python] Support UDF bindings in DataFrame sql() - #29146
[FLINK-40197][python] Support UDF bindings in DataFrame sql()#29146fdolce wants to merge 1 commit into
Conversation
Allow UDFs created with pyflink.dataframe.udf to be bound in sql(), both explicitly by keyword and via auto-bind from the caller's scope. UDFs are registered as temporary system functions for the duration of the call and dropped afterwards. Auto-bound UDFs never shadow existing or built-in functions; explicit bindings may shadow built-ins but raise on collision with an existing user-defined function. Function name matching is case-insensitive, mirroring the function catalog.
| functions: List[str] = [] | ||
| try: | ||
| _register_bindings(t_env, bindings, auto_bindings, registered) | ||
| views = _register_views(t_env, explicit_frames, auto_frames) |
There was a problem hiding this comment.
The registered names are assigned to views / functions only after each helper returns successfully. If an exception occurs after a partial auto-bind, the outer finally cannot see or clean up the objects that were already registered.
There was a problem hiding this comment.
It's true that the outer finally doesn't catch that, but both _register_views and _register_functions have their own internal try/catch to drop partially registered ones. If you prefer, I can move the responsibility of the drop up here, but as it is it should work correctly, or am I missing anything else?
There was a problem hiding this comment.
The internal cleanup only covers the explicit-binding loops. The auto-binding loops are outside those try/except blocks.
The same issue exists in _register_views(). Moving the registration tracker to the outer scope, or wrapping both explicit and auto registration in the helper cleanup block, would cover this path.
| # list_user_defined_functions() covers temporary and permanent functions alike; | ||
| # the permanent ones are those the current catalog lists for the current database. | ||
| user_defined = {f.lower() for f in t_env.list_user_defined_functions()} | ||
| temporary_functions = user_defined - _permanent_functions(t_env) |
There was a problem hiding this comment.
The temporary functions cannot be derived by subtracting these two name sets. list_user_defined_functions() merges temporary system, temporary catalog, and permanent catalog functions, so the source information has already been lost.
For example, if both a permanent function f and a temporary catalog function f exist:
user_defined == {"f"}_permanent_functions(t_env) == {"f"}temporary_functions == set()
An explicit f=my_udf binding is then allowed and registered as a temporary system function, which shadows the existing temporary function instead of raising the documented ValueError.
There was a problem hiding this comment.
Ah, yes, good catch, working on this
|
|
||
| _LOG = logging.getLogger(__name__) | ||
|
|
||
| _Binding = Union[DataFrame, _DataFrameUDFWrapper] |
There was a problem hiding this comment.
The public type contract here does not match the return type of pf.udf().
sql() accepts _DataFrameUDFWrapper, while the pf.udf() overloads are declared to return Callable[..., Expression].
The documented usage works at runtime, but it will be rejected by static type checkers:
df = pf.from_dict({"a": [1]})
@pf.udf
def inc(value: int) -> int:
return value + 1
result = pf.sql(
"SELECT inc(a) FROM df",
auto_bind=False,
df=df,
inc=inc,
)
At runtime, inc is an _DataFrameUDFWrapper. However, the pf.udf() overload declares it as Callable[..., Expression], which is incompatible with the _Binding annotation expected by pf.sql().
Do you think it makes sense to update the return type of py.udf from Callable[..., Expression] to _DataFrameUDFWrapper though it's not directly introduced by this PR?
There was a problem hiding this comment.
True. Mh, having pf.udf return _DataFrameUDFWrapper kind of makes that class a part of the public api though right? Let me see if I find any alternative solution here, I'll let you know
| # 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. | ||
| auto_candidates = {name: value for name, value in auto.items() if name not in explicit} |
There was a problem hiding this comment.
Should we normalize the explicit names first and filter auto candidates using the normalized name set?
For the following example:
add_one = pf.udf(...)
pf.sql("SELECT ADD_ONE(a)", ADD_ONE=override)
add_one is not removed from auto_candidates because this comparison uses the original Python keys. The explicit function is registered first, and the auto-bound add_one then produces a misleading "function already exists" warning.
There was a problem hiding this comment.
True, working on this
What is the purpose of the change
pyflink.dataframe.sql()currently binds only DataFrames. This PR extends it to also bind UDFs created withpyflink.dataframe.udf, so a query can call a Python UDF the same way it references a DataFrame: either by its Python variable name via auto-bind, or under a chosen SQL name via an explicit keyword binding. UDFs are registered as temporary system functions for the duration of the call and dropped afterwards, mirroring how DataFrames are registered as temporary views.Brief change log
sql()accepts_DataFrameUDFWrappervalues in**bindingsand picks them up from the caller's scope whenauto_bind=True; other types still raiseTypeError.create_temporary_system_functionso they resolve by bare name independently of the current catalog/database, and are dropped infinally.ValueErroron collision with an existing temporary function; auto-bound UDFs never shadow any existing function and are skipped with a warning instead. Name collision checks are case-insensitive, matching the function catalog.TableEnvironmentresolution, which is still driven by the bound DataFrames only.sql.pyinto_register_views/_register_functions/_drop_views/_drop_functionshelpers; updated docstrings anddocs/reference/pyflink.dataframe/sql.rst.Verifying this change
This change added tests and can be verified as follows:
flink-python/pyflink/dataframe/tests/test_sql.pywith tests covering:auto_bind=Falseignoring caller UDFsValueErrorTableEnvironmentresolutionpyflink.table.udfobjects are rejected / ignored were kept (renamed to*_table_udfs_*).Does this pull request potentially affect one of the following parts:
@Public(Evolving): yes (pyflink.dataframe.sql,@PublicEvolving, accepts UDFs inbindings; backwards compatible)Documentation
docs/reference/pyflink.dataframe/sql.rst) and thesql()docstringWas generative AI tooling used to co-author this PR?
Generated-by: Claude Code (Claude Fable 5.1)