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 9e76ac48dab [FLINK-40197][python] Add sql() to the DataFrame API
9e76ac48dab is described below

commit 9e76ac48dab13733dce130de0da5b017f4c2dae2
Author: Federico Dolce <[email protected]>
AuthorDate: Fri Aug 28 12:39:36 2026 +0200

    [FLINK-40197][python] Add sql() to the DataFrame API
    
    Add pyflink.dataframe.sql(), which executes a SQL SELECT query and
    returns the result as a DataFrame. Referenced DataFrames are registered
    as temporary views for the duration of the call and dropped afterwards:
    auto-binding registers DataFrames from the caller's scope under their
    variable names (best-effort, with warnings on invalid identifiers and
    collisions, never shadowing permanent catalog objects), while explicit
    keyword bindings are strict, take precedence, and may intentionally
    shadow permanent catalog tables.
    
    This closes #29036.
---
 .../docs/reference/pyflink.dataframe/index.rst     |   1 +
 .../pyflink.dataframe/{index.rst => sql.rst}       |  33 +-
 flink-python/pyflink/dataframe/__init__.py         |   2 +
 flink-python/pyflink/dataframe/sql.py              | 266 +++++++++++++++
 flink-python/pyflink/dataframe/tests/test_sql.py   | 370 +++++++++++++++++++++
 5 files changed, 661 insertions(+), 11 deletions(-)

diff --git a/flink-python/docs/reference/pyflink.dataframe/index.rst 
b/flink-python/docs/reference/pyflink.dataframe/index.rst
index e07a631d1fe..5f0645c2baa 100644
--- a/flink-python/docs/reference/pyflink.dataframe/index.rst
+++ b/flink-python/docs/reference/pyflink.dataframe/index.rst
@@ -28,5 +28,6 @@ This page gives an overview of all public PyFlink DataFrame 
APIs.
     dataframe
     creation
     io
+    sql
     datatype
     environment
diff --git a/flink-python/docs/reference/pyflink.dataframe/index.rst 
b/flink-python/docs/reference/pyflink.dataframe/sql.rst
similarity index 61%
copy from flink-python/docs/reference/pyflink.dataframe/index.rst
copy to flink-python/docs/reference/pyflink.dataframe/sql.rst
index e07a631d1fe..fdfb26ab796 100644
--- a/flink-python/docs/reference/pyflink.dataframe/index.rst
+++ b/flink-python/docs/reference/pyflink.dataframe/sql.rst
@@ -16,17 +16,28 @@
     limitations under the License.
    
################################################################################
 
-==================
-PyFlink DataFrame
-==================
+===
+SQL
+===
 
-This page gives an overview of all public PyFlink DataFrame APIs.
+Execute SQL SELECT queries against DataFrames.
 
-.. toctree::
-    :maxdepth: 1
+Example::
 
-    dataframe
-    creation
-    io
-    datatype
-    environment
+    >>> 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"]})
+    >>> joined = pf.sql("SELECT df1.a, b, c FROM df1 JOIN df2 ON df1.a = 
df2.a")
+    >>> result = pf.sql(
+    ...     "SELECT * FROM src WHERE a > 1",
+    ...     auto_bind=False,
+    ...     src=df1,
+    ... )
+    >>> pf.sql("SELECT a, b FROM df1").filter(pf.col("a") > 1).to_pandas()
+
+.. currentmodule:: pyflink.dataframe
+
+.. autosummary::
+    :toctree: api/
+
+    sql
diff --git a/flink-python/pyflink/dataframe/__init__.py 
b/flink-python/pyflink/dataframe/__init__.py
index 88e7ca2aad5..326b139efc9 100644
--- a/flink-python/pyflink/dataframe/__init__.py
+++ b/flink-python/pyflink/dataframe/__init__.py
@@ -54,6 +54,7 @@ from pyflink.dataframe.context import (
 from pyflink.dataframe.dataframe import DataFrame, GroupedDataFrame, col, lit
 from pyflink.dataframe.datatype import DataType
 from pyflink.dataframe.io import read_generic
+from pyflink.dataframe.sql import sql
 
 __all__ = [
     "DataFrame",
@@ -68,6 +69,7 @@ __all__ = [
     "from_table",
     "range",
     "read_generic",
+    "sql",
     "set_table_environment",
     "get_table_environment",
     "get_or_create_table_environment",
diff --git a/flink-python/pyflink/dataframe/sql.py 
b/flink-python/pyflink/dataframe/sql.py
new file mode 100644
index 00000000000..f244a81897b
--- /dev/null
+++ b/flink-python/pyflink/dataframe/sql.py
@@ -0,0 +1,266 @@
+################################################################################
+#  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, Dict, List
+
+from py4j.protocol import Py4JJavaError
+
+from pyflink.dataframe.context import get_or_create_table_environment
+from pyflink.dataframe.dataframe import DataFrame
+from pyflink.table import Table, TableEnvironment
+from pyflink.util.api_stability_decorators import PublicEvolving
+from pyflink.util.java_utils import is_instance_of
+
+__all__ = ["sql"]
+
+_LOG = logging.getLogger(__name__)
+
+
+@PublicEvolving()
+def sql(query: str, *, auto_bind: bool = True, **bindings: DataFrame) -> 
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.
+
+    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.
+
+    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.
+
+    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. The resolved
+    environment is used only for this call and never replaces the global one.
+
+    :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.
+    :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`.
+
+    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,
+        ... )
+        >>> # 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")
+    auto_bindings: Dict[str, DataFrame] = {}
+    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] = []
+    try:
+        _register_bindings(t_env, bindings, auto_bindings, registered)
+        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
+                )
+
+
+def _resolve_table_environment(
+    explicit: Dict[str, Any], 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.
+    """
+    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()}
+    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
+        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"
+        )
+    return get_or_create_table_environment()
+
+
+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 under it can succeed. This is 
the same
+    validation :meth:`TableEnvironment.create_temporary_view` applies to its 
path, 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_bindings(
+    t_env: TableEnvironment,
+    explicit: Dict[str, DataFrame],
+    auto: Dict[str, DataFrame],
+    registered: List[str],
+) -> None:
+    """
+    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`).
+    """
+    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())
+
+    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:
+            t_env.create_temporary_view(name, value.to_table())
+        except Exception as e:
+            _warn_skipped(name, f"registration failed: {e}")
+            continue
+        registered.append(name)
+
+
+def _warn_skipped(name: str, reason: str) -> None:
+    warnings.warn(
+        f"sql() auto-bind skipped '{name}': {reason}.",
+        UserWarning,
+    )
diff --git a/flink-python/pyflink/dataframe/tests/test_sql.py 
b/flink-python/pyflink/dataframe/tests/test_sql.py
new file mode 100644
index 00000000000..bed0eecae5b
--- /dev/null
+++ b/flink-python/pyflink/dataframe/tests/test_sql.py
@@ -0,0 +1,370 @@
+################################################################################
+#  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 unittest
+
+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.testing.test_case_utils import PyFlinkDataFrameUTTestCase
+
+
+class SqlValidationTests(unittest.TestCase):
+    def setUp(self):
+        previous_environment = pf.get_table_environment()
+        self.addCleanup(pf.set_table_environment, previous_environment)
+        pf.set_table_environment(None)
+
+    def test_query_must_be_a_string_checked_before_environment_creation(self):
+        with self.assertRaisesRegex(TypeError, "query must be a string"):
+            pf.sql(42)
+
+        self.assertIsNone(pf.get_table_environment())
+
+
+class SqlTests(PyFlinkDataFrameUTTestCase):
+    def test_non_query_statements_are_rejected(self):
+        self.t_env.execute_sql(
+            "CREATE TABLE sink (a BIGINT) WITH ('connector' = 'blackhole')"
+        )
+        self.addCleanup(self.t_env.execute_sql, "DROP TABLE sink")
+
+        for statement in [
+            "INSERT INTO sink VALUES (1)",
+            "CREATE TABLE t (a INT)",
+            "DROP TABLE t",
+            "EXPLAIN SELECT 1",
+        ]:
+            with self.subTest(statement=statement):
+                with self.assertRaisesRegex(
+                    ValueError, "only supports queries that return a result"
+                ):
+                    pf.sql(statement)
+
+    def test_unparsable_statements_surface_the_java_error(self):
+        for statement, error in [
+            ("", "SQL parse failed"),
+            ("-- only a comment", "only single statement supported"),
+            ("SELECT 1; SELECT 2", "only single statement supported"),
+        ]:
+            with self.subTest(statement=statement):
+                with self.assertRaisesRegex(Py4JJavaError, error):
+                    pf.sql(statement)
+
+    def test_auto_bind_joins_dataframes_by_variable_name(self):
+        df1 = pf.from_dict({"a": [1, 2, 3], "b": ["x", "y", "z"]})  # noqa: 
F841
+        df2 = pf.from_dict({"a": [1, 2, 3], "c": ["p", "q", "r"]})  # noqa: 
F841
+
+        joined = pf.sql(
+            "SELECT df1.a, b, c FROM df1 JOIN df2 ON df1.a = df2.a"
+        )
+
+        self.assertEqual(
+            sorted(joined.collect(), key=lambda row: row[0]),
+            [Row(1, "x", "p"), Row(2, "y", "q"), Row(3, "z", "r")],
+        )
+
+    def test_select_variants_are_accepted(self):
+        df = pf.from_dict({"a": [1, 2]})  # noqa: F841
+
+        for query in [
+            "SELECT a FROM df",
+            "-- leading comment\nSELECT a FROM df",
+            "/* block comment */ SELECT a FROM df",
+            "WITH cte AS (SELECT a FROM df) SELECT a FROM cte",
+            "(SELECT a FROM df)",
+        ]:
+            with self.subTest(query=query):
+                self.assertEqual(
+                    sorted(pf.sql(query).collect(), key=lambda row: row[0]),
+                    [Row(1), Row(2)],
+                )
+
+        self.assertEqual(pf.sql("VALUES (1)").collect(), [Row(1)])
+
+    def test_explicit_bindings_with_auto_bind_disabled(self):
+        src = pf.from_dict({"a": [1, 2]})  # noqa: F841
+        other = pf.from_dict({"a": [10, 20]})
+
+        result = pf.sql(
+            "SELECT a FROM src WHERE a > 1", auto_bind=False, src=other
+        )
+
+        self.assertEqual(
+            sorted(result.collect(), key=lambda row: row[0]),
+            [Row(10), Row(20)],
+        )
+
+    def test_auto_bind_disabled_ignores_caller_variables(self):
+        df = pf.from_dict({"a": [1]})  # noqa: F841
+
+        with self.assertRaisesRegex(Py4JJavaError, "Object 'df' not found"):
+            pf.sql("SELECT a FROM df", auto_bind=False)
+
+        self.assertNotIn("df", self.t_env.list_temporary_views())
+
+    def test_explicit_bindings_take_precedence_over_auto_bind(self):
+        df = pf.from_dict({"a": [1]})  # noqa: F841
+        other = pf.from_dict({"a": [42]})
+
+        result = pf.sql("SELECT a FROM df", df=other)
+
+        self.assertEqual(result.collect(), [Row(42)])
+
+    def test_auto_bind_finds_module_level_globals(self):
+        globals()["global_test_df"] = pf.from_dict({"a": [7]})
+        self.addCleanup(globals().pop, "global_test_df", None)
+
+        result = pf.sql("SELECT a FROM global_test_df")
+
+        self.assertEqual(result.collect(), [Row(7)])
+
+    def test_auto_bind_locals_take_precedence_over_globals(self):
+        globals()["shadow_df"] = pf.from_dict({"a": [1]})
+        self.addCleanup(globals().pop, "shadow_df", None)
+        shadow_df = pf.from_dict({"a": [2]})  # noqa: F841
+
+        self.assertEqual(pf.sql("SELECT a FROM shadow_df").collect(), [Row(2)])
+
+    def test_auto_bind_warns_and_skips_on_collision_with_existing_view(self):
+        self.t_env.create_temporary_view(
+            "df", pf.from_dict({"a": [100]}).to_table()
+        )
+        self.addCleanup(self.t_env.drop_temporary_view, "df")
+        df = pf.from_dict({"a": [1]})  # noqa: F841
+
+        with self.assertWarnsRegex(UserWarning, "skipped 'df'"):
+            result = pf.sql("SELECT a FROM df")
+
+        # The pre-existing view wins and survives the call.
+        self.assertEqual(result.collect(), [Row(100)])
+        self.assertIn("df", self.t_env.list_temporary_views())
+
+    def test_explicit_binding_collision_with_temporary_view_raises(self):
+        self.t_env.create_temporary_view(
+            "src", pf.from_dict({"a": [100]}).to_table()
+        )
+        self.addCleanup(self.t_env.drop_temporary_view, "src")
+
+        with self.assertRaisesRegex(ValueError, "'src'.*already exists"):
+            pf.sql(
+                "SELECT a FROM src",
+                auto_bind=False,
+                src=pf.from_dict({"a": [1]}),
+            )
+
+    def 
test_partial_registrations_are_dropped_when_a_later_binding_fails(self):
+        self.t_env.create_temporary_view(
+            "taken", pf.from_dict({"a": [100]}).to_table()
+        )
+        self.addCleanup(self.t_env.drop_temporary_view, "taken")
+
+        with self.assertRaisesRegex(ValueError, "'taken'.*already exists"):
+            pf.sql(
+                "SELECT a FROM fresh",
+                auto_bind=False,
+                fresh=pf.from_dict({"a": [1]}),
+                taken=pf.from_dict({"a": [2]}),
+            )
+
+        # The binding registered before the failure is cleaned up.
+        self.assertNotIn("fresh", self.t_env.list_temporary_views())
+        self.assertIn("taken", self.t_env.list_temporary_views())
+
+    def test_explicit_binding_shadows_permanent_table(self):
+        self.t_env.execute_sql(
+            "CREATE TABLE perm (a BIGINT) "
+            "WITH ('connector' = 'datagen', 'number-of-rows' = '1')"
+        )
+        self.addCleanup(self.t_env.execute_sql, "DROP TABLE perm")
+
+        result = pf.sql(
+            "SELECT a FROM perm",
+            auto_bind=False,
+            perm=pf.from_dict({"a": [42]}),
+        )
+
+        self.assertEqual(result.collect(), [Row(42)])
+        # The permanent table is intact after the call.
+        self.assertIn("perm", self.t_env.list_tables())
+        self.assertNotIn("perm", self.t_env.list_temporary_views())
+
+    def test_auto_bind_warns_and_skips_on_collision_with_permanent_table(self):
+        self.t_env.execute_sql(
+            "CREATE TABLE perm (a BIGINT) WITH ("
+            "'connector' = 'datagen', 'fields.a.kind' = 'sequence', "
+            "'fields.a.start' = '100', 'fields.a.end' = '100')"
+        )
+        self.addCleanup(self.t_env.execute_sql, "DROP TABLE perm")
+        perm = pf.from_dict({"a": [1]})  # noqa: F841
+
+        with self.assertWarnsRegex(UserWarning, "skipped 'perm'"):
+            result = pf.sql("SELECT a FROM perm")
+
+        # The permanent table wins and is never shadowed.
+        self.assertEqual(result.collect(), [Row(100)])
+        self.assertNotIn("perm", self.t_env.list_temporary_views())
+
+    def test_auto_bind_skips_invalid_sql_identifiers_with_warning(self):
+        globals()["my df"] = pf.from_dict({"a": [1]})
+        self.addCleanup(globals().pop, "my df", None)
+        df = pf.from_dict({"a": [2]})  # noqa: F841
+
+        with self.assertWarnsRegex(
+            UserWarning, "skipped 'my df'.*not a valid SQL identifier"
+        ):
+            result = pf.sql("SELECT a FROM df")
+
+        self.assertEqual(result.collect(), [Row(2)])
+
+    def test_auto_bind_supports_unicode_identifiers(self):
+        globals()["dfé"] = pf.from_dict({"a": [1]})
+        self.addCleanup(globals().pop, "dfé", None)
+
+        self.assertEqual(pf.sql("SELECT a FROM dfé").collect(), [Row(1)])
+
+    def test_auto_bind_supports_keyword_names_via_quoting(self):
+        order = pf.from_dict({"a": [1]})  # noqa: F841
+
+        self.assertEqual(pf.sql("SELECT a FROM `order`").collect(), [Row(1)])
+
+    def test_bindings_are_dropped_after_success(self):
+        df = pf.from_dict({"a": [1]})  # noqa: F841
+
+        pf.sql("SELECT a FROM df")
+
+        self.assertNotIn("df", self.t_env.list_temporary_views())
+
+    def test_bindings_are_dropped_after_failure(self):
+        df = pf.from_dict({"a": [1]})  # noqa: F841
+
+        with self.assertRaises(Py4JJavaError):
+            pf.sql("SELECT nonexistent_column FROM df")
+
+        self.assertNotIn("df", self.t_env.list_temporary_views())
+
+    def test_result_composes_with_dataframe_api(self):
+        df1 = pf.from_dict({"a": [1, 2, 3], "b": ["x", "y", "z"]})  # noqa: 
F841
+
+        result = (
+            pf.sql("SELECT a, b FROM df1")
+            .filter(pf.col("a") > 1)
+            .to_pandas()
+        )
+
+        self.assertEqual(sorted(result["a"].tolist()), [2, 3])
+
+    def test_explicit_binding_of_unsupported_type_raises(self):
+        with self.assertRaisesRegex(TypeError, "'x' must be a DataFrame"):
+            pf.sql("SELECT * FROM x", auto_bind=False, x=42)
+
+    def test_explicit_binding_of_raw_table_raises(self):
+        table = pf.from_dict({"a": [1]}).to_table()
+
+        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())
+
+        with self.assertRaisesRegex(TypeError, "'add_one' must be a 
DataFrame"):
+            pf.sql("SELECT add_one(a) FROM df", auto_bind=False, 
add_one=add_one)
+
+    def test_auto_bind_ignores_udfs(self):
+        df = pf.from_dict({"a": [1]})  # noqa: F841
+        add_one = udf(lambda i: i + 1, result_type=DataTypes.BIGINT())  # 
noqa: F841
+
+        with self.assertRaisesRegex(Py4JJavaError, "No match found for 
function"):
+            pf.sql("SELECT add_one(a) FROM df")
+
+    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"]))
+
+        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)]
+        )
+        # The environment is resolved per call; the global one is untouched.
+        self.assertIs(pf.get_table_environment(), self.t_env)
+        self.assertNotIn("src", other_env.list_temporary_views())
+
+    def test_explicit_bindings_from_different_environments_raise(self):
+        other_env = 
TableEnvironment.create(EnvironmentSettings.in_batch_mode())
+        foreign = pf.DataFrame(other_env.from_elements([(1,)], ["a"]))
+        local = pf.from_dict({"b": [2]})
+
+        with self.assertRaisesRegex(ValueError, "different TableEnvironments"):
+            pf.sql(
+                "SELECT * FROM one JOIN two ON TRUE",
+                auto_bind=False,
+                one=foreign,
+                two=local,
+            )
+
+        self.assertNotIn("one", other_env.list_temporary_views())
+        self.assertNotIn("two", self.t_env.list_temporary_views())
+
+    def test_auto_bound_dataframes_sharing_an_environment_resolve_it(self):
+        other_env = 
TableEnvironment.create(EnvironmentSettings.in_batch_mode())
+        remote_df = pf.DataFrame(other_env.from_elements([(1,)], ["a"]))  # 
noqa: F841
+
+        self.assertEqual(pf.sql("SELECT a FROM remote_df").collect(), [Row(1)])
+        self.assertIs(pf.get_table_environment(), self.t_env)
+
+    def test_environment_resolved_from_bindings_does_not_become_global(self):
+        pf.set_table_environment(None)
+        self.addCleanup(pf.set_table_environment, self.t_env)
+        other_env = 
TableEnvironment.create(EnvironmentSettings.in_batch_mode())
+        source = pf.DataFrame(other_env.from_elements([(1,)], ["a"]))
+
+        self.assertEqual(
+            pf.sql("SELECT a FROM src", auto_bind=False, src=source).collect(),
+            [Row(1)],
+        )
+        self.assertIsNone(pf.get_table_environment())
+
+    def test_explicit_binding_with_invalid_sql_identifier_raises(self):
+        df = pf.from_dict({"a": [1]})
+
+        with self.assertRaisesRegex(
+            ValueError, "'my df'.*not a valid SQL identifier"
+        ):
+            pf.sql("SELECT a FROM `my df`", auto_bind=False, **{"my df": df})
+
+        self.assertNotIn("my df", self.t_env.list_temporary_views())
+
+    def test_auto_bound_dataframe_from_other_environment_raises(self):
+        other_env = 
TableEnvironment.create(EnvironmentSettings.in_batch_mode())
+        foreign = pf.DataFrame(other_env.from_elements([(1,)], ["a"]))  # 
noqa: F841
+        df = pf.from_dict({"a": [2]})  # noqa: F841
+
+        with self.assertRaisesRegex(
+            ValueError, "auto-bound DataFrames belong to different 
TableEnvironments"
+        ):
+            pf.sql("SELECT a FROM df")
+
+
+if __name__ == "__main__":
+    unittest.main()

Reply via email to