You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ru...@apache.org on 2026/08/06 13:28:23 UTC

(superset) branch master updated: fix(engine): update databend engine spec for dialect version >=0.4.6 (#28627)

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

rusackas 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 de93a19b3c7 fix(engine): update databend engine spec for dialect version >=0.4.6 (#28627)
de93a19b3c7 is described below

commit de93a19b3c78495df5c33f621ed2f1029bdf877a
Author: Pat Buxton <45...@users.noreply.github.com>
AuthorDate: Thu Aug 6 14:28:16 2026 +0100

    fix(engine): update databend engine spec for dialect version >=0.4.6 (#28627)
    
    Co-authored-by: Claude Opus 5 (1M context) <no...@anthropic.com>
---
 .../version-6.0.0/configuration/databases.mdx      |   4 +-
 superset/db_engine_specs/base.py                   |  13 +-
 superset/db_engine_specs/databend.py               |  89 ++++++--
 ...0-00_c4a1b8e2d739_databend_secure_to_sslmode.py | 163 +++++++++++++
 tests/unit_tests/db_engine_specs/test_base.py      | 101 +++++++-
 tests/unit_tests/db_engine_specs/test_databend.py  | 132 ++++++++++-
 .../migrations/test_databend_secure_to_sslmode.py  | 253 +++++++++++++++++++++
 7 files changed, 729 insertions(+), 26 deletions(-)

diff --git a/docs/user_docs_versioned_docs/version-6.0.0/configuration/databases.mdx b/docs/user_docs_versioned_docs/version-6.0.0/configuration/databases.mdx
index 6db8e8d7edd..22a5aea1968 100644
--- a/docs/user_docs_versioned_docs/version-6.0.0/configuration/databases.mdx
+++ b/docs/user_docs_versioned_docs/version-6.0.0/configuration/databases.mdx
@@ -423,7 +423,7 @@ echo "sqlalchemy-cratedb" >> ./docker/requirements-local.txt
 #### Databend
 
 The recommended connector library for Databend is [databend-sqlalchemy](https://pypi.org/project/databend-sqlalchemy/).
-Superset has been tested on `databend-sqlalchemy>=0.2.3`.
+Superset has been tested on `databend-sqlalchemy>=0.5.5`.
 
 The recommended connection string is:
 
@@ -434,7 +434,7 @@ databend://{username}:{password}@{host}:{port}/{database_name}
 Here's a connection string example of Superset connecting to a Databend database:
 
 ```
-databend://user:password@localhost:8000/default?secure=false
+databend://user:password@localhost:8000/default?sslmode=disable
 ```
 
 #### Databricks
diff --git a/superset/db_engine_specs/base.py b/superset/db_engine_specs/base.py
index 1a99c397d7a..945adc9634b 100644
--- a/superset/db_engine_specs/base.py
+++ b/superset/db_engine_specs/base.py
@@ -2876,6 +2876,11 @@ class BasicParametersMixin:
     # for Postgres this would be `{"sslmode": "verify-ca"}`, eg.
     encryption_parameters: dict[str, str] = {}
 
+    # query parameter to explicitly disable encryption, for drivers that do not
+    # treat the absence of `encryption_parameters` as an unencrypted connection
+    # for Databend this would be `{"sslmode": "disable"}`, eg.
+    encryption_disable_parameters: dict[str, str] = {}
+
     @classmethod
     def build_sqlalchemy_uri(  # pylint: disable=unused-argument
         cls,
@@ -2891,6 +2896,8 @@ class BasicParametersMixin:
                     "Unable to build a URL with encryption enabled"
                 )
             query.update(cls.encryption_parameters)
+        else:
+            query.update(cls.encryption_disable_parameters)
 
         return str(
             URL.create(
@@ -2909,10 +2916,14 @@ class BasicParametersMixin:
         cls, uri: str, encrypted_extra: dict[str, Any] | None = None
     ) -> BasicParametersType:
         url = make_url_safe(uri)
+        encryption_items = [
+            *cls.encryption_parameters.items(),
+            *cls.encryption_disable_parameters.items(),
+        ]
         query = {
             key: value
             for (key, value) in url.query.items()
-            if (key, value) not in cls.encryption_parameters.items()
+            if (key, value) not in encryption_items
         }
         encryption = all(
             item in url.query.items() for item in cls.encryption_parameters.items()
diff --git a/superset/db_engine_specs/databend.py b/superset/db_engine_specs/databend.py
index 9c11a1ce5fb..ecd8e21b486 100644
--- a/superset/db_engine_specs/databend.py
+++ b/superset/db_engine_specs/databend.py
@@ -195,10 +195,15 @@ class DatabendEngineSpec(BasicParametersMixin, DatabendBaseEngineSpec):
     supports_file_upload = False
 
     sqlalchemy_uri_placeholder = (
-        "databend://user:password@host[:port][/dbname][?secure=value&=value...]"
+        "databend://user:password@host[:port][/dbname][?sslmode=value&=value...]"
     )
     parameters_schema = DatabendParametersSchema()
-    encryption_parameters = {"secure": "true"}
+    encryption_parameters = {"sslmode": "require"}
+    encryption_disable_parameters = {"sslmode": "disable"}
+
+    # every ``sslmode`` the driver resolves to an https scheme; it accepts both
+    # spellings, so a hand-written ``sslmode=enable`` must not read as plaintext
+    encryption_sslmodes = frozenset({"require", "enable"})
 
     metadata = {
         "description": (
@@ -214,7 +219,7 @@ class DatabendEngineSpec(BasicParametersMixin, DatabendBaseEngineSpec):
         ],
         "pypi_packages": ["databend-sqlalchemy"],
         "connection_string": (
-            "databend://{username}:{password}@{host}:{port}/{database}?secure=true"
+            "databend://{username}:{password}@{host}:{port}/{database}?sslmode=require"
         ),
         "default_port": 443,
         "parameters": {
@@ -259,29 +264,73 @@ class DatabendEngineSpec(BasicParametersMixin, DatabendBaseEngineSpec):
 
     @classmethod
     def build_sqlalchemy_uri(
-        cls, parameters: BasicParametersType, *_args: dict[str, str] | None
+        cls,
+        parameters: BasicParametersType,
+        encrypted_extra: dict[str, str] | None = None,
     ) -> str:
-        url_params = parameters.copy()
-        if url_params.get("encryption"):
-            query = parameters.get("query", {}).copy()
-            query.update(cls.encryption_parameters)
-            url_params["query"] = query
-        if not url_params.get("database"):
-            url_params["database"] = "__default__"
-        url_params.pop("encryption", None)
-        return str(URL(f"{cls.engine}", **url_params))
+        """
+        Build a Databend URI, always stating the TLS mode explicitly.
+
+        The driver honours ``sslmode`` rather than inferring TLS from the port,
+        so an unencrypted connection needs ``sslmode=disable`` spelled out
+        rather than simply omitting the encryption parameters.
+        """
+        query = parameters.get("query", {}).copy()
+        query.update(
+            cls.encryption_parameters
+            if parameters.get("encryption")
+            else cls.encryption_disable_parameters
+        )
+
+        return str(
+            URL.create(
+                cls.engine,
+                username=parameters.get("username"),
+                password=parameters.get("password"),
+                host=parameters.get("host"),
+                port=parameters.get("port"),
+                database=parameters.get("database") or "__default__",
+                query=query,
+            )
+        )
+
+    @classmethod
+    def _encryption_from_tls_parameters(
+        cls, sslmode: str | None, secure: str | None
+    ) -> bool:
+        """
+        Resolve whether a connection is encrypted from either TLS spelling.
+
+        ``databend-py`` parsed the legacy ``secure`` value with ``asbool``, so
+        casing is not significant in either parameter.
+        """
+        if sslmode is not None:
+            return sslmode.lower() in cls.encryption_sslmodes
+        if secure is not None:
+            return secure.lower() == "true"
+        return False
 
     @classmethod
     def get_parameters_from_uri(
-        cls, uri: str, *_args: dict[str, Any] | None
+        cls,
+        uri: str,
+        encrypted_extra: dict[str, Any] | None = None,
     ) -> BasicParametersType:
+        """
+        Decompose a Databend URI into individual connection parameters.
+
+        The legacy ``secure`` parameter is still recognised so that connections
+        stored before the move to ``sslmode`` repopulate the form correctly. Its
+        values were parsed as booleans by the previous driver, so casing is not
+        significant in either parameter. Both are always removed, so a URI
+        carrying the legacy and the current spelling at once cannot leak one of
+        them back into the connection as a user-supplied extra parameter.
+        """
         url = make_url_safe(uri)
-        query = url.query
-        if "secure" in query:
-            encryption = url.query.get("secure") == "true"
-            query.pop("secure")
-        else:
-            encryption = False
+        query = dict(url.query)
+        encryption = cls._encryption_from_tls_parameters(
+            query.pop("sslmode", None), query.pop("secure", None)
+        )
         return BasicParametersType(
             username=url.username,
             password=url.password,
diff --git a/superset/migrations/versions/2026-08-06_00-00_c4a1b8e2d739_databend_secure_to_sslmode.py b/superset/migrations/versions/2026-08-06_00-00_c4a1b8e2d739_databend_secure_to_sslmode.py
new file mode 100644
index 00000000000..34508611170
--- /dev/null
+++ b/superset/migrations/versions/2026-08-06_00-00_c4a1b8e2d739_databend_secure_to_sslmode.py
@@ -0,0 +1,163 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""migrate Databend connections to an explicit sslmode
+
+``databend-sqlalchemy`` moved from the pure-Python ``databend-py`` client to a
+Rust core, and the two disagree about TLS in ways that silently break stored
+connections.
+
+``databend-py`` took ``secure``, defaulting to ``False``, and selected an
+``http`` scheme unless it was set. The Rust core takes ``sslmode`` and defaults
+the scheme to ``https``; an unrecognised parameter is not rejected but stored as
+a session variable. A Databend connection therefore keeps whatever ``secure``
+value it was saved with, has it quietly ignored, and switches to TLS against a
+server that may not speak it.
+
+Both affected shapes are rewritten to the parameter the driver now reads:
+
+* an explicit ``secure`` becomes the equivalent ``sslmode``. Values were parsed
+  as booleans by ``databend-py``, so ``secure=True`` counted as encrypted and
+  casing is not significant here either.
+* no TLS parameter at all becomes ``sslmode=disable``. These connections were
+  plaintext under ``databend-py``'s ``http`` default -- Superset only ever wrote
+  ``secure=true``, never ``secure=false`` -- so this preserves how they have
+  always behaved rather than downgrading them. Leaving them untouched would let
+  the new ``https`` default break exactly the connections this migration exists
+  to protect.
+
+The query string is edited one parameter at a time instead of being parsed and
+re-rendered through ``URL``. ``URL.render_as_string`` sorts the query keys and
+re-encodes every value, which would reorder and rewrite unrelated parameters on
+every row it touched; editing in place leaves everything but the TLS parameter
+byte-identical.
+
+``downgrade`` restores ``secure``, which is semantically but not textually exact:
+a row that had no TLS parameter before ``upgrade`` comes back as ``secure=false``
+rather than bare, and non-canonical casing is normalised. Both forms mean the
+same thing to ``databend-py``.
+
+Revision ID: c4a1b8e2d739
+Revises: 1a27941d5352
+Create Date: 2026-08-06 00:00:00.000000
+
+"""
+
+from alembic import op
+from sqlalchemy import Column, Integer, String
+from sqlalchemy.orm import declarative_base
+
+from superset import db
+from superset.migrations.shared.utils import paginated_update
+
+# revision identifiers, used by Alembic.
+revision = "c4a1b8e2d739"
+down_revision = "1a27941d5352"
+
+Base = declarative_base()
+
+_TLS_PARAMETERS = ("sslmode", "secure")
+
+# (parameter, lower-cased value) -> replacement parameter and value
+_TO_SSLMODE = {
+    ("secure", "true"): ("sslmode", "require"),
+    ("secure", "false"): ("sslmode", "disable"),
+}
+_TO_SECURE = {
+    ("sslmode", "require"): ("secure", "true"),
+    ("sslmode", "enable"): ("secure", "true"),
+    ("sslmode", "disable"): ("secure", "false"),
+}
+
+
+class Database(Base):  # type: ignore
+    __tablename__ = "dbs"
+
+    id = Column(Integer, primary_key=True)
+    sqlalchemy_uri = Column(String(1024), nullable=False)
+
+
+def _split_query(uri: str) -> tuple[str, list[str]]:
+    """
+    Separate a URI from its query parameters.
+
+    The delimiter is searched for after the credentials, which are not escaped
+    for ``?`` and would otherwise be mistaken for the start of the query.
+    """
+    start = uri.find("?", uri.rfind("@") + 1)
+    if start == -1:
+        return uri, []
+    return uri[:start], uri[start + 1 :].split("&")
+
+
+def _rewrite_query_parameters(
+    uri: str,
+    replacements: dict[tuple[str, str], tuple[str, str]],
+    default: tuple[str, str] | None = None,
+) -> str | None:
+    """
+    Swap known TLS parameters in a URI's query string, preserving the rest.
+
+    ``default`` is appended when the URI carries no TLS parameter at all.
+    Returns ``None`` when nothing matched, so callers can skip the write.
+    """
+    base, pairs = _split_query(uri)
+
+    changed = False
+    tls_parameter_seen = False
+    rewritten = []
+    for pair in pairs:
+        key, _, value = pair.partition("=")
+        tls_parameter_seen = tls_parameter_seen or key in _TLS_PARAMETERS
+        if replacement := replacements.get((key, value.lower())):
+            rewritten.append("=".join(replacement))
+            changed = True
+        else:
+            rewritten.append(pair)
+
+    if default and not tls_parameter_seen:
+        rewritten.append("=".join(default))
+        changed = True
+
+    if not changed:
+        return None
+    return f"{base}?{'&'.join(rewritten)}" if rewritten else base
+
+
+def _migrate(
+    replacements: dict[tuple[str, str], tuple[str, str]],
+    default: tuple[str, str] | None = None,
+) -> None:
+    bind = op.get_bind()
+    session = db.Session(bind=bind, future=True)
+
+    query = session.query(Database).filter(Database.sqlalchemy_uri.like("databend%"))
+    for database in paginated_update(query):
+        updated = _rewrite_query_parameters(
+            database.sqlalchemy_uri, replacements, default
+        )
+        if updated:
+            database.sqlalchemy_uri = updated
+
+    session.commit()
+
+
+def upgrade() -> None:
+    _migrate(_TO_SSLMODE, default=("sslmode", "disable"))
+
+
+def downgrade() -> None:
+    _migrate(_TO_SECURE)
diff --git a/tests/unit_tests/db_engine_specs/test_base.py b/tests/unit_tests/db_engine_specs/test_base.py
index 60e82b749a1..2b121853bc5 100644
--- a/tests/unit_tests/db_engine_specs/test_base.py
+++ b/tests/unit_tests/db_engine_specs/test_base.py
@@ -23,7 +23,7 @@ import json  # noqa: TID251
 import re
 from datetime import timedelta
 from textwrap import dedent
-from typing import Any
+from typing import Any, cast
 from urllib.parse import parse_qs, urlparse
 
 import pytest
@@ -33,7 +33,11 @@ from sqlalchemy.dialects import sqlite
 from sqlalchemy.engine.url import make_url, URL
 from sqlalchemy.sql import sqltypes
 
-from superset.db_engine_specs.base import BaseEngineSpec, convert_inspector_columns
+from superset.db_engine_specs.base import (
+    BaseEngineSpec,
+    BasicParametersType,
+    convert_inspector_columns,
+)
 from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
 from superset.exceptions import OAuth2RedirectError
 from superset.sql.parse import Table
@@ -1383,3 +1387,96 @@ def test_base_spec_public_information_includes_supports_offset() -> None:
 
     assert "supports_offset" in info
     assert info["supports_offset"] is True
+
+
+def _parameters(encryption: bool) -> BasicParametersType:
+    parameters: dict[str, Any] = {
+        "username": "user",
+        "password": "pwd",
+        "host": "localhost",
+        "port": 5432,
+        "database": "db",
+        "query": {},
+        "encryption": encryption,
+    }
+    return cast(BasicParametersType, parameters)
+
+
+def test_build_sqlalchemy_uri_omits_disable_parameters_by_default() -> None:
+    """
+    Specs that do not define ``encryption_disable_parameters`` must keep
+    emitting nothing at all when encryption is off.
+    """
+    from superset.db_engine_specs.base import BasicParametersMixin
+
+    class TestEngineSpec(BasicParametersMixin):
+        engine = "testdb"
+        encryption_parameters = {"sslmode": "require"}
+
+    uri = TestEngineSpec.build_sqlalchemy_uri(_parameters(encryption=False))
+
+    assert make_url(uri).query == {}
+
+
+@pytest.mark.parametrize(
+    "encryption,expected_query",
+    [
+        (True, {"sslmode": "require"}),
+        (False, {"sslmode": "disable"}),
+    ],
+)
+def test_build_sqlalchemy_uri_applies_disable_parameters(
+    encryption: bool, expected_query: dict[str, str]
+) -> None:
+    from superset.db_engine_specs.base import BasicParametersMixin
+
+    class TestEngineSpec(BasicParametersMixin):
+        engine = "testdb"
+        encryption_parameters = {"sslmode": "require"}
+        encryption_disable_parameters = {"sslmode": "disable"}
+
+    uri = TestEngineSpec.build_sqlalchemy_uri(_parameters(encryption=encryption))
+
+    assert dict(make_url(uri).query) == expected_query
+
+
+@pytest.mark.parametrize(
+    "uri,expected_encryption",
+    [
+        ("testdb://user:pwd@localhost:5432/db?sslmode=require", True),
+        ("testdb://user:pwd@localhost:5432/db?sslmode=disable", False),
+    ],
+)
+def test_get_parameters_from_uri_strips_both_parameter_sets(
+    uri: str, expected_encryption: bool
+) -> None:
+    """
+    Both sets share a key with differing values, so neither may leak into
+    ``query`` and reappear as a user-supplied extra parameter.
+    """
+    from superset.db_engine_specs.base import BasicParametersMixin
+
+    class TestEngineSpec(BasicParametersMixin):
+        engine = "testdb"
+        encryption_parameters = {"sslmode": "require"}
+        encryption_disable_parameters = {"sslmode": "disable"}
+
+    parameters = TestEngineSpec.get_parameters_from_uri(uri)
+
+    assert parameters["encryption"] is expected_encryption
+    assert parameters["query"] == {}
+
+
+def test_get_parameters_from_uri_keeps_unrelated_query_parameters() -> None:
+    from superset.db_engine_specs.base import BasicParametersMixin
+
+    class TestEngineSpec(BasicParametersMixin):
+        engine = "testdb"
+        encryption_parameters = {"sslmode": "require"}
+        encryption_disable_parameters = {"sslmode": "disable"}
+
+    parameters = TestEngineSpec.get_parameters_from_uri(
+        "testdb://user:pwd@localhost:5432/db?sslmode=disable&application_name=superset"
+    )
+
+    assert parameters["query"] == {"application_name": "superset"}
diff --git a/tests/unit_tests/db_engine_specs/test_databend.py b/tests/unit_tests/db_engine_specs/test_databend.py
index 96f9ba7a043..707e467fa79 100644
--- a/tests/unit_tests/db_engine_specs/test_databend.py
+++ b/tests/unit_tests/db_engine_specs/test_databend.py
@@ -16,10 +16,11 @@
 # under the License.
 
 from datetime import datetime
-from typing import Any, Optional
+from typing import Any, cast, Optional
 from unittest.mock import Mock
 
 import pytest
+from sqlalchemy.engine.url import make_url
 from sqlalchemy.types import (
     Boolean,
     Date,
@@ -32,6 +33,7 @@ from sqlalchemy.types import (
 )
 from urllib3.connection import HTTPConnection
 
+from superset.db_engine_specs.base import BasicParametersType
 from superset.utils.core import GenericDataType
 from tests.unit_tests.db_engine_specs.utils import (
     assert_column_spec,
@@ -176,3 +178,131 @@ def test_make_label_compatible(column_name: str, expected_result: str) -> None:
 
     label = spec.make_label_compatible(column_name)
     assert label == expected_result
+
+
+def _parameters(**overrides: Any) -> BasicParametersType:
+    parameters: dict[str, Any] = {
+        "username": "user",
+        "password": "pwd",
+        "host": "localhost",
+        "port": 443,
+        "database": "testdb",
+        "query": {},
+        "encryption": True,
+        **overrides,
+    }
+    return cast(BasicParametersType, parameters)
+
+
+@pytest.mark.parametrize(
+    "encryption,expected_sslmode",
+    [
+        (True, "require"),
+        (False, "disable"),
+    ],
+)
+def test_build_sqlalchemy_uri_always_states_sslmode(
+    encryption: bool, expected_sslmode: str
+) -> None:
+    """
+    The driver does not infer TLS from the port, so an unencrypted connection
+    needs ``sslmode=disable`` spelled out rather than the parameter omitted.
+    """
+    from superset.db_engine_specs.databend import DatabendEngineSpec
+
+    uri = DatabendEngineSpec.build_sqlalchemy_uri(_parameters(encryption=encryption))
+
+    assert make_url(uri).query["sslmode"] == expected_sslmode
+
+
+def test_build_sqlalchemy_uri_preserves_other_query_params() -> None:
+    from superset.db_engine_specs.databend import DatabendEngineSpec
+
+    uri = DatabendEngineSpec.build_sqlalchemy_uri(
+        _parameters(query={"warehouse": "wh1"})
+    )
+
+    query = make_url(uri).query
+    assert query["warehouse"] == "wh1"
+    assert query["sslmode"] == "require"
+
+
+def test_build_sqlalchemy_uri_substitutes_default_database() -> None:
+    from superset.db_engine_specs.databend import DatabendEngineSpec
+
+    uri = DatabendEngineSpec.build_sqlalchemy_uri(_parameters(database=""))
+
+    assert make_url(uri).database == "__default__"
+
+
+@pytest.mark.parametrize(
+    "uri,expected_encryption",
+    [
+        ("databend://user:pwd@localhost:443/db?sslmode=require", True),
+        ("databend://user:pwd@localhost:8000/db?sslmode=disable", False),
+        # the driver resolves both spellings to an https scheme
+        ("databend://user:pwd@localhost:443/db?sslmode=enable", True),
+        ("databend://user:pwd@localhost:443/db?sslmode=REQUIRE", True),
+        # legacy form, stored by Superset before the move to ``sslmode``
+        ("databend://user:pwd@localhost:443/db?secure=true", True),
+        ("databend://user:pwd@localhost:8000/db?secure=false", False),
+        # databend-py parsed the legacy value as a boolean, so casing is moot
+        ("databend://user:pwd@localhost:443/db?secure=True", True),
+        # the current spelling wins, and neither may survive into the form
+        ("databend://user:pwd@localhost:443/db?sslmode=require&secure=false", True),
+        ("databend://user:pwd@localhost:8000/db?sslmode=disable&secure=true", False),
+        ("databend://user:pwd@localhost:8000/db", False),
+    ],
+)
+def test_get_parameters_from_uri_encryption(
+    uri: str, expected_encryption: bool
+) -> None:
+    from superset.db_engine_specs.databend import DatabendEngineSpec
+
+    parameters = DatabendEngineSpec.get_parameters_from_uri(uri)
+
+    assert parameters["encryption"] is expected_encryption
+    assert "sslmode" not in parameters["query"]
+    assert "secure" not in parameters["query"]
+
+
+def test_get_parameters_from_uri_accepts_encrypted_extra_keyword() -> None:
+    """
+    ``Database.parameters`` passes ``encrypted_extra`` by keyword, and swallows
+    any exception into an empty dict, so a signature mismatch silently empties
+    the connection form.
+    """
+    from superset.db_engine_specs.databend import DatabendEngineSpec
+
+    parameters = DatabendEngineSpec.get_parameters_from_uri(
+        "databend://user:pwd@localhost:443/db?sslmode=require",
+        encrypted_extra={},
+    )
+
+    assert parameters["encryption"] is True
+
+
+def test_get_parameters_from_uri_restores_empty_database() -> None:
+    from superset.db_engine_specs.databend import DatabendEngineSpec
+
+    parameters = DatabendEngineSpec.get_parameters_from_uri(
+        "databend://user:pwd@localhost:443/__default__?sslmode=require"
+    )
+
+    assert parameters["database"] == ""
+
+
+@pytest.mark.parametrize("encryption", [True, False])
+def test_parameters_round_trip(encryption: bool) -> None:
+    from superset.db_engine_specs.databend import DatabendEngineSpec
+
+    uri = DatabendEngineSpec.build_sqlalchemy_uri(
+        _parameters(encryption=encryption, query={"warehouse": "wh1"})
+    )
+    parameters = DatabendEngineSpec.get_parameters_from_uri(uri)
+
+    assert parameters["encryption"] is encryption
+    assert parameters["database"] == "testdb"
+    assert parameters["host"] == "localhost"
+    assert parameters["port"] == 443
+    assert parameters["query"] == {"warehouse": "wh1"}
diff --git a/tests/unit_tests/migrations/test_databend_secure_to_sslmode.py b/tests/unit_tests/migrations/test_databend_secure_to_sslmode.py
new file mode 100644
index 00000000000..b283ea6e779
--- /dev/null
+++ b/tests/unit_tests/migrations/test_databend_secure_to_sslmode.py
@@ -0,0 +1,253 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Tests for migration ``c4a1b8e2d739_databend_secure_to_sslmode``.
+
+Covers the query-parameter rewrite helper, the full upgrade() path over a
+mixture of Databend and non-Databend connections, and the downgrade()
+round trip.
+"""
+
+from __future__ import annotations
+
+from importlib import import_module
+from unittest.mock import patch
+
+import pytest
+from sqlalchemy import create_engine
+from sqlalchemy.orm import Session
+
+migration = import_module(
+    "superset.migrations.versions."
+    "2026-08-06_00-00_c4a1b8e2d739_databend_secure_to_sslmode"
+)
+
+Database = migration.Database
+_rewrite_query_parameters = migration._rewrite_query_parameters
+_TO_SSLMODE = migration._TO_SSLMODE
+_TO_SECURE = migration._TO_SECURE
+_DEFAULT = ("sslmode", "disable")
+
+# Superset stores the password as this mask rather than the real credential
+MASK = "X" * 10
+
+
+@pytest.fixture
+def engine():
+    engine = create_engine("sqlite:///:memory:", future=True)
+    migration.Base.metadata.create_all(engine)
+    return engine
+
+
+def _run(migrate, conn) -> None:
+    session = Session(bind=conn, future=True)
+    with (
+        patch.object(migration, "op") as mock_op,
+        patch.object(migration, "db") as mock_db,
+    ):
+        mock_op.get_bind.return_value = conn
+        mock_db.Session.return_value = session
+        migrate()
+
+
+@pytest.mark.parametrize(
+    "uri,expected",
+    [
+        (
+            f"databend://user:{MASK}@host:8000/db?secure=false",
+            f"databend://user:{MASK}@host:8000/db?sslmode=disable",
+        ),
+        (
+            f"databend://user:{MASK}@host:443/db?secure=true",
+            f"databend://user:{MASK}@host:443/db?sslmode=require",
+        ),
+        # databend-py parsed the value as a boolean, so casing carried no meaning
+        (
+            f"databend://user:{MASK}@host:443/db?secure=True",
+            f"databend://user:{MASK}@host:443/db?sslmode=require",
+        ),
+        # unrelated parameters keep their position and encoding
+        (
+            f"databend://user:{MASK}@host:8000/db?warehouse=wh&secure=false&presign=on",
+            f"databend://user:{MASK}@host:8000/db?warehouse=wh&sslmode=disable&presign=on",
+        ),
+    ],
+)
+def test_rewrite_query_parameters_replaces_secure(uri: str, expected: str) -> None:
+    assert _rewrite_query_parameters(uri, _TO_SSLMODE, _DEFAULT) == expected
+
+
+@pytest.mark.parametrize(
+    "uri",
+    [
+        # already migrated
+        f"databend://user:{MASK}@host:8000/db?sslmode=disable",
+        f"databend://user:{MASK}@host:443/db?sslmode=require",
+        # an unrecognised value is left for a human rather than guessed at
+        f"databend://user:{MASK}@host:8000/db?secure=maybe",
+    ],
+)
+def test_rewrite_query_parameters_leaves_migrated_uris_alone(uri: str) -> None:
+    assert _rewrite_query_parameters(uri, _TO_SSLMODE, _DEFAULT) is None
+
+
+@pytest.mark.parametrize(
+    "uri,expected",
+    [
+        (
+            f"databend://user:{MASK}@host:8000/db",
+            f"databend://user:{MASK}@host:8000/db?sslmode=disable",
+        ),
+        (
+            f"databend://user:{MASK}@host:8000/db?warehouse=wh",
+            f"databend://user:{MASK}@host:8000/db?warehouse=wh&sslmode=disable",
+        ),
+    ],
+)
+def test_rewrite_query_parameters_pins_plaintext_default(
+    uri: str, expected: str
+) -> None:
+    """
+    A URI with no TLS parameter was plaintext under databend-py's http default,
+    so it needs sslmode=disable to keep behaving that way under the Rust core.
+    """
+    assert _rewrite_query_parameters(uri, _TO_SSLMODE, _DEFAULT) == expected
+
+
+def test_rewrite_query_parameters_ignores_question_mark_in_credentials() -> None:
+    """
+    The credentials are not escaped for ``?``, so the query delimiter has to be
+    located after them or the parameters are never found.
+    """
+    rewritten = _rewrite_query_parameters(
+        "databend://user:pa?ss@host:8000/db?secure=false", _TO_SSLMODE, _DEFAULT
+    )
+
+    assert rewritten == "databend://user:pa?ss@host:8000/db?sslmode=disable"
+
+
+def test_upgrade_rewrites_only_databend_connections(engine) -> None:
+    with Session(engine, future=True) as seed:
+        seed.add_all(
+            [
+                Database(
+                    id=1,
+                    sqlalchemy_uri=f"databend://user:{MASK}@host:8000/db?secure=false",
+                ),
+                Database(
+                    id=2,
+                    sqlalchemy_uri=f"databend://user:{MASK}@host:443/db?secure=true",
+                ),
+                # no TLS parameter: plaintext under the old client, so it must
+                # be pinned rather than left to the new https default
+                Database(id=3, sqlalchemy_uri=f"databend://user:{MASK}@host:8000/db"),
+                # a different engine that happens to use the same parameter
+                Database(
+                    id=4,
+                    sqlalchemy_uri=f"clickhousedb://user:{MASK}@host:8443/db?secure=true",
+                ),
+            ]
+        )
+        seed.commit()
+
+    with engine.begin() as conn:
+        _run(migration.upgrade, conn)
+
+    with Session(engine, future=True) as verify:
+        assert (
+            verify.get(Database, 1).sqlalchemy_uri
+            == f"databend://user:{MASK}@host:8000/db?sslmode=disable"
+        )
+        assert (
+            verify.get(Database, 2).sqlalchemy_uri
+            == f"databend://user:{MASK}@host:443/db?sslmode=require"
+        )
+        assert (
+            verify.get(Database, 3).sqlalchemy_uri
+            == f"databend://user:{MASK}@host:8000/db?sslmode=disable"
+        )
+        assert (
+            verify.get(Database, 4).sqlalchemy_uri
+            == f"clickhousedb://user:{MASK}@host:8443/db?secure=true"
+        )
+
+
+def test_upgrade_is_idempotent(engine) -> None:
+    with Session(engine, future=True) as seed:
+        seed.add(
+            Database(
+                id=1, sqlalchemy_uri=f"databend://user:{MASK}@host:8000/db?secure=false"
+            )
+        )
+        seed.commit()
+
+    for _ in range(2):
+        with engine.begin() as conn:
+            _run(migration.upgrade, conn)
+
+    with Session(engine, future=True) as verify:
+        assert (
+            verify.get(Database, 1).sqlalchemy_uri
+            == f"databend://user:{MASK}@host:8000/db?sslmode=disable"
+        )
+
+
+@pytest.mark.parametrize(
+    "uri,expected",
+    [
+        (
+            f"databend://user:{MASK}@host:8000/db?sslmode=disable",
+            f"databend://user:{MASK}@host:8000/db?secure=false",
+        ),
+        (
+            f"databend://user:{MASK}@host:443/db?sslmode=require",
+            f"databend://user:{MASK}@host:443/db?secure=true",
+        ),
+        # the driver treats enable as an alias of require
+        (
+            f"databend://user:{MASK}@host:443/db?sslmode=enable",
+            f"databend://user:{MASK}@host:443/db?secure=true",
+        ),
+    ],
+)
+def test_downgrade_restores_secure(uri: str, expected: str) -> None:
+    assert _rewrite_query_parameters(uri, _TO_SECURE) == expected
+
+
+def test_downgrade_does_not_add_a_parameter(engine) -> None:
+    """
+    Only upgrade pins a default; downgrade must leave a bare URI bare rather
+    than inventing a secure parameter Superset never wrote.
+    """
+    assert (
+        _rewrite_query_parameters(f"databend://user:{MASK}@host:8000/db", _TO_SECURE)
+        is None
+    )
+
+
+def test_round_trip_through_upgrade_and_downgrade(engine) -> None:
+    original = f"databend://user:{MASK}@host:8000/db?secure=false"
+    with Session(engine, future=True) as seed:
+        seed.add(Database(id=1, sqlalchemy_uri=original))
+        seed.commit()
+
+    with engine.begin() as conn:
+        _run(migration.upgrade, conn)
+    with engine.begin() as conn:
+        _run(migration.downgrade, conn)
+
+    with Session(engine, future=True) as verify:
+        assert verify.get(Database, 1).sqlalchemy_uri == original