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

Lee-W 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 7f945b45d07 Make `SQLToolset` `check_query` follow `allow_writes` like 
`query` (#74448)
7f945b45d07 is described below

commit 7f945b45d0798f1599083b8e8db785835cc28d3d
Author: Wei Lee <[email protected]>
AuthorDate: Fri Oct 9 15:34:10 2026 +0900

    Make `SQLToolset` `check_query` follow `allow_writes` like `query` (#74448)
---
 providers/common/ai/docs/toolsets/sql.rst          |  4 +-
 .../airflow/providers/common/ai/toolsets/sql.py    | 41 +++++++++-----
 .../ai/tests/unit/common/ai/toolsets/test_sql.py   | 62 ++++++++++++++++++++++
 3 files changed, 92 insertions(+), 15 deletions(-)

diff --git a/providers/common/ai/docs/toolsets/sql.rst 
b/providers/common/ai/docs/toolsets/sql.rst
index 51a8d602071..93b16520943 100644
--- a/providers/common/ai/docs/toolsets/sql.rst
+++ b/providers/common/ai/docs/toolsets/sql.rst
@@ -36,7 +36,9 @@ Curated toolset wrapping
      - Executes a SQL query and returns bounded, columnar JSON (see
        :ref:`bounded-query-results`)
    * - ``check_query``
-     - Validates SQL syntax without executing it
+     - Validates SQL syntax without executing it, with the same statement and
+       ``allowed_tables`` rules as ``query``, so it accepts writes only when
+       ``allow_writes=True``
 
 .. code-block:: python
 
diff --git 
a/providers/common/ai/src/airflow/providers/common/ai/toolsets/sql.py 
b/providers/common/ai/src/airflow/providers/common/ai/toolsets/sql.py
index a18149ea951..1311c9ee157 100644
--- a/providers/common/ai/src/airflow/providers/common/ai/toolsets/sql.py
+++ b/providers/common/ai/src/airflow/providers/common/ai/toolsets/sql.py
@@ -514,18 +514,7 @@ class SQLToolset(AirflowToolset):
     def _query(self, sql: str) -> str:
         hook = self._get_db_hook()
         dialect = self._dialect_for_validation()
-        statements: list[Any] | None = None
-        if not self._allow_writes:
-            # allow_read_only_metadata lets agents inspect schemas with 
DESCRIBE/SHOW
-            # (a common first move) instead of hard-failing; the deep scan 
still
-            # rejects any data-modifying statement, including EXPLAIN <write>.
-            statements = _validate_sql(sql, dialect=dialect, 
allow_read_only_metadata=True)
-        elif self._allowed_canonical is not None:
-            # Writes are allowed but tables are restricted: parse anyway so the
-            # allow-list still governs which tables a write may touch.
-            statements = _parse_sql(sql, dialect=dialect)
-        if statements is not None:
-            self._enforce_allowed_tables(statements)
+        self._validate_for_execution(sql, dialect=dialect, require_parse=False)
 
         # One row beyond the cap, so "there is more" is knowable without 
fetching the
         # rest. strip_sql_string mirrors what get_records did for the hooks 
that
@@ -546,6 +535,31 @@ class SQLToolset(AirflowToolset):
             total_rows=fetch.total_rows,
         )
 
+    def _validate_for_execution(self, sql: str, *, dialect: str | None, 
require_parse: bool) -> None:
+        """
+        Apply the checks ``query`` runs before executing ``sql``, raising when 
it would be refused.
+
+        ``check_query`` shares this so it never reports valid for a statement 
``query`` refuses.
+        ``require_parse`` makes ``check_query`` syntax-check writes too; 
``query`` leaves a write
+        unparsed unless ``allowed_tables`` needs the AST, so a statement 
sqlglot cannot parse
+        still reaches the database.
+        """
+        statements: list[Any] | None = None
+        if not self._allow_writes:
+            # allow_read_only_metadata lets agents inspect schemas with 
DESCRIBE/SHOW
+            # (a common first move) instead of hard-failing; the deep scan 
still
+            # rejects any data-modifying statement, including EXPLAIN <write>.
+            statements = _validate_sql(sql, dialect=dialect, 
allow_read_only_metadata=True)
+        elif require_parse or self._allowed_canonical is not None:
+            # Writes are allowed, so only parse: the allow-list, when set, 
still
+            # governs which tables a write may touch. Without one, ``query`` 
sends the
+            # string to the hook unparsed, multi-statement included, so don't 
reject it here.
+            statements = _parse_sql(
+                sql, dialect=dialect, 
allow_multiple_statements=self._allowed_canonical is None
+            )
+        if statements is not None:
+            self._enforce_allowed_tables(statements)
+
     def _check_query(self, sql: str) -> str:
         # Resolve the dialect best-effort: if the connection can't be reached 
we
         # still syntax-check dialect-agnostically rather than reporting 
invalid.
@@ -553,8 +567,7 @@ class SQLToolset(AirflowToolset):
         with suppress(Exception):
             dialect = self._dialect_for_validation()
         try:
-            statements = _validate_sql(sql, dialect=dialect, 
allow_read_only_metadata=True)
-            self._enforce_allowed_tables(statements)
+            self._validate_for_execution(sql, dialect=dialect, 
require_parse=True)
             return dumps_masked({"valid": True})
         except Exception as e:
             return dumps_masked({"valid": False, "error": str(e)})
diff --git a/providers/common/ai/tests/unit/common/ai/toolsets/test_sql.py 
b/providers/common/ai/tests/unit/common/ai/toolsets/test_sql.py
index 53a978cf7e8..dd16a1c1b17 100644
--- a/providers/common/ai/tests/unit/common/ai/toolsets/test_sql.py
+++ b/providers/common/ai/tests/unit/common/ai/toolsets/test_sql.py
@@ -548,6 +548,68 @@ class TestSQLToolsetQuery:
 
 
 class TestSQLToolsetCheckQuery:
+    @pytest.mark.parametrize(
+        ("allow_writes", "expected_valid"),
+        [(False, False), (True, True)],
+    )
+    def test_write_statement_validity_follows_allow_writes(self, allow_writes, 
expected_valid):
+        ts = SQLToolset("pg_default", allow_writes=allow_writes)
+        ts._hook = _make_mock_db_hook()
+
+        result = asyncio.run(
+            ts.call_tool(
+                "check_query",
+                {"sql": "INSERT INTO users VALUES (3, 'Eve')"},
+                ctx=MagicMock(),
+                tool=MagicMock(),
+            )
+        )
+        assert json.loads(result)["valid"] is expected_valid
+
+    def test_write_to_allowed_table_is_valid(self):
+        ts = SQLToolset("pg_default", allowed_tables=["orders"], 
allow_writes=True)
+        ts._hook = _make_mock_db_hook()
+
+        assert _run_check(ts, "INSERT INTO orders (id) VALUES (1)")["valid"] 
is True
+
+    def test_write_to_disallowed_table_is_invalid_and_names_the_table(self):
+        ts = SQLToolset("pg_default", allowed_tables=["orders"], 
allow_writes=True)
+        ts._hook = _make_mock_db_hook()
+
+        data = _run_check(ts, "INSERT INTO secret_salaries (id) VALUES (1)")
+
+        assert data["valid"] is False
+        assert "secret_salaries" in data["error"]
+
+    def 
test_malformed_write_is_invalid_for_check_query_but_still_reaches_the_hook_for_query(self):
+        """check_query syntax-checks writes; query without an allow-list 
leaves them unparsed."""
+        sql = "INSERT INTO users VALUES ("
+        ts = SQLToolset("pg_default", allow_writes=True)
+        ts._hook = _make_mock_db_hook(records=[], last_description=None)
+
+        assert _run_check(ts, sql)["valid"] is False
+
+        _run_query(ts, sql)
+        _assert_executed(ts._hook, sql)
+
+    @pytest.mark.parametrize(
+        ("toolset_kwargs", "expected_valid"),
+        [({}, True), ({"allowed_tables": ["users"]}, False)],
+    )
+    def test_multiple_statements_follow_query_when_writes_allowed(self, 
toolset_kwargs, expected_valid):
+        ts = SQLToolset("pg_default", allow_writes=True, **toolset_kwargs)
+        ts._hook = _make_mock_db_hook()
+
+        result = asyncio.run(
+            ts.call_tool(
+                "check_query",
+                {"sql": "INSERT INTO users VALUES (1, 'a'); DELETE FROM 
users"},
+                ctx=MagicMock(),
+                tool=MagicMock(),
+            )
+        )
+        assert json.loads(result)["valid"] is expected_valid
+
     def test_valid_select(self):
         ts = SQLToolset("pg_default")
         ts._hook = _make_mock_db_hook()

Reply via email to