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"