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

aminghadersohi 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 4911dbe00b0 fix(doris): offer the connection form by matching the 
installed driver (#44736)
4911dbe00b0 is described below

commit 4911dbe00b0abebe59af196452887b5e5b875a1c
Author: Amin Ghadersohi <[email protected]>
AuthorDate: Thu Oct 1 16:28:53 2026 +1000

    fix(doris): offer the connection form by matching the installed driver 
(#44736)
---
 UPDATING.md                                    |  13 ++
 docs/src/data/databases.json                   |   1 +
 superset/db_engine_specs/doris.py              |  29 ++++-
 tests/unit_tests/db_engine_specs/test_doris.py | 160 ++++++++++++++++++++++++-
 4 files changed, 195 insertions(+), 8 deletions(-)

diff --git a/UPDATING.md b/UPDATING.md
index 56376a76778..aa87e37dfbe 100644
--- a/UPDATING.md
+++ b/UPDATING.md
@@ -24,6 +24,19 @@ assists people when migrating to a new version.
 
 ## Next
 
+### Apache Doris connection form and `DBS_AVAILABLE_DENYLIST`
+
+`DBS_AVAILABLE_DENYLIST` is matched against an engine spec's `default_driver`.
+The Doris engine spec's `default_driver` is `mysqldb` (the driver pydoris
+registers), so a deployment that hides Doris with `{"pydoris": {"pydoris"}}`
+sees it listed again; change the entry to `{"pydoris": {"mysqldb"}}`.
+
+The Doris connection form's SSL switch sets `ssl_mode=VERIFY_CA`. With MariaDB
+Connector/C this also verifies the server hostname, so the switch needs
+`ssl_ca=<path>` in Additional parameters and a Doris FE certificate matching 
the
+host. A Doris FE on its default self-signed certificate fails to connect once
+the switch is on.
+
 ### Empty MCP chart previews
 
 Saved Bubble and Histogram Vega-Lite previews with zero rows return an empty
diff --git a/docs/src/data/databases.json b/docs/src/data/databases.json
index c675370e60a..63748377220 100644
--- a/docs/src/data/databases.json
+++ b/docs/src/data/databases.json
@@ -686,6 +686,7 @@
         "pypi_packages": ["pydoris"],
         "connection_string": 
"doris://{username}:{password}@{host}:{port}/{catalog}.{database}",
         "default_port": 9030,
+        "notes": "The SSL switch sets ssl_mode=VERIFY_CA. With MariaDB 
Connector/C, which mysqlclient uses in the Superset image, this also verifies 
the server hostname. Add ssl_ca=<path to the CA certificate> in Additional 
parameters and use a Doris FE certificate that matches the host. A Doris FE on 
its default self-signed certificate fails to connect once the switch is on.",
         "parameters": {
           "username": "User name",
           "password": "Password",
diff --git a/superset/db_engine_specs/doris.py 
b/superset/db_engine_specs/doris.py
index e84471b3694..22307c78ed7 100644
--- a/superset/db_engine_specs/doris.py
+++ b/superset/db_engine_specs/doris.py
@@ -132,11 +132,21 @@ class DorisEngineSpec(MySQLEngineSpec):
     engine_aliases = {"doris"}
     engine_name = "Apache Doris"
     max_column_name_length = 64
-    default_driver = "pydoris"
+    # pydoris registers its dialect (a ``MySQLDialect_mysqldb`` subclass) as
+    # ``doris`` and ``pydoris``, so the installed driver is ``mysqldb``. The
+    # connection form is only offered when ``default_driver`` is installed.
+    default_driver = "mysqldb"
     sqlalchemy_uri_placeholder = (
         "doris://user:password@host:port/catalog.db[?key=value&key=value...]"
     )
-    # REQUIRED can fall back with MariaDB Connector/C; verification fails 
closed.
+    # mysqlclient receives URI query values as strings, and neither ``ssl=0`` 
nor
+    # ``ssl=1`` enables TLS. ``VERIFY_CA`` requires TLS rather than falling 
back to
+    # cleartext, as ``REQUIRED`` can with MariaDB Connector/C. With MariaDB
+    # Connector/C, mysqlclient maps ``VERIFY_CA`` to
+    # ``MYSQL_OPT_SSL_VERIFY_SERVER_CERT``, which also verifies the hostname: 
the
+    # switch needs ``ssl_ca=<path>`` in the connection's additional parameters 
and
+    # a server certificate matching the host. A Doris FE on its default
+    # self-signed certificate fails closed once the switch is on.
     encryption_parameters = {"ssl_mode": MYSQL_SSL_MODE_VERIFY_CA}
     supports_dynamic_schema = True
     supports_catalog = supports_dynamic_catalog = True
@@ -171,6 +181,14 @@ class DorisEngineSpec(MySQLEngineSpec):
             "doris://{username}:{password}@{host}:{port}/{catalog}.{database}"
         ),
         "default_port": 9030,
+        "notes": (
+            "The SSL switch sets ssl_mode=VERIFY_CA. With MariaDB Connector/C, 
"
+            "which mysqlclient uses in the Superset image, this also verifies 
the "
+            "server hostname. Add ssl_ca=<path to the CA certificate> in 
Additional "
+            "parameters and use a Doris FE certificate that matches the host. 
A "
+            "Doris FE on its default self-signed certificate fails to connect 
once "
+            "the switch is on."
+        ),
         "parameters": {
             "username": "User name",
             "password": "Password",
@@ -355,9 +373,10 @@ class DorisEngineSpec(MySQLEngineSpec):
             == MYSQL_SSL_MODE_VERIFY_IDENTITY
         ):
             uri = uri.update_query_dict({"ssl_mode": 
MYSQL_SSL_MODE_VERIFY_IDENTITY})
-        # ``engine+default_driver`` would be ``pydoris+pydoris``, which no
-        # SQLAlchemy entry point provides; ``doris`` is the dialect's scheme.
-        return 
uri.set(drivername="doris").render_as_string(hide_password=False)
+        # ``engine+default_driver`` would be ``pydoris+mysqldb``, which no
+        # SQLAlchemy entry point provides. ``pydoris`` is registered, and keeps
+        # the URL backend equal to ``engine`` so the edit modal finds the form.
+        return 
uri.set(drivername=cls.engine).render_as_string(hide_password=False)
 
     @classmethod
     def get_parameters_from_uri(
diff --git a/tests/unit_tests/db_engine_specs/test_doris.py 
b/tests/unit_tests/db_engine_specs/test_doris.py
index 25e63ed1254..a5b182c2630 100644
--- a/tests/unit_tests/db_engine_specs/test_doris.py
+++ b/tests/unit_tests/db_engine_specs/test_doris.py
@@ -15,6 +15,7 @@
 # specific language governing permissions and limitations
 # under the License.
 
+from collections.abc import Iterator
 from typing import Any, Optional
 from unittest.mock import MagicMock, Mock, patch
 
@@ -22,6 +23,7 @@ import pytest
 from pytest_mock import MockerFixture
 from sqlalchemy import JSON, types
 from sqlalchemy.engine.url import make_url
+from sqlalchemy.exc import NoSuchModuleError
 
 from superset.db_engine_specs.doris import (
     AggState,
@@ -282,6 +284,158 @@ def test_get_catalog_names(
     assert catalogs == expected_result
 
 
+def _install_pydoris(mocker: MockerFixture) -> None:
+    """
+    Make ``DorisEngineSpec`` the only engine spec, with the two entry points
+    pydoris ships installed.
+    """
+    from sqlalchemy.dialects.mysql.mysqldb import MySQLDialect_mysqldb
+
+    from superset.db_engine_specs.doris import DorisEngineSpec
+
+    class PyDorisDialect(MySQLDialect_mysqldb):
+        name = "pydoris"
+
+    def entry_point(name: str) -> Any:
+        ep = mocker.MagicMock()
+        ep.name = name
+        ep.value = "pydoris.sqlalchemy.dialect:DorisDialect"
+        ep.load.return_value = PyDorisDialect
+        return ep
+
+    mocker.patch(
+        "superset.db_engine_specs.load_engine_specs",
+        return_value=iter([DorisEngineSpec]),
+    )
+    mocker.patch(
+        "superset.db_engine_specs.entry_points",
+        side_effect=lambda group: (
+            [entry_point("doris"), entry_point("pydoris")]
+            if group == "sqlalchemy.dialects"
+            else []
+        ),
+    )
+
+
+def test_connection_form_default_driver_is_installed(mocker: MockerFixture) -> 
None:
+    """
+    The database picker offers the connection form only when ``default_driver``
+    is among the drivers installed for the engine. pydoris registers a
+    ``MySQLDialect_mysqldb`` subclass named ``pydoris`` under both the 
``doris``
+    and ``pydoris`` entry points, so its driver is ``mysqldb``.
+    """
+    from superset.db_engine_specs import get_available_engine_specs
+    from superset.db_engine_specs.doris import DorisEngineSpec
+
+    _install_pydoris(mocker)
+
+    drivers = get_available_engine_specs()[DorisEngineSpec]
+
+    assert drivers == {"mysqldb"}
+    assert DorisEngineSpec.default_driver in drivers
+
+
[email protected](
+    "app,hidden",
+    [
+        ({"DBS_AVAILABLE_DENYLIST": {"pydoris": {"mysqldb"}}}, True),
+        ({"DBS_AVAILABLE_DENYLIST": {"pydoris": {"pydoris"}}}, False),
+    ],
+    indirect=["app"],
+)
+def test_denylist_matches_the_default_driver(
+    mocker: MockerFixture, hidden: bool
+) -> None:
+    """
+    ``DBS_AVAILABLE_DENYLIST`` is matched against ``default_driver``, so Doris 
is
+    hidden with ``{"pydoris": {"mysqldb"}}``, not ``{"pydoris": {"pydoris"}}``.
+    """
+    from superset.db_engine_specs import get_available_engine_specs
+    from superset.db_engine_specs.doris import DorisEngineSpec
+
+    _install_pydoris(mocker)
+
+    assert (DorisEngineSpec not in get_available_engine_specs()) is hidden
+
+
[email protected]
+def pydoris_dialects() -> Iterator[None]:
+    """
+    Register the two entry points pydoris ships, so that a URI can be resolved
+    to a dialect the way :func:`sqlalchemy.create_engine` resolves one.
+    """
+    from sqlalchemy.dialects import registry
+
+    for name in ("doris", "pydoris"):
+        registry.register(
+            name, "sqlalchemy.dialects.mysql.mysqldb", "MySQLDialect_mysqldb"
+        )
+    yield
+    for name in ("doris", "pydoris"):
+        registry.impls.pop(name, None)
+
+
[email protected]("encryption", [False, True])
[email protected]("pydoris_dialects")
+def test_build_sqlalchemy_uri_uses_the_pydoris_scheme(encryption: bool) -> 
None:
+    """
+    A URI built from the connection form must name a registered dialect:
+    ``pydoris+mysqldb`` (``engine+default_driver``) is not one, ``pydoris`` is.
+    Its backend must also equal ``engine``, which the edit modal matches 
against
+    to find the connection form of a saved database.
+    """
+    from sqlalchemy.dialects.mysql.mysqldb import MySQLDialect_mysqldb
+
+    from superset.db_engine_specs.base import BasicParametersType
+    from superset.db_engine_specs.doris import DorisEngineSpec
+
+    parameters: BasicParametersType = {
+        "username": "user",
+        "password": "p@ss",
+        "host": "doris.example.com",
+        "port": 9030,
+        "database": "internal.sales",
+        "query": {},
+        "encryption": encryption,
+    }
+
+    uri = DorisEngineSpec.build_sqlalchemy_uri(parameters)
+
+    url = make_url(uri)
+    assert url.drivername == "pydoris"
+    assert url.get_backend_name() == DorisEngineSpec.engine
+    # Resolving the dialect is what ``create_engine`` does first, and is the
+    # step that fails for a scheme no entry point provides.
+    assert url.get_dialect() is MySQLDialect_mysqldb
+    assert (url.username, url.password, url.host, url.port, url.database) == (
+        "user",
+        "p@ss",
+        "doris.example.com",
+        9030,
+        "internal.sales",
+    )
+    assert DorisEngineSpec.get_parameters_from_uri(uri)["encryption"] is 
encryption
+
+    # The SSL switch must reach mysqlclient as a mode that requires TLS.
+    _, connect_kwargs = MySQLDialect_mysqldb().create_connect_args(url)
+    assert "ssl" not in connect_kwargs
+    assert connect_kwargs.get("ssl_mode") == ("VERIFY_CA" if encryption else 
None)
+
+
[email protected]("pydoris_dialects")
+def test_engine_plus_default_driver_scheme_has_no_dialect() -> None:
+    """
+    ``engine+default_driver`` is the scheme the connection form would emit
+    without the override; pydoris registers no such entry point.
+    """
+    from superset.db_engine_specs.doris import DorisEngineSpec
+
+    scheme = f"{DorisEngineSpec.engine}+{DorisEngineSpec.default_driver}"
+
+    with pytest.raises(NoSuchModuleError):
+        
make_url(f"{scheme}://user:p@[email protected]:9030/db").get_dialect()
+
+
 @pytest.mark.parametrize(
     "native_type,generic_type",
     [
@@ -387,14 +541,14 @@ def test_build_sqlalchemy_uri() -> None:
     encrypted = make_url(
         DorisEngineSpec.build_sqlalchemy_uri({**parameters, "encryption": 
True})
     )
-    assert encrypted.drivername == "doris"
+    assert encrypted.drivername == "pydoris"
     assert dict(encrypted.query) == {"ssl_mode": "VERIFY_CA"}
     assert encrypted.password == "p@ss"  # noqa: S105
 
     plain = make_url(
         DorisEngineSpec.build_sqlalchemy_uri({**parameters, "encryption": 
False})
     )
-    assert plain.drivername == "doris"
+    assert plain.drivername == "pydoris"
     assert dict(plain.query) == {}
 
     round_trip = DorisEngineSpec.get_parameters_from_uri(
@@ -435,7 +589,7 @@ def test_doris_tls_request_uses_verification(source: str, 
client_info: str) -> N
     oracle_required = source != "toggle" and client_info == "8.4.6"
     expected_mode = "REQUIRED" if oracle_required else "VERIFY_CA"
     assert args.get("ssl_mode", url.query.get("ssl_mode")) == expected_mode
-    assert url.drivername == "doris"
+    assert url.drivername == ("pydoris" if source == "toggle" else "doris")
     assert url.database == "external.other"
 
 

Reply via email to