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

potiuk 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 6c364f7e29b Raise errors from all statements of multi-statement SQL in 
VerticaHook (#75967)
6c364f7e29b is described below

commit 6c364f7e29b6a5efacec95b9e7598129512257e9
Author: Ianislava Chalbysheva <[email protected]>
AuthorDate: Mon Nov 9 22:14:09 2026 +0700

    Raise errors from all statements of multi-statement SQL in VerticaHook 
(#75967)
    
    * Raise errors from all statements of multi-statement SQL in VerticaHook
    
    vertica-python only raises the error of a statement other than the first
    one in a multi-statement query once its result set is reached with
    cursor.nextset(). VerticaHook only drained the remaining result sets when
    the handler was fetch_all_handler and the first statement returned rows.
    With no handler (e.g. SQLExecuteQueryOperator with do_xcom_push=False),
    fetch_one_handler, a custom handler, or a first statement without result
    description (DDL), a failing statement was silently ignored, the
    transaction was committed and the task succeeded.
    
    Drain all result sets after every statement, whatever the handler is.
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
    
    * Keep description of the consumed result set after draining
    
    nextset() overwrites cursor.description with the columns of each next
    result set, while DbApiHook.run reads it after the handler returns, so
    hook.descriptions described the last result set instead of the rows
    returned by the handler. Restore the description after draining, and keep
    hook.descriptions empty when no handler is given, as before.
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
    
    * Read every result set with fetchone() when draining
    
    nextset() skips the rest of the current result set, including an error
    that arrives after the set has started returning rows (e.g.
    "SELECT 1; SELECT a/b FROM t" failing on the second row of t), so such an
    error was still swallowed; vertica-python's own test_nextset_with_error_2
    asserts that behaviour. On vertica-python 1.3.0-1.3.4 skipping does not
    stop at ReadyForQuery and can hang. Read each result set with fetchone(),
    which raises on the error, before moving to the next one.
    
    Also wrap every handler the same way (vertica_fetch_all_handler is kept
    as a public name only), update the class docstring, and mock two result
    sets for the "split_statements=False" case of test_vertica_run_queries so
    it goes through the drain.
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
    
    ---------
    
    Co-authored-by: Claude Opus 5.5 <[email protected]>
---
 .../src/airflow/providers/vertica/hooks/vertica.py |  81 ++++++++++----
 .../tests/unit/vertica/hooks/test_vertica.py       | 122 ++++++++++++++++++++-
 .../tests/unit/vertica/hooks/test_vertica_sql.py   |  19 +++-
 3 files changed, 194 insertions(+), 28 deletions(-)

diff --git a/providers/vertica/src/airflow/providers/vertica/hooks/vertica.py 
b/providers/vertica/src/airflow/providers/vertica/hooks/vertica.py
index 84634ff26bf..1913555bd32 100644
--- a/providers/vertica/src/airflow/providers/vertica/hooks/vertica.py
+++ b/providers/vertica/src/airflow/providers/vertica/hooks/vertica.py
@@ -16,6 +16,7 @@
 # under the License.
 from __future__ import annotations
 
+import functools
 from collections.abc import Callable, Iterable, Mapping
 from typing import TYPE_CHECKING, Any, overload
 
@@ -29,29 +30,54 @@ if TYPE_CHECKING:
     from sqlalchemy.engine import URL
 
 
-def vertica_fetch_all_handler(cursor) -> list[tuple] | None:
+def _drain_result_sets(cursor) -> None:
+    """
+    Consume all remaining rows and result sets of the cursor.
+
+    vertica-python streams results: an error raised by a statement other than 
the first one in a
+    multi-statement query is only reported once the client reads up to it. 
``nextset()`` alone is
+    not enough: it skips the rest of the current result set, including an 
error that arrives after
+    the set has started returning rows (e.g. ``SELECT a/b FROM t`` failing on 
its second row), and
+    on vertica-python < 1.3.5 skipping can hang instead. So every result set 
is read row by row
+    with ``fetchone()``, which raises on such an error, before moving to the 
next one.
+
+    ``nextset()`` also overwrites ``cursor.description`` with the columns of 
each next result set,
+    while ``DbApiHook.run`` reads it after the handler returns. The 
description of the result set
+    the handler consumed is restored, so that ``hook.descriptions`` matches 
the returned rows.
+
+    See https://github.com/apache/airflow/issues/32993 and
+    https://github.com/vertica/vertica-python/issues/197.
     """
-    Replace the default DbApiHook fetch_all_handler in order to fix this issue 
https://github.com/apache/airflow/issues/32993.
+    description = cursor.description
+    while True:
+        while cursor.fetchone() is not None:
+            pass
+        if not cursor.nextset():
+            break
+    cursor.description = description
+
 
-    Returned value will not change after the initial call of 
fetch_all_handler, all the remaining code is here
-    only to make vertica client throws error.
-    With Vertica, if you run the following sql (with split_statements set to 
false):
+def _with_result_set_drain(handler: Callable[[Any], Any]) -> Callable[[Any], 
Any]:
+    """Wrap a cursor handler so that all result sets are consumed after the 
handler has run."""
 
-    INSERT INTO MyTable (Key, Label) values (1, 'test 1');
-    INSERT INTO MyTable (Key, Label) values (1, 'test 2');
-    INSERT INTO MyTable (Key, Label) values (3, 'test 3');
+    @functools.wraps(handler)
+    def wrapper(cursor):
+        result = handler(cursor)
+        _drain_result_sets(cursor)
+        return result
 
-    each insert will have its own result set and if you don't try to fetch 
data of those result sets
-    you won't detect error on the second insert.
+    return wrapper
+
+
+def vertica_fetch_all_handler(cursor) -> list[tuple] | None:
+    """
+    Return all rows of the current result set, then consume the remaining 
result sets.
+
+    Kept for backward compatibility: ``VerticaHook.run`` now drains the result 
sets for every
+    handler, so passing ``fetch_all_handler`` is enough. See 
https://github.com/apache/airflow/issues/32993.
     """
     result = fetch_all_handler(cursor)
-    # loop on all statement result sets to get errors
-    if cursor.description is not None:
-        while cursor.nextset():
-            if cursor.description is not None:
-                row = cursor.fetchone()
-                while row:
-                    row = cursor.fetchone()
+    _drain_result_sets(cursor)
     return result
 
 
@@ -59,7 +85,8 @@ class VerticaHook(DbApiHook):
     """
     Interact with Vertica.
 
-    This hook use a customized version of default fetch_all_handler named 
vertica_fetch_all_handler.
+    ``run`` consumes all rows and result sets after each executed statement, 
whatever the handler is,
+    so that an error raised by any statement of a multi-statement query is not 
silently ignored.
     """
 
     conn_name_attr = "vertica_conn_id"
@@ -205,8 +232,18 @@ class VerticaHook(DbApiHook):
         """
         Overwrite the common sql run.
 
-        Will automatically replace fetch_all_handler by 
vertica_fetch_all_handler.
+        vertica-python only reports an error raised by a statement once its 
result set is reached, so
+        every result set is consumed after each executed statement, whatever 
the handler is (including
+        when no handler is passed). Otherwise a failing statement in a 
multi-statement query could be
+        silently ignored.
         """
-        if handler == fetch_all_handler:
-            handler = vertica_fetch_all_handler
-        return DbApiHook.run(self, sql, autocommit, parameters, handler, 
split_statements, return_last)
+        if handler is None:
+            DbApiHook.run(
+                self, sql, autocommit, parameters, _drain_result_sets, 
split_statements, return_last
+            )
+            # Without a handler, DbApiHook.run does not collect descriptions; 
keep that behaviour.
+            self.descriptions = []
+            return None
+        return DbApiHook.run(
+            self, sql, autocommit, parameters, 
_with_result_set_drain(handler), split_statements, return_last
+        )
diff --git a/providers/vertica/tests/unit/vertica/hooks/test_vertica.py 
b/providers/vertica/tests/unit/vertica/hooks/test_vertica.py
index 8fc260f1d85..2e14150eb93 100644
--- a/providers/vertica/tests/unit/vertica/hooks/test_vertica.py
+++ b/providers/vertica/tests/unit/vertica/hooks/test_vertica.py
@@ -21,8 +21,11 @@ import json
 from unittest import mock
 from unittest.mock import patch
 
+import pytest
+
 from airflow.models import Connection
-from airflow.providers.vertica.hooks.vertica import VerticaHook
+from airflow.providers.common.sql.hooks.handlers import fetch_all_handler, 
fetch_one_handler
+from airflow.providers.vertica.hooks.vertica import VerticaHook, 
vertica_fetch_all_handler
 
 
 class TestVerticaHookConn:
@@ -128,6 +131,8 @@ class TestVerticaHook:
     def setup_method(self):
         self.cur = mock.MagicMock(rowcount=0)
         self.cur.nextset.side_effect = [None]
+        # The hook drains the cursor with fetchone() until it returns None.
+        self.cur.fetchone.return_value = None
         self.conn = mock.MagicMock()
         self.conn.cursor.return_value = self.cur
         conn = self.conn
@@ -152,7 +157,7 @@ class TestVerticaHook:
     def test_get_first_record(self):
         statement = "SQL"
         result_sets = [("row1",), ("row2",)]
-        self.cur.fetchone.return_value = result_sets[0]
+        self.cur.fetchone.side_effect = [result_sets[0], None]
 
         assert result_sets[0] == self.db_hook.get_first(statement)
         self.conn.close.assert_called_once_with()
@@ -202,6 +207,8 @@ class TestVerticaHook:
 class TestVerticaHookLineage:
     def setup_method(self):
         self.cur = mock.MagicMock(rowcount=0)
+        self.cur.nextset.return_value = False
+        self.cur.fetchone.return_value = None
         self.conn = mock.MagicMock()
         self.conn.cursor.return_value = self.cur
         conn = self.conn
@@ -274,3 +281,114 @@ class TestVerticaHookLineage:
         assert call_kw["context"] is self.db_hook
         assert call_kw["sql"] == sql
         assert call_kw["sql_parameters"] == parameters
+
+
+class TestVerticaHookResultSetDrain:
+    """
+    vertica-python only raises the error of a non-first statement once the 
client reads up to it.
+
+    The mocks follow the driver: ``fetchone()`` returns rows of the current 
result set, then
+    ``None``, and raises if the server error arrives in the middle of the set; 
``nextset()`` moves to
+    the next result set and raises if the next "set" is an error.
+    """
+
+    def setup_method(self):
+        self.cur = mock.MagicMock(rowcount=0, description=None)
+        self.cur.fetchone.return_value = None
+        self.conn = mock.MagicMock()
+        self.conn.cursor.return_value = self.cur
+        conn = self.conn
+
+        class UnitTestVerticaHook(VerticaHook):
+            conn_name_attr = "test_conn_id"
+
+            def get_conn(self):
+                return conn
+
+        self.db_hook = UnitTestVerticaHook()
+
+    @pytest.mark.parametrize(
+        "handler",
+        [None, fetch_all_handler, fetch_one_handler, lambda cur: "custom"],
+        ids=["no_handler", "fetch_all", "fetch_one", "custom"],
+    )
+    def test_error_in_later_statement_is_raised(self, handler):
+        self.cur.nextset.side_effect = [True, RuntimeError("Duplicate key 
values")]
+        with pytest.raises(RuntimeError, match="Duplicate key values"):
+            self.db_hook.run("INSERT INTO t VALUES (1); INSERT INTO t VALUES 
(1);", handler=handler)
+
+    @pytest.mark.parametrize(
+        "handler",
+        [None, fetch_all_handler, fetch_one_handler, lambda cur: "custom"],
+        ids=["no_handler", "fetch_all", "fetch_one", "custom"],
+    )
+    def test_error_after_rows_of_later_statement_is_raised(self, handler):
+        # SELECT 1; SELECT a/b FROM t; -- the second statement returns a row, 
then fails on the next
+        # one. nextset() would skip that error, only fetchone() raises it.
+        self.cur.fetchall.return_value = [(1,)]
+        self.cur.fetchone.side_effect = [None, (4,), RuntimeError("Division by 
zero")]
+        self.cur.nextset.side_effect = [True, False]
+        with pytest.raises(RuntimeError, match="Division by zero"):
+            self.db_hook.run("SELECT 1; SELECT a/b FROM t;", handler=handler)
+
+    def test_error_in_rest_of_consumed_result_set_is_raised(self):
+        # fetch_one_handler reads one row of the only statement; its next row 
fails.
+        self.cur.fetchone.side_effect = [(4,), RuntimeError("Division by 
zero")]
+        self.cur.nextset.return_value = False
+        with pytest.raises(RuntimeError, match="Division by zero"):
+            self.db_hook.run("SELECT a/b FROM t;", handler=fetch_one_handler)
+
+    def test_all_result_sets_are_consumed(self):
+        self.cur.nextset.side_effect = [True, True, False]
+        assert self.db_hook.run("CREATE TABLE t (a INT); INSERT INTO t VALUES 
(1); SELECT 1;") is None
+        assert self.cur.nextset.call_count == 3
+
+    def test_handler_result_is_returned_after_drain(self):
+        self.cur.nextset.side_effect = [True, False]
+        self.cur.description = [("?column?", None, None, None, None, None, 
None)]
+        self.cur.fetchone.side_effect = [(1,), None, (2,), None]
+        assert self.db_hook.run("SELECT 1; SELECT 2;", 
handler=fetch_one_handler) == (1,)
+        assert self.cur.nextset.call_count == 2
+
+    def test_each_split_statement_is_drained(self):
+        self.cur.nextset.side_effect = [False, False]
+        self.db_hook.run("SELECT 1; SELECT 2;", split_statements=True)
+        assert self.cur.execute.call_count == 2
+        assert self.cur.nextset.call_count == 2
+
+    def test_vertica_fetch_all_handler_drains(self):
+        self.cur.fetchall.return_value = [(1,)]
+        self.cur.nextset.side_effect = [True, RuntimeError("Division by zero")]
+        with pytest.raises(RuntimeError, match="Division by zero"):
+            vertica_fetch_all_handler(self.cur)
+
+    @pytest.mark.parametrize(
+        "handler",
+        [fetch_all_handler, fetch_one_handler, lambda cur: cur.fetchone()],
+        ids=["fetch_all", "fetch_one", "custom"],
+    )
+    def test_description_of_consumed_result_set_is_kept(self, handler):
+        first_description = [("a", None, None, None, None, None, None)]
+        last_description = [
+            ("b", None, None, None, None, None, None),
+            ("c", None, None, None, None, None, None),
+        ]
+        self.cur.description = first_description
+        self.cur.fetchone.side_effect = [(1,), None, (2, 3), None]
+        self.cur.fetchall.return_value = [(1,)]
+
+        def nextset():
+            if self.cur.description is first_description:
+                self.cur.description = last_description
+                return True
+            return False
+
+        self.cur.nextset.side_effect = nextset
+        self.db_hook.run("SELECT 1 AS a; SELECT 2 AS b, 3 AS c;", 
handler=handler)
+        assert self.db_hook.descriptions == [first_description]
+
+    def test_no_descriptions_without_handler(self):
+        self.cur.description = [("a", None, None, None, None, None, None)]
+        self.cur.nextset.side_effect = [False]
+        self.db_hook.run("SELECT 1 AS a;")
+        assert self.db_hook.descriptions == []
diff --git a/providers/vertica/tests/unit/vertica/hooks/test_vertica_sql.py 
b/providers/vertica/tests/unit/vertica/hooks/test_vertica_sql.py
index e95adb47989..3618d55baad 100644
--- a/providers/vertica/tests/unit/vertica/hooks/test_vertica_sql.py
+++ b/providers/vertica/tests/unit/vertica/hooks/test_vertica_sql.py
@@ -77,6 +77,8 @@ def mock_cursor(mock_get_conn):
     cursor.fetchall.return_value = [("1", "row1")]
     cursor.description = get_cursor_descriptions(["id", "value"])
     cursor.nextset.side_effect = [False]
+    # The hook drains each result set with fetchone() until it returns None.
+    cursor.fetchone.return_value = None
     mock_get_conn.return_value.cursor.return_value = cursor
     return cursor
 
@@ -149,10 +151,17 @@ def test_vertica_run_queries(
 ):
     if split_statements:
         mock_cursor.fetchall.side_effect = cursor_results
-        mock_cursor.nextset.side_effect = [True] * (len(cursor_results) - 1) + 
[False]
     else:
         mock_cursor.fetchall.return_value = cursor_results
-        mock_cursor.nextset.side_effect = lambda: False
+    # Like on a real Vertica, each executed SQL string produces one result set 
per statement, and
+    # the hook moves through all of them with nextset().
+    result_sets_per_execute = [
+        len([statement for statement in sql_call.split(";") if 
statement.strip()])
+        for sql_call in expected_calls
+    ]
+    mock_cursor.nextset.side_effect = [
+        has_next for count in result_sets_per_execute for has_next in [True] * 
(count - 1) + [False]
+    ]
 
     result = vertica_hook.run(
         sql,
@@ -163,13 +172,15 @@ def test_vertica_run_queries(
 
     expected_mock_calls = [mock.call(sql_call) for sql_call in expected_calls]
     mock_cursor.execute.assert_has_calls(expected_mock_calls)
+    assert mock_cursor.nextset.call_count == sum(result_sets_per_execute)
     assert [SerializableRow(*row) for row in result] == expected_result
 
 
 def test_run_with_multiple_statements(vertica_hook, mock_cursor):
     mock_cursor.fetchall.side_effect = [[(1,)], [(2,)]]
-
-    mock_cursor.nextset.side_effect = [True, False]
+    # Statements are split, so each execute() produces a single result set.
+    mock_cursor.nextset.side_effect = None
+    mock_cursor.nextset.return_value = False
 
     sql = "SELECT 1; SELECT 2;"
 

Reply via email to