You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by pm...@apache.org on 2026/09/21 13:48:19 UTC

(superset) branch master updated: fix(sql): classify ATTACH/DETACH and MySQL REPLACE/RENAME/SET PASSWORD as mutating (#44072)

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

sha174n pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git


The following commit(s) were added to refs/heads/master by this push:
     new c11dc027969 fix(sql): classify ATTACH/DETACH and MySQL REPLACE/RENAME/SET PASSWORD as mutating (#44072)
c11dc027969 is described below

commit c11dc027969451567d7fe11a549e5baf241e9394
Author: Shaitan <10...@users.noreply.github.com>
AuthorDate: Mon Sep 21 14:48:12 2026 +0100

    fix(sql): classify ATTACH/DETACH and MySQL REPLACE/RENAME/SET PASSWORD as mutating (#44072)
    
    Co-authored-by: Claude Opus 4.8 <no...@anthropic.com>
---
 superset/sql/parse.py               | 14 ++++++++++++++
 tests/unit_tests/sql/parse_tests.py | 17 +++++++++++++++++
 2 files changed, 31 insertions(+)

diff --git a/superset/sql/parse.py b/superset/sql/parse.py
index c7951e85c9a..a3835f780a4 100644
--- a/superset/sql/parse.py
+++ b/superset/sql/parse.py
@@ -963,6 +963,11 @@ class SQLStatement(BaseSQLStatement[exp.Expression]):
             # MySQL LOAD DATA INFILE ingests server files into a table;
             # PostgreSQL LOAD '/path/lib.so' dlopens a shared library.
             "LOAD",
+            # MySQL REPLACE INTO is destructive DML and RENAME TABLE is DDL;
+            # both fall back to an opaque exp.Command with these heads (no
+            # structured node), and neither has a read-only form.
+            "REPLACE",
+            "RENAME",
             # NOTE: `SHOW` is intentionally NOT included. It is a read (mutates
             # nothing), so classifying it as mutating would be wrong for every
             # is_mutating()/has_mutation() consumer (the commit decision, the
@@ -993,6 +998,10 @@ class SQLStatement(BaseSQLStatement[exp.Expression]):
         {
             Dialects.POSTGRES,
             Dialects.STARROCKS,
+            # MySQL shares StarRocks' parser: ordinary `SET var = value` parses
+            # as exp.Set, so the opaque-Command fallback is reached only by the
+            # dangerous forms (SET PASSWORD FOR .../SET ROLE).
+            Dialects.MYSQL,
         }
     )
 
@@ -1278,6 +1287,11 @@ class SQLStatement(BaseSQLStatement[exp.Expression]):
             # fires for dialects where this instead falls back to
             # exp.Command.
             exp.Refresh,
+            # ATTACH/DETACH (SQLite) connect or disconnect a database file;
+            # ATTACH can bring a writable database into scope. Structured
+            # nodes on SQLite, so the exp.Command fallback never sees them.
+            exp.Attach,
+            exp.Detach,
         )
 
         if self._parsed.find(*mutating_nodes):
diff --git a/tests/unit_tests/sql/parse_tests.py b/tests/unit_tests/sql/parse_tests.py
index fdf2ba59ede..edf19bb8db3 100644
--- a/tests/unit_tests/sql/parse_tests.py
+++ b/tests/unit_tests/sql/parse_tests.py
@@ -2031,6 +2031,14 @@ def test_is_mutating(sql: str, engine: str, expected: bool) -> None:
         ("EXPLAIN ANALYZE )))", "postgresql"),
         # The flag need not be followed by whitespace.
         ("EXPLAIN ANALYZE(DELETE FROM t)", "postgresql"),
+        # SQLite ATTACH/DETACH and MySQL REPLACE INTO / RENAME TABLE /
+        # SET PASSWORD FOR fall past node-type matching (opaque command or
+        # dialect-specific structured nodes) and must be gated as mutating.
+        ("ATTACH DATABASE 'x.db' AS y", "sqlite"),
+        ("DETACH DATABASE y", "sqlite"),
+        ("REPLACE INTO t VALUES (1)", "mysql"),
+        ("RENAME TABLE a TO b", "mysql"),
+        ("SET PASSWORD FOR 'u'@'h' = 'p'", "mysql"),
     ],
 )
 def test_is_mutating_fails_closed_on_gate_blind_spots(sql: str, engine: str) -> None:
@@ -2042,6 +2050,15 @@ def test_is_mutating_fails_closed_on_gate_blind_spots(sql: str, engine: str) ->
     assert SQLStatement(sql, engine).is_mutating()
 
 
+@pytest.mark.parametrize("engine", ["mysql", "sqlite"])
+def test_is_mutating_replace_function_is_read(engine: str) -> None:
+    """The REPLACE() string function inside a SELECT is a read; only the
+    REPLACE INTO statement form is mutating."""
+    assert not SQLStatement(
+        "SELECT REPLACE(name, 'a', 'b') FROM t", engine
+    ).is_mutating()
+
+
 @pytest.mark.parametrize(
     "sql, expected",
     [