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