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 c96984baf44 Add sqlglot drift tripwires for common.ai read-only SQL 
validation (#74487)
c96984baf44 is described below

commit c96984baf44f865177cf182c9a193495f8e868c4
Author: Wei Lee <[email protected]>
AuthorDate: Mon Oct 12 15:37:12 2026 +0900

    Add sqlglot drift tripwires for common.ai read-only SQL validation (#74487)
---
 .../unit/common/ai/utils/test_sql_validation.py    | 163 ++++++++++++++++++++-
 1 file changed, 156 insertions(+), 7 deletions(-)

diff --git 
a/providers/common/ai/tests/unit/common/ai/utils/test_sql_validation.py 
b/providers/common/ai/tests/unit/common/ai/utils/test_sql_validation.py
index d37f194d039..f21cecd41f2 100644
--- a/providers/common/ai/tests/unit/common/ai/utils/test_sql_validation.py
+++ b/providers/common/ai/tests/unit/common/ai/utils/test_sql_validation.py
@@ -20,6 +20,8 @@ import pytest
 from sqlglot import exp
 
 from airflow.providers.common.ai.utils.sql_validation import (
+    _DATA_MODIFYING_NODES,
+    DEFAULT_ALLOWED_TYPES,
     SQLSafetyError,
     collect_table_references,
     parse_sql,
@@ -211,8 +213,6 @@ class TestDataModifyingNodeDetection:
 
     def test_deep_scan_runs_with_explicit_default_types(self):
         """Deep scan should also block when DEFAULT_ALLOWED_TYPES is passed 
explicitly."""
-        from airflow.providers.common.ai.utils.sql_validation import 
DEFAULT_ALLOWED_TYPES
-
         with pytest.raises(SQLSafetyError, match="Data-modifying operation 
'Delete'"):
             validate_sql(
                 "WITH del AS (DELETE FROM users RETURNING *) SELECT * FROM 
del",
@@ -257,11 +257,6 @@ class TestReadOnlyMetadata:
         with pytest.raises(SQLSafetyError, match="Command.*not allowed"):
             validate_sql("SHOW TABLES", allow_read_only_metadata=True)
 
-    def test_explain_wrapped_write_still_blocked(self):
-        """EXPLAIN <write> parses to exp.Describe but the deep scan rejects 
the inner write."""
-        with pytest.raises(SQLSafetyError, match="Data-modifying operation 
'Delete'"):
-            validate_sql("EXPLAIN DELETE FROM users", dialect="mysql", 
allow_read_only_metadata=True)
-
     @pytest.mark.parametrize(
         ("sql", "node"),
         [
@@ -614,3 +609,157 @@ class TestCollectTableReferences:
         scan = collect_table_references(parse_sql(sql, dialect=dialect))
         assert scan.tables == []
         assert scan.unverifiable_sources
+
+
+# sqlglot has no upper bound in pyproject.toml, so an upstream rename or split 
of an exp class
+# would otherwise make the read-only check silently stop recognising a write.
+# The tests below turn that drift into a CI failure; if one fails after a 
sqlglot bump, review
+# _DATA_MODIFYING_NODES / DEFAULT_ALLOWED_TYPES in utils/sql_validation.py.
+_DIALECTS = ["postgres", "mysql", "snowflake", "bigquery", "sqlite"]
+
+_WRITE_STATEMENTS = {
+    "insert": "INSERT INTO t (a) VALUES (1)",
+    "update": "UPDATE t SET a = 1",
+    "delete": "DELETE FROM t",
+    "merge": "MERGE INTO t USING s ON t.id = s.id WHEN MATCHED THEN UPDATE SET 
a = 1",
+    "drop": "DROP TABLE t",
+    "truncate": "TRUNCATE TABLE t",
+    "create": "CREATE TABLE t (id INT)",
+    "create_as_select": "CREATE TABLE t AS SELECT 1",
+    "alter": "ALTER TABLE t ADD COLUMN c INT",
+    "copy": "COPY t FROM '/tmp/x'",
+    "grant": "GRANT SELECT ON t TO u",
+    "cte_delete": "WITH d AS (DELETE FROM t RETURNING *) SELECT * FROM d",
+    "cte_insert": "WITH d AS (INSERT INTO t VALUES (1) RETURNING *) SELECT * 
FROM d",
+    # THEN DELETE is required: with THEN UPDATE the nested Update is still 
rejected once Merge
+    # leaves the deny-list, which hides the gap.
+    "cte_merge": (
+        "WITH d AS (MERGE INTO t USING s ON t.id = s.id WHEN MATCHED THEN 
DELETE RETURNING *) SELECT * FROM d"
+    ),
+    "select_into": "SELECT * INTO n FROM t",
+}
+
+# Node each write must parse to, keyed like _WRITE_STATEMENTS. copy and grant 
are absent
+# on purpose: they are not in _DATA_MODIFYING_NODES and are only stopped by 
the top-level type check.
+_WRITE_NODE_TYPES = {
+    "insert": exp.Insert,
+    "update": exp.Update,
+    "delete": exp.Delete,
+    "merge": exp.Merge,
+    "drop": exp.Drop,
+    "truncate": exp.TruncateTable,
+    "create": exp.Create,
+    "create_as_select": exp.Create,
+    "alter": exp.Alter,
+    "cte_delete": exp.Delete,
+    "cte_insert": exp.Insert,
+    "cte_merge": exp.Merge,
+    "select_into": exp.Into,
+}
+
+_TOP_LEVEL_GUARD = "Statement type '{}' is not allowed"
+_DEEP_SCAN_GUARD = "Data-modifying operation '{}'"
+
+# Guard that must reject each write: the top-level type check, or the deep 
scan for writes
+# nested inside an otherwise-allowed SELECT.
+_WRITE_REJECTION = {
+    "insert": _TOP_LEVEL_GUARD.format("Insert"),
+    "update": _TOP_LEVEL_GUARD.format("Update"),
+    "delete": _TOP_LEVEL_GUARD.format("Delete"),
+    "merge": _TOP_LEVEL_GUARD.format("Merge"),
+    "drop": _TOP_LEVEL_GUARD.format("Drop"),
+    "truncate": _TOP_LEVEL_GUARD.format("TruncateTable"),
+    "create": _TOP_LEVEL_GUARD.format("Create"),
+    "create_as_select": _TOP_LEVEL_GUARD.format("Create"),
+    "alter": _TOP_LEVEL_GUARD.format("Alter"),
+    "copy": _TOP_LEVEL_GUARD.format("Copy"),
+    "grant": _TOP_LEVEL_GUARD.format("Grant"),
+    "cte_delete": _DEEP_SCAN_GUARD.format("Delete"),
+    "cte_insert": _DEEP_SCAN_GUARD.format("Insert"),
+    "cte_merge": _DEEP_SCAN_GUARD.format("Merge"),
+    "select_into": _DEEP_SCAN_GUARD.format("Into"),
+}
+
+_READ_STATEMENTS = {
+    "select": "SELECT a FROM t",
+    "cte_select": "WITH c AS (SELECT 1 AS a) SELECT * FROM c",
+    "union_all": "SELECT 1 UNION ALL SELECT 2",
+}
+
+
+class TestSqlglotDriftTripwires:
+    """Fail loudly if a sqlglot upgrade silently changes what the read-only 
check recognises."""
+
+    def test_allowed_types_never_overlap_data_modifying_nodes(self):
+        """A write type slipping into the allow-list would bypass the 
statement-type check."""
+        for allowed in DEFAULT_ALLOWED_TYPES:
+            for denied in _DATA_MODIFYING_NODES:
+                assert not issubclass(allowed, denied), f"{allowed.__name__} 
is a kind of {denied.__name__}"
+                assert not issubclass(denied, allowed), f"{denied.__name__} is 
a kind of {allowed.__name__}"
+
+    @pytest.mark.parametrize("dialect", _DIALECTS)
+    @pytest.mark.parametrize(
+        ("key", "node_type"), list(_WRITE_NODE_TYPES.items()), 
ids=list(_WRITE_NODE_TYPES)
+    )
+    def test_write_statement_parses_to_a_denied_node(self, key, node_type, 
dialect):
+        """sqlglot must keep producing the node class the deny-list names for 
each write."""
+        sql = _WRITE_STATEMENTS[key]
+        parsed = parse_sql(sql, dialect=dialect)
+        found = [n for stmt in parsed for n in stmt.walk() if isinstance(n, 
node_type)]
+        assert found, f"{sql!r} no longer parses to {node_type.__name__}; 
review _DATA_MODIFYING_NODES"
+        assert issubclass(node_type, _DATA_MODIFYING_NODES)
+
+    @pytest.mark.parametrize("allow_read_only_metadata", [False, True])
+    @pytest.mark.parametrize("dialect", _DIALECTS)
+    @pytest.mark.parametrize("key", list(_WRITE_STATEMENTS))
+    def test_write_and_ddl_statements_rejected_in_every_dialect(self, key, 
dialect, allow_read_only_metadata):
+        with pytest.raises(SQLSafetyError, match=_WRITE_REJECTION[key]):
+            validate_sql(
+                _WRITE_STATEMENTS[key], dialect=dialect, 
allow_read_only_metadata=allow_read_only_metadata
+            )
+
+    @pytest.mark.parametrize("allow_read_only_metadata", [False, True])
+    @pytest.mark.parametrize(
+        ("sql", "dialect", "node_name"),
+        [
+            ("DESCRIBE ALTER TABLE t ADD COLUMN c INT", "postgres", "Alter"),
+            ("DESCRIBE ALTER TABLE t ADD COLUMN c INT", "mysql", "Alter"),
+            ("EXPLAIN DELETE FROM t", "mysql", "Delete"),
+            ("DESCRIBE ALTER SYSTEM SET x=1", "postgres", "Command"),
+        ],
+        ids=[
+            "describe_alter_postgres",
+            "describe_alter_mysql",
+            "explain_delete_mysql",
+            "describe_alter_system_postgres",
+        ],
+    )
+    def test_wrapped_write_rejected_by_the_guard_that_applies(
+        self, sql, dialect, node_name, allow_read_only_metadata
+    ):
+        """Without the flag the wrapper itself is refused; with it, the 
wrapped write hits the deep scan."""
+        if allow_read_only_metadata:
+            match = _DEEP_SCAN_GUARD.format(node_name)
+        else:
+            match = _TOP_LEVEL_GUARD.format("Describe")
+        with pytest.raises(SQLSafetyError, match=match):
+            validate_sql(sql, dialect=dialect, 
allow_read_only_metadata=allow_read_only_metadata)
+
+    @pytest.mark.parametrize("dialect", _DIALECTS)
+    @pytest.mark.parametrize("sql", list(_READ_STATEMENTS.values()), 
ids=list(_READ_STATEMENTS))
+    def test_read_statements_accepted_in_every_dialect(self, sql, dialect):
+        assert validate_sql(sql, dialect=dialect)
+
+    @pytest.mark.parametrize("name", ["nextval", "pg_terminate_backend", 
"dblink_exec"])
+    def 
test_side_effect_function_is_flagged_only_when_allowed_tables_is_set(self, 
name):
+        """validate_sql accepts these calls; only collect_table_references 
(used when
+        allowed_tables is set) flags them, as exp.Anonymous functions with no 
exp.Table."""
+        sql = f"SELECT {name}('x')"
+        assert validate_sql(sql, dialect="postgres")
+        parsed = parse_sql(sql, dialect="postgres")
+        assert any(fn.name.casefold() == name for fn in 
parsed[0].find_all(exp.Anonymous)), (
+            f"{name}() is no longer parsed as exp.Anonymous; the 
allowed_functions guard would miss it"
+        )
+        scan = collect_table_references(parsed)
+        assert scan.unverifiable_sources
+        assert name in scan.unverifiable_sources[0]

Reply via email to