This is an automated email from the ASF dual-hosted git repository.

vincbeck pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 5a7a4bc19b4 Migrate exasol provider to pyexasol 2.x and remove the <2 
cap (#72119)
5a7a4bc19b4 is described below

commit 5a7a4bc19b4aed071e36341d62736115de2f2956
Author: Joe Bergin <[email protected]>
AuthorDate: Thu Aug 27 11:07:35 2026 -0500

    Migrate exasol provider to pyexasol 2.x and remove the <2 cap (#72119)
---
 providers/exasol/README.rst                        |  2 +-
 providers/exasol/docs/index.rst                    |  2 +-
 providers/exasol/pyproject.toml                    |  4 +-
 .../src/airflow/providers/exasol/hooks/exasol.py   | 64 +++++++++++++++++-----
 .../exasol/tests/unit/exasol/hooks/test_exasol.py  | 25 ++++++++-
 5 files changed, 78 insertions(+), 19 deletions(-)

diff --git a/providers/exasol/README.rst b/providers/exasol/README.rst
index 70aba90425d..0699754302e 100644
--- a/providers/exasol/README.rst
+++ b/providers/exasol/README.rst
@@ -56,7 +56,7 @@ PIP package                                 Version required
 ``apache-airflow``                          ``>=2.11.0``
 ``apache-airflow-providers-common-compat``  ``>=1.12.0``
 ``apache-airflow-providers-common-sql``     ``>=1.32.0``
-``pyexasol``                                ``>=0.26.0,<2``
+``pyexasol``                                ``>=0.26.0``
 ``pandas``                                  ``>=2.1.2; python_version < 
"3.13"``
 ``pandas``                                  ``>=2.2.3; python_version >= 
"3.13" and python_version < "3.14"``
 ``pandas``                                  ``>=2.3.3; python_version >= 
"3.14"``
diff --git a/providers/exasol/docs/index.rst b/providers/exasol/docs/index.rst
index d361cc38f9d..06a8e89cccd 100644
--- a/providers/exasol/docs/index.rst
+++ b/providers/exasol/docs/index.rst
@@ -101,7 +101,7 @@ PIP package                                 Version required
 ``apache-airflow``                          ``>=2.11.0``
 ``apache-airflow-providers-common-compat``  ``>=1.12.0``
 ``apache-airflow-providers-common-sql``     ``>=1.32.0``
-``pyexasol``                                ``>=0.26.0,<2``
+``pyexasol``                                ``>=0.26.0``
 ``pandas``                                  ``>=2.1.2; python_version < 
"3.13"``
 ``pandas``                                  ``>=2.2.3; python_version >= 
"3.13" and python_version < "3.14"``
 ``pandas``                                  ``>=2.3.3; python_version >= 
"3.14"``
diff --git a/providers/exasol/pyproject.toml b/providers/exasol/pyproject.toml
index 7878be0064a..39baebb06b2 100644
--- a/providers/exasol/pyproject.toml
+++ b/providers/exasol/pyproject.toml
@@ -62,9 +62,7 @@ dependencies = [
     "apache-airflow>=2.11.0",
     "apache-airflow-providers-common-compat>=1.12.0",
     "apache-airflow-providers-common-sql>=1.32.0",
-    # Capped to 1.x: pyexasol 2.x ships stricter types that break the exasol 
hook.
-    # Remove the cap after migrating; tracked at 
https://github.com/apache/airflow/issues/69123
-    "pyexasol>=0.26.0,<2",
+    "pyexasol>=0.26.0",
     'pandas>=2.1.2; python_version <"3.13"',
     'pandas>=2.2.3; python_version >="3.13" and python_version <"3.14"',
     'pandas>=2.3.3; python_version >="3.14"',
diff --git a/providers/exasol/src/airflow/providers/exasol/hooks/exasol.py 
b/providers/exasol/src/airflow/providers/exasol/hooks/exasol.py
index 476c5259ba5..5682b4731c2 100644
--- a/providers/exasol/src/airflow/providers/exasol/hooks/exasol.py
+++ b/providers/exasol/src/airflow/providers/exasol/hooks/exasol.py
@@ -42,6 +42,39 @@ if TYPE_CHECKING:
 T = TypeVar("T")
 
 
+def _to_query_params(parameters: Iterable | Mapping[str, Any] | None) -> dict 
| None:
+    """
+    Adapt DB-API parameters to what pyexasol accepts.
+
+    pyexasol substitutes named placeholders only, so ``query_params`` has to 
be a
+    mapping (or ``None``). Positional sequences never reached the driver 
intact,
+    so reject them with an actionable message instead of a driver-internal 
error.
+    """
+    if parameters is None:
+        return None
+    if isinstance(parameters, Mapping):
+        return dict(parameters)
+    raise TypeError(
+        f"Exasol supports named query parameters only, got 
{type(parameters).__name__}. "
+        "Pass a mapping such as {'name': value} and reference it as {name} in 
the statement."
+    )
+
+
+def _to_single_statement(sql: str | list[str]) -> str:
+    """
+    Return the single statement ``ExaConnection.execute`` accepts.
+
+    :class:`~airflow.providers.common.sql.hooks.sql.DbApiHook` allows a list of
+    statements, but ``execute`` runs exactly one, so a list never worked here.
+    """
+    if isinstance(sql, str):
+        return sql
+    raise TypeError(
+        f"Exasol executes a single statement here, got a list of {len(sql)}. "
+        "Use ExasolHook.run() to execute several statements."
+    )
+
+
 class ExasolHook(DbApiHook):
     """
     Interact with Exasol.
@@ -68,20 +101,19 @@ class ExasolHook(DbApiHook):
         self._sqlalchemy_scheme = sqlalchemy_scheme
 
     def get_conn(self) -> ExaConnection:
-        conn = self.get_connection(self.get_conn_id())
+        airflow_conn = self.get_connection(self.get_conn_id())
         conn_args = {
-            "dsn": f"{conn.host}:{conn.port}",
-            "user": conn.login,
-            "password": conn.password,
-            "schema": self.schema or conn.schema,
+            "dsn": f"{airflow_conn.host}:{airflow_conn.port}",
+            "user": airflow_conn.login,
+            "password": airflow_conn.password,
+            "schema": self.schema or airflow_conn.schema,
         }
-        # check for parameters in conn.extra
-        for arg_name, arg_val in conn.extra_dejson.items():
+        # check for parameters in airflow_conn.extra
+        for arg_name, arg_val in airflow_conn.extra_dejson.items():
             if arg_name in ["compression", "encryption", "json_lib", 
"client_name"]:
                 conn_args[arg_name] = arg_val
 
-        conn = pyexasol.connect(**conn_args)
-        return conn
+        return pyexasol.connect(**conn_args)
 
     @property
     def sqlalchemy_scheme(self) -> str:
@@ -145,7 +177,7 @@ class ExasolHook(DbApiHook):
         ``pyexasol.ExaConnection.export_to_pandas``.
         """
         with closing(self.get_conn()) as conn:
-            df = conn.export_to_pandas(sql, query_params=parameters, **kwargs)
+            df = conn.export_to_pandas(sql, 
query_params=_to_query_params(parameters), **kwargs)
             return df
 
     @deprecated(
@@ -188,7 +220,10 @@ class ExasolHook(DbApiHook):
             sql statements to execute
         :param parameters: The parameters to render the SQL query with.
         """
-        with closing(self.get_conn()) as conn, closing(conn.execute(sql, 
parameters)) as cur:
+        with (
+            closing(self.get_conn()) as conn,
+            closing(conn.execute(_to_single_statement(sql), 
_to_query_params(parameters))) as cur,
+        ):
             send_sql_hook_lineage(
                 context=self,
                 sql=sql,
@@ -205,7 +240,10 @@ class ExasolHook(DbApiHook):
             sql statements to execute
         :param parameters: The parameters to render the SQL query with.
         """
-        with closing(self.get_conn()) as conn, closing(conn.execute(sql, 
parameters)) as cur:
+        with (
+            closing(self.get_conn()) as conn,
+            closing(conn.execute(_to_single_statement(sql), 
_to_query_params(parameters))) as cur,
+        ):
             send_sql_hook_lineage(
                 context=self,
                 sql=sql,
@@ -334,7 +372,7 @@ class ExasolHook(DbApiHook):
             results = []
             for sql_statement in sql_list:
                 self.log.info("Running statement: %s, parameters: %s", 
sql_statement, parameters)
-                with closing(conn.execute(sql_statement, parameters)) as 
exa_statement:
+                with closing(conn.execute(sql_statement, 
_to_query_params(parameters))) as exa_statement:
                     if handler is not None:
                         result = 
self._make_common_data_structure(handler(exa_statement))
 
diff --git a/providers/exasol/tests/unit/exasol/hooks/test_exasol.py 
b/providers/exasol/tests/unit/exasol/hooks/test_exasol.py
index 098a0ff3292..7923b043064 100644
--- a/providers/exasol/tests/unit/exasol/hooks/test_exasol.py
+++ b/providers/exasol/tests/unit/exasol/hooks/test_exasol.py
@@ -18,6 +18,7 @@
 from __future__ import annotations
 
 import json
+from types import MappingProxyType
 from unittest import mock
 
 import pytest
@@ -194,12 +195,34 @@ class TestExasolHook:
 
     def test_run_with_parameters(self):
         sql = "SQL"
-        parameters = ("param1", "param2")
+        parameters = {"param1": "val1", "param2": "val2"}
         self.db_hook.run(sql, autocommit=True, parameters=parameters)
         self.conn.set_autocommit.assert_called_once_with(True)
         self.conn.execute.assert_called_once_with(sql, parameters)
         self.conn.commit.assert_not_called()
 
+    def test_run_normalizes_mapping_parameters(self):
+        # pyexasol 2.x types ``query_params`` as ``dict``, so any other mapping
+        # is copied into one before it reaches the driver.
+        self.db_hook.run("SQL", parameters=MappingProxyType({"param1": 
"val1"}))
+        passed = self.conn.execute.call_args.args[1]
+        assert passed == {"param1": "val1"}
+        assert type(passed) is dict
+
+    def test_run_rejects_positional_parameters(self):
+        # pyexasol substitutes named placeholders via ``**query_params``, so a
+        # sequence never reached the driver intact.
+        with pytest.raises(TypeError, match="named query parameters only"):
+            self.db_hook.run("SQL", parameters=("param1", "param2"))
+
+    def test_get_records_rejects_statement_list(self):
+        with pytest.raises(TypeError, match="single statement"):
+            self.db_hook.get_records(["SQL1", "SQL2"])
+
+    def test_get_first_rejects_statement_list(self):
+        with pytest.raises(TypeError, match="single statement"):
+            self.db_hook.get_first(["SQL1", "SQL2"])
+
     def test_run_multi_queries(self):
         sql = ["SQL1", "SQL2"]
         self.db_hook.run(sql, autocommit=True)

Reply via email to