blob: 46d66cc60deccee7bf8603dcc991a297921a5cd5 [file]
################################################################################
# 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.
################################################################################
import inspect
import logging
import warnings
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.dataframe.udtf import _DataFrameUDTFWrapper
from pyflink.dataframe.udaf import _DataFrameUDAFWrapper
from pyflink.java_gateway import get_gateway
from pyflink.table import Table, TableEnvironment
from pyflink.table.expression import Expression
from pyflink.table.udf import UserDefinedFunctionWrapper
from pyflink.util.api_stability_decorators import PublicEvolving
from pyflink.util.java_utils import is_instance_of
__all__ = ["sql"]
_LOG = logging.getLogger(__name__)
# Scalar UDFs use 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.
_FunctionBinding = Union[_DataFrameUDFWrapper, _DataFrameUDTFWrapper, _DataFrameUDAFWrapper]
_Binding = Union[DataFrame, Callable[..., Expression], _DataFrameUDTFWrapper]
_FUNCTION_TYPES = (_DataFrameUDFWrapper, _DataFrameUDTFWrapper, _DataFrameUDAFWrapper)
_BINDABLE_TYPES = (DataFrame, *_FUNCTION_TYPES)
@PublicEvolving()
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 and the referenced
UDFs (created with :func:`~pyflink.dataframe.udf`, :func:`~pyflink.dataframe.udtf`,
or :func:`~pyflink.dataframe.udaf`)
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` 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 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
environment shared by all valid auto-bound candidates. Explicit bindings from
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. 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 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 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`,
:func:`~pyflink.dataframe.udtf`, or :func:`~pyflink.dataframe.udaf`.
Example::
>>> import pyflink.dataframe as pf
>>> df1 = pf.from_dict({"a": [1, 2, 3], "b": ["x", "y", "z"]})
>>> df2 = pf.from_dict({"a": [1, 2, 3], "c": ["p", "q", "r"]})
>>> # Auto-bind: df1 / df2 are registered under their variable names
>>> joined = pf.sql("SELECT df1.a, b, c FROM df1 JOIN df2 ON df1.a = df2.a")
>>> # Explicit bindings: pick the SQL names, turn off scanning
>>> result = pf.sql(
... "SELECT * FROM src WHERE a > 1",
... 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)
>>> # Table UDFs can be used in lateral joins
>>> @pf.udtf(return_dtype=str)
... def chars(text):
... yield from text
>>> pf.sql("SELECT ch FROM df1, LATERAL TABLE(chars(b)) AS T(ch)")
>>> # Mix SQL and the DataFrame API
>>> pf.sql("SELECT a, b FROM df1").filter(pf.col("a") > 1).to_pandas()
.. versionadded:: 2.4.0
"""
if not isinstance(query, str):
raise TypeError("query must be a string")
# Collect auto bindings first.
variables = {}
# Gather the variables in the namespace
if auto_bind:
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 "
"pyflink.dataframe.udf, pyflink.dataframe.udtf or pyflink.dataframe.udaf, "
f"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:
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:
_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, _FunctionBinding]:
return {k: v for k, v in namespace.items() if isinstance(v, _FUNCTION_TYPES)}
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, DataFrame], auto: Dict[str, DataFrame]
) -> TableEnvironment:
"""
Pick the environment to run the query in: the environment shared by the explicit
bindings when given, otherwise the environment shared by all auto-bound
candidates. Explicit bindings from different environments are an error the caller
must resolve. Auto-bound candidates with invalid identifiers do not participate in
environment resolution. Mixed valid auto-bound candidates are also ambiguous and
require explicit bindings, regardless of the configured global environment.
"""
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 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:
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
and rejects anything that is not a single query returning a result. Translate that
rejection into a plain :class:`ValueError`.
"""
try:
return t_env.sql_query(query)
except Py4JJavaError as e:
if "Unsupported SQL query!" in str(e.java_exception):
raise ValueError(
"sql() only supports queries that return a result, such as SELECT "
"or VALUES (no INSERT / DDL); use TableEnvironment.execute_sql() "
"for other statements."
) from e
raise
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 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.
"""
try:
identifier = t_env._j_tenv.getParser().parseIdentifier(name)
except Py4JJavaError as e:
if not is_instance_of(
e.java_exception, "org.apache.flink.table.api.SqlParserException"
):
raise
return False
return (
not identifier.getCatalogName().isPresent()
and not identifier.getDatabaseName().isPresent()
and identifier.getObjectName() == name
)
def _register_views(
t_env: TableEnvironment,
explicit: Dict[str, DataFrame],
auto: Dict[str, DataFrame],
) -> List[str]:
"""
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] = []
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())
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 _get_table_udf_wrapper(value: _FunctionBinding) -> UserDefinedFunctionWrapper:
if isinstance(value, _DataFrameUDTFWrapper):
return value._create_table_wrapper(preserve_field_names=True)
return value._table_udf_wrapper
def _register_functions(
t_env: TableEnvironment,
explicit: Dict[str, _FunctionBinding],
auto: Dict[str, _FunctionBinding],
) -> 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, _get_table_udf_wrapper(value))
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, _get_table_udf_wrapper(value))
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:
warnings.warn(
f"sql() auto-bind skipped '{name}': {reason}.",
UserWarning,
)