Compare commits

...

8 Commits

Author SHA1 Message Date
Elizabeth Thompson
8c57796b03 chore(translations): add Druid connection form strings to messages.pot
Extract the two user-facing strings added by the Druid dynamic connection
form (port placeholder and HTTPS tooltip) so messages.pot matches a fresh
extraction and check_pot_drift.py passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 00:35:07 +00:00
Elizabeth Thompson
4948b3bb48 Merge remote-tracking branch 'origin/master' into sc-120998-druid-new-form 2026-10-07 00:32:52 +00:00
Elizabeth Thompson
18d0aba84b Merge remote-tracking branch 'origin/master' into sc-120998-druid-new-form 2026-10-06 20:44:17 +00:00
Elizabeth Thompson
d3575ca792 fix(druid): use Druid-specific port placeholder and HTTPS tooltip in the form
The shared connection form shows a Postgres port placeholder ("e.g. 5432")
and a Postgres SSL tooltip ('SSL Mode "require" will be used.'). Add an
ENGINE_FORM_FIELD_OVERRIDES map, layered over FORM_FIELD_MAP for the
selected engine, and register Druid fields that show the broker port
(8082, router 8888) and explain that the toggle switches to HTTPS. The
toggle still sends the same `encryption` parameter, so druid:// vs
druid+https:// is unchanged. The shared CommonParameters fields are not
modified.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 01:21:34 +00:00
Elizabeth Thompson
cb99cdd0f7 Merge remote-tracking branch 'origin/master' into sc-120998-druid-new-form
# Conflicts:
#	tests/unit_tests/databases/api_test.py
2026-10-03 01:15:35 +00:00
Elizabeth Thompson
c08094a64d fix(druid): match pydruid's "rest" driver so the dynamic form is offered
Every pydruid dialect reports its driver as "rest", but the spec's default
driver was empty, so /api/v1/database/available/ never returned form
parameters for Druid and the UI fell back to the SQLAlchemy URI box.

Set default_driver to "rest" and build the URI scheme explicitly as
`druid` (http) or `druid+https`, since "rest" is not a registered dialect
name. Add tests covering the available-databases payload, the detected
pydruid driver, the denylist, and engine-spec resolution for existing URIs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 01:09:13 +00:00
Elizabeth Thompson
9bf71150a6 fix(druid): narrow spec type for mypy and make params schema reloadable
- Cast get_engine_spec("druid") to type[DruidEngineSpec] in the unit test so
  mypy resolves parameters_schema (defined on BasicParametersMixin) instead of
  failing with attr-defined on type[BaseEngineSpec].
- DruidParametersSchema omits the fixed `database` path but
  get_parameters_from_uri still emits it; set `unknown = EXCLUDE` so reloading
  the emitted parameters via parameters_schema.load(...) does not raise an
  "Unknown field" error on the create/update path.
- Add a reload test guarding the schema behaviour.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-15 19:24:14 +00:00
Elizabeth Thompson
abf54cb4a5 feat(druid): support the dynamic connection form
DruidEngineSpec now mixes in BasicParametersMixin so `/api/v1/database/
available/` returns individual connection parameters (host, port, username,
password, encryption) instead of the raw SQLAlchemy URI box.

The pydruid URI has a fixed SQL endpoint path (`/druid/v2/sql/`) and toggles
TLS by selecting the `druid` (http) vs `druid+https` dialect rather than a
query parameter, so a custom `DruidParametersSchema` omits the database field
and `build_sqlalchemy_uri`/`get_parameters_from_uri` are overridden to inject
the fixed path and round-trip the encryption toggle through the driver name.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-15 05:16:56 +00:00
8 changed files with 654 additions and 5 deletions

View File

@@ -0,0 +1,139 @@
/**
* 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.
*/
import {
render,
screen,
userEvent,
within,
} from 'spec/helpers/testing-library';
import {
ConfigurationMethod,
DatabaseConnectionFormProps,
DatabaseForm,
DatabaseObject,
} from 'src/features/databases/types';
import DatabaseConnectionForm from './index';
const basicParametersModel = (engine: string) =>
({
engine,
name: engine,
preferred: false,
sqlalchemy_uri_placeholder: '',
available_drivers: [],
default_driver: '',
engine_information: {
supports_file_upload: false,
disable_ssh_tunneling: false,
},
parameters: {
properties: {
host: { type: 'string', description: 'Hostname or IP address' },
port: { type: 'integer', description: 'Database port' },
username: { type: 'string', description: 'Username' },
password: { type: 'string', description: 'Password' },
encryption: {
type: 'boolean',
description: 'Use an encrypted connection',
},
},
required: ['host', 'port'],
},
}) as unknown as DatabaseForm;
const renderForm = (
engine: string,
onParametersChange: jest.Mock = jest.fn(),
) => {
const db: Partial<DatabaseObject> = {
engine,
configuration_method: ConfigurationMethod.DynamicForm,
parameters: {},
};
const noop = jest.fn();
const props: DatabaseConnectionFormProps = {
dbModel: basicParametersModel(engine),
db,
sslForced: false,
isValidating: false,
onParametersChange,
onChange: noop,
onQueryChange: noop,
onExtraInputChange: noop,
onEncryptedExtraInputChange: noop,
onClearEncryptedExtraKey: noop,
onAddTableCatalog: noop,
onRemoveTableCatalog: noop,
validationErrors: null,
getValidation: noop,
clearValidationErrors: noop,
};
return render(<DatabaseConnectionForm {...props} />);
};
test('uses the Druid broker port placeholder', () => {
renderForm('druid');
expect(
screen.getByPlaceholderText('e.g. 8082 (8888 for the router)'),
).toBeInTheDocument();
expect(screen.queryByPlaceholderText('e.g. 5432')).not.toBeInTheDocument();
});
test('explains that the Druid SSL toggle switches to HTTPS', async () => {
renderForm('druid');
const sslToggle = screen.getByText('SSL').parentElement as HTMLElement;
await userEvent.hover(
within(sslToggle).getByRole('img', { name: 'info-circle' }),
);
expect(
await screen.findByText(
'HTTPS will be used to connect to the Druid SQL endpoint.',
),
).toBeInTheDocument();
expect(
screen.queryByText('SSL Mode "require" will be used.'),
).not.toBeInTheDocument();
});
test('the Druid SSL toggle still sends the encryption parameter', async () => {
const onParametersChange = jest.fn();
renderForm('druid', onParametersChange);
await userEvent.click(screen.getByRole('switch'));
expect(onParametersChange).toHaveBeenCalledWith({
target: {
type: 'toggle',
name: 'encryption',
checked: true,
value: true,
},
});
});
test('other engines keep the common field text', () => {
renderForm('postgresql');
expect(screen.getByPlaceholderText('e.g. 5432')).toBeInTheDocument();
expect(
screen.queryByPlaceholderText('e.g. 8082 (8888 for the router)'),
).not.toBeInTheDocument();
});

View File

@@ -0,0 +1,95 @@
/**
* 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.
*/
import { t } from '@apache-superset/core/translation';
import { SupersetTheme } from '@apache-superset/core/theme';
import { Switch } from '@superset-ui/core/components/Switch';
import {
InfoTooltip,
LabeledErrorBoundInput as ValidatedInput,
} from '@superset-ui/core/components';
import { FieldPropTypes } from '../../types';
import { toggleStyle, infoTooltip } from '../styles';
/**
* Druid-specific variants of the common connection form fields.
*
* Superset connects to Druid's SQL endpoint on the broker (port 8082 by
* default) or the router (8888), and the encryption toggle switches the pydruid
* dialect from `druid://` (HTTP) to `druid+https://` rather than setting a
* Postgres-style SSL mode.
*/
export const DRUID_ENGINE = 'druid';
export const druidPortField = ({
required,
changeMethods,
getValidation,
validationErrors,
db,
isValidating,
}: FieldPropTypes) => (
<ValidatedInput
id="port"
name="port"
type="number"
isValidating={isValidating}
required={required}
value={db?.parameters?.port as number}
validationMethods={{ onBlur: getValidation }}
errorMessage={validationErrors?.port}
placeholder={t('e.g. 8082 (8888 for the router)')}
className="form-group-w-50"
label={t('Port')}
onChange={changeMethods.onParametersChange}
/>
);
export const druidEncryptionField = ({
isEditMode,
changeMethods,
db,
sslForced,
}: FieldPropTypes) => (
<div css={(theme: SupersetTheme) => infoTooltip(theme)}>
<Switch
disabled={sslForced && !isEditMode}
checked={db?.parameters?.encryption || sslForced}
onChange={changed => {
changeMethods.onParametersChange({
target: {
type: 'toggle',
name: 'encryption',
checked: true,
value: changed,
},
});
}}
/>
<span css={toggleStyle}>{t('SSL')}</span>
<InfoTooltip
tooltip={t('HTTPS will be used to connect to the Druid SQL endpoint.')}
placement="right"
/>
</div>
);
export const DRUID_FORM_FIELD_MAP = {
port: druidPortField,
encryption: druidEncryptionField,
};

View File

@@ -37,6 +37,7 @@ import { OAuth2ClientField } from './OAuth2ClientField';
import { validatedInputField } from './ValidatedInputField';
import { EncryptedField } from './EncryptedField';
import { TableCatalog } from './TableCatalog';
import { DRUID_ENGINE, DRUID_FORM_FIELD_MAP } from './DruidParameters';
import SSHTunnelSwitch from '../SSHTunnelSwitch';
export const FormFieldOrder = [
@@ -93,3 +94,15 @@ export const FORM_FIELD_MAP = {
ssh: SSHTunnelSwitchComponent,
project_id: projectIdfield,
};
/**
* Per-engine replacements for entries of ``FORM_FIELD_MAP``, keyed by the
* engine name. Engines use these when a common field needs engine-specific
* labels, placeholders or tooltips.
*/
export const ENGINE_FORM_FIELD_OVERRIDES: Record<
string,
Partial<typeof FORM_FIELD_MAP>
> = {
[DRUID_ENGINE]: DRUID_FORM_FIELD_MAP,
};

View File

@@ -19,7 +19,11 @@
import { useEffect, useState } from 'react';
import { SupersetTheme } from '@apache-superset/core/theme';
import { Form } from '@superset-ui/core/components';
import { FormFieldOrder, FORM_FIELD_MAP } from './constants';
import {
ENGINE_FORM_FIELD_OVERRIDES,
FormFieldOrder,
FORM_FIELD_MAP,
} from './constants';
import { formScrollableStyles, validatedFormStyles } from '../styles';
import {
DatabaseConnectionFormProps,
@@ -96,6 +100,11 @@ const DatabaseConnectionForm = ({
// field that computeInitialIsPublic actually reads.
}, [db?.id, db?.engine, db?.masked_encrypted_extra, db?.parameters]);
const fieldMap = {
...FORM_FIELD_MAP,
...ENGINE_FORM_FIELD_OVERRIDES[db?.engine ?? ''],
};
return (
<Form>
<div
@@ -115,7 +124,7 @@ const DatabaseConnectionForm = ({
// makes the field's hooks register against this parent's fiber and
// breaks the rules of hooks once the parent owns its own state.
// @ts-expect-error TODO: fix ComponentClass for SSHTunnelSwitchComponent not having call signature.
const FieldComponent = FORM_FIELD_MAP[field];
const FieldComponent = fieldMap[field];
return (
<FieldComponent
key={field}

View File

@@ -21,11 +21,21 @@ import logging
from datetime import datetime
from typing import Any, TYPE_CHECKING
from flask_babel import gettext as __
from marshmallow import EXCLUDE, fields, Schema
from marshmallow.validate import Range
from sqlalchemy import types
from sqlalchemy.engine.url import URL
from superset import is_feature_enabled
from superset.constants import TimeGrain
from superset.db_engine_specs.base import BaseEngineSpec, DatabaseCategory
from superset.databases.utils import make_url_safe
from superset.db_engine_specs.base import (
BaseEngineSpec,
BasicParametersMixin,
BasicParametersType,
DatabaseCategory,
)
from superset.db_engine_specs.exceptions import SupersetDBAPIConnectionError
from superset.exceptions import SupersetException
from superset.utils import core as utils, json
@@ -40,7 +50,43 @@ logger = logging.getLogger()
_DRUID_FLOAT_SPECIAL = frozenset({"NaN", "Infinity", "-Infinity"})
class DruidEngineSpec(BaseEngineSpec):
class DruidParametersSchema(Schema):
"""
Parameters for the dynamic connection form.
Druid connects over a fixed SQL endpoint (`/druid/v2/sql/`), so the schema
intentionally omits the ``database`` field. The ``encryption`` toggle selects
the http vs. https pydruid dialect rather than adding a query parameter.
"""
class Meta: # pylint: disable=too-few-public-methods
# `get_parameters_from_uri` emits the fixed `database` path, which is not
# a form field; ignore it (and any other stray keys) on reload instead of
# raising an "Unknown field" error.
unknown = EXCLUDE
username = fields.String(allow_none=True, metadata={"description": __("Username")})
password = fields.String(allow_none=True, metadata={"description": __("Password")})
host = fields.String(
required=True, metadata={"description": __("Hostname or IP address")}
)
port = fields.Integer(
required=True,
metadata={"description": __("Database port")},
validate=Range(min=0, max=2**16, max_inclusive=False),
)
encryption = fields.Boolean(
required=False,
metadata={"description": __("Use an encrypted connection to the database")},
)
query = fields.Dict(
keys=fields.Str(),
values=fields.Raw(),
metadata={"description": __("Additional parameters")},
)
class DruidEngineSpec(BasicParametersMixin, BaseEngineSpec):
"""Engine spec for Druid.io"""
engine = "druid"
@@ -48,6 +94,19 @@ class DruidEngineSpec(BaseEngineSpec):
allows_joins = is_feature_enabled("DRUID_JOINS")
allows_subqueries = True
# Every pydruid dialect (`druid`, `druid+http`, `druid+https`) reports its
# driver as "rest", which is what `get_available_engine_specs` detects; the
# default driver must match it for the dynamic form to be offered. "rest" is
# not a registered dialect name, so `build_sqlalchemy_uri` writes the `druid`
# / `druid+https` schemes explicitly. The SQL endpoint path is fixed and never
# entered by the user, so it is injected instead of exposed as a form field.
default_driver = "rest"
sqlalchemy_uri_placeholder = "druid://user:password@host:port/druid/v2/sql/"
sqlalchemy_uri_database = "druid/v2/sql/"
parameters_schema = DruidParametersSchema()
# username/password are optional for anonymous Druid clusters
required_parameters = {"host", "port"}
# pydruid builds cursor.description from the first returned row, so a
# WHERE FALSE query (zero rows) leaves description as None.
type_probe_needs_row = True
@@ -162,6 +221,46 @@ class DruidEngineSpec(BaseEngineSpec):
if orm_col.column_name == "__time":
orm_col.is_dttm = True
@classmethod
def build_sqlalchemy_uri(
cls,
parameters: BasicParametersType,
encrypted_extra: dict[str, str] | None = None,
) -> str:
query = parameters.get("query", {}).copy()
# `druid+https` selects pydruid's TLS dialect; the bare `druid` scheme
# connects over plain HTTP.
drivername = (
f"{cls.engine}+https" if parameters.get("encryption") else cls.engine
)
# SQLAlchemy 2.0 hides the password from `URL.__str__()`, so render it
# explicitly since this URI is used to connect, not just displayed.
return URL.create(
drivername,
username=parameters.get("username"),
password=parameters.get("password"),
host=parameters["host"],
port=parameters["port"],
database=cls.sqlalchemy_uri_database,
query=query,
).render_as_string(hide_password=False)
@classmethod
def get_parameters_from_uri(
cls, uri: str, encrypted_extra: dict[str, Any] | None = None
) -> BasicParametersType:
url = make_url_safe(uri)
return BasicParametersType(
username=url.username,
password=url.password,
host=url.host,
port=url.port,
database=url.database or cls.sqlalchemy_uri_database,
query=dict(url.query),
encryption=(url.drivername or "").endswith("https"),
)
@staticmethod
def get_extra_params(
database: Database, source: QuerySource | None = None

View File

@@ -7156,6 +7156,9 @@ msgstr ""
msgid "HTTP Path"
msgstr ""
msgid "HTTPS will be used to connect to the Druid SQL endpoint."
msgstr ""
msgid "Handlebars"
msgstr ""
@@ -17078,6 +17081,9 @@ msgstr ""
msgid "e.g. 5432"
msgstr ""
msgid "e.g. 8082 (8888 for the router)"
msgstr ""
msgid "e.g. AccountAdmin"
msgstr ""

View File

@@ -2842,6 +2842,49 @@ def test_import_includes_configuration_method(
assert db_obj_api["configuration_method"] == "dynamic_form"
@pytest.mark.parametrize(
"drivers,expect_parameters",
[
# every pydruid dialect (`druid`, `druid+http`, `druid+https`) reports "rest"
({"rest"}, True),
({"other"}, False),
],
)
def test_available_druid_dynamic_form(
mocker: MockerFixture,
client: Any,
full_api_access: None,
drivers: set[str],
expect_parameters: bool,
) -> None:
"""
Druid is offered with the dynamic connection form when pydruid's "rest"
driver is detected, and falls back to the SQLAlchemy URI box otherwise.
"""
from superset.db_engine_specs.druid import DruidEngineSpec
mocker.patch(
"superset.databases.api.get_available_engine_specs",
return_value={DruidEngineSpec: drivers},
)
response = client.get("/api/v1/database/available/")
assert response.status_code == 200
(druid,) = response.json["databases"]
assert druid["engine"] == "druid"
assert druid["default_driver"] == "rest"
assert druid["available_drivers"] == sorted(drivers)
assert ("parameters" in druid) is expect_parameters
if expect_parameters:
assert set(druid["parameters"]["required"]) == {"host", "port"}
assert "database" not in druid["parameters"]["properties"]
assert (
druid["sqlalchemy_uri_placeholder"]
== "druid://user:password@host:port/druid/v2/sql/"
)
def test_related_objects_includes_datasets(
session: Session,
client: Any,

View File

@@ -15,12 +15,13 @@
# specific language governing permissions and limitations
# under the License.
from datetime import datetime
from typing import Optional
from typing import Any, cast, Optional
from unittest import mock
import pandas as pd
import pytest
from sqlalchemy import column
from sqlalchemy.engine.url import make_url
from superset.db_engine_specs.base import BaseEngineSpec
from superset.result_set import SupersetResultSet
@@ -258,3 +259,247 @@ def test_unmask_encrypted_extra() -> None:
assert DruidEngineSpec.unmask_encrypted_extra(old, new) == json.dumps(
{"connect_args": {"scheme": "http", "jwt": "old-token", "password": "new"}}
)
# ---------------------------------------------------------------------------
# Dynamic connection form (BasicParametersMixin)
#
# Druid connects over a fixed SQL endpoint (`/druid/v2/sql/`) and toggles TLS by
# switching between pydruid's `druid` (http) and `druid+https` dialects, so the
# form omits the database field and encodes encryption in the driver name rather
# than a query parameter.
# ---------------------------------------------------------------------------
def _parameters(**overrides: Any) -> Any:
from superset.db_engine_specs.base import BasicParametersType
parameters: dict[str, Any] = {
"username": "user",
"password": "pwd",
"host": "localhost",
"port": 9088,
"query": {},
}
parameters.update(overrides)
return cast(BasicParametersType, parameters)
def test_get_engine_spec_supports_parameters() -> None:
"""
Druid must resolve to a spec that supports the dynamic connection form so
the ``/available`` endpoint returns individual parameters.
"""
from superset.db_engine_specs import get_engine_spec
from superset.db_engine_specs.druid import DruidEngineSpec
spec = cast("type[DruidEngineSpec]", get_engine_spec("druid"))
assert spec is DruidEngineSpec
assert spec.parameters_schema is not None
assert hasattr(spec, "build_sqlalchemy_uri")
@pytest.mark.parametrize(
"encryption,expected_driver",
[
(False, "druid"),
(True, "druid+https"),
],
)
def test_build_sqlalchemy_uri_toggles_scheme(
encryption: bool, expected_driver: str
) -> None:
"""
The encryption toggle selects the http vs. https pydruid dialect and always
injects the fixed SQL endpoint path.
"""
from superset.db_engine_specs.druid import DruidEngineSpec
uri = make_url(
DruidEngineSpec.build_sqlalchemy_uri(_parameters(encryption=encryption))
)
assert uri.drivername == expected_driver
assert uri.database == "druid/v2/sql/"
assert uri.host == "localhost"
assert uri.port == 9088
def test_build_sqlalchemy_uri_preserves_query_params() -> None:
from superset.db_engine_specs.druid import DruidEngineSpec
uri = make_url(
DruidEngineSpec.build_sqlalchemy_uri(_parameters(query={"header": "true"}))
)
assert uri.query["header"] == "true"
def test_build_sqlalchemy_uri_renders_password() -> None:
"""The stored URI is used to connect, so the password must not be masked."""
from superset.db_engine_specs.druid import DruidEngineSpec
uri = DruidEngineSpec.build_sqlalchemy_uri(_parameters(password="s3cret")) # noqa: S106
assert "s3cret" in uri
@pytest.mark.parametrize(
"uri,expected_encryption",
[
("druid://user:pwd@localhost:9088/druid/v2/sql/", False),
("druid+http://user:pwd@localhost:9088/druid/v2/sql/", False),
("druid+https://user:pwd@localhost:9088/druid/v2/sql/", True),
],
)
def test_get_parameters_from_uri_encryption(
uri: str, expected_encryption: bool
) -> None:
from superset.db_engine_specs.druid import DruidEngineSpec
parameters = DruidEngineSpec.get_parameters_from_uri(uri)
assert parameters["encryption"] is expected_encryption
assert parameters["host"] == "localhost"
assert parameters["port"] == 9088
assert parameters["database"] == "druid/v2/sql/"
def test_get_parameters_from_uri_accepts_encrypted_extra_keyword() -> None:
"""
``Database.parameters`` passes ``encrypted_extra`` by keyword; a signature
mismatch would silently empty the connection form.
"""
from superset.db_engine_specs.druid import DruidEngineSpec
parameters = DruidEngineSpec.get_parameters_from_uri(
"druid+https://user:pwd@localhost:9088/druid/v2/sql/",
encrypted_extra={},
)
assert parameters["encryption"] is True
@pytest.mark.parametrize("encryption", [True, False])
def test_parameters_round_trip(encryption: bool) -> None:
from superset.db_engine_specs.druid import DruidEngineSpec
uri = DruidEngineSpec.build_sqlalchemy_uri(
_parameters(encryption=encryption, query={"header": "true"})
)
parameters = DruidEngineSpec.get_parameters_from_uri(uri)
assert parameters["encryption"] is encryption
assert parameters["host"] == "localhost"
assert parameters["port"] == 9088
assert parameters["username"] == "user"
assert parameters["query"] == {"header": "true"}
def test_parameters_json_schema_omits_database() -> None:
"""
The SQL endpoint path is fixed, so ``database`` must not appear as a form
field; the encryption toggle must be present instead.
"""
from superset.db_engine_specs.druid import DruidEngineSpec
schema = DruidEngineSpec.parameters_json_schema()
assert "database" not in schema["properties"]
assert "encryption" in schema["properties"]
assert set(schema["required"]) == {"host", "port"}
def test_parameters_schema_reloads_emitted_parameters() -> None:
"""
``get_parameters_from_uri`` emits the fixed ``database`` path, which is not a
form field. Re-loading that dict through the schema (the create/update path)
must not raise on the unknown ``database`` key.
"""
from superset.db_engine_specs.druid import DruidEngineSpec
parameters = DruidEngineSpec.get_parameters_from_uri(
"druid+https://user:pwd@localhost:9088/druid/v2/sql/"
)
assert parameters["database"] == "druid/v2/sql/"
loaded = DruidEngineSpec.parameters_schema.load(parameters)
assert "database" not in loaded
assert loaded["host"] == "localhost"
assert loaded["encryption"] is True
def test_default_driver_matches_pydruid_dialects() -> None:
"""
``/available`` only returns form parameters when the spec's default driver is
among the detected drivers, so it must match what pydruid's dialects report.
"""
sqlalchemy_dialect = pytest.importorskip("pydruid.db.sqlalchemy")
from superset.db_engine_specs.druid import DruidEngineSpec
detected = {
sqlalchemy_dialect.DruidHTTPDialect.driver,
sqlalchemy_dialect.DruidHTTPSDialect.driver,
}
assert detected == {"rest"}
assert DruidEngineSpec.default_driver in detected
def test_build_sqlalchemy_uri_uses_registered_dialect_names() -> None:
"""
The default driver ("rest") is not a registered dialect name, so it must
never leak into the generated URI scheme.
"""
from superset.db_engine_specs.druid import DruidEngineSpec
for encryption in (True, False):
uri = DruidEngineSpec.build_sqlalchemy_uri(_parameters(encryption=encryption))
assert "rest" not in make_url(uri).drivername
@pytest.mark.parametrize(
"uri",
[
"druid://user:pwd@localhost:8082/druid/v2/sql/",
"druid+https://user:pwd@localhost:8082/druid/v2/sql/",
],
)
def test_existing_uris_resolve_to_druid_spec(uri: str) -> None:
"""Existing Druid connections keep resolving to the Druid engine spec."""
from superset.db_engine_specs import get_engine_spec
from superset.db_engine_specs.druid import DruidEngineSpec
url = make_url(uri)
driver = url.drivername.split("+")[1] if "+" in url.drivername else "rest"
assert get_engine_spec(url.get_backend_name(), driver) is DruidEngineSpec
@pytest.mark.parametrize(
"denylist,expect_available",
[
({"druid": {"rest"}}, False),
({"druid": {"other"}}, True),
({}, True),
],
)
def test_available_engine_specs_druid_denylist(
mocker: Any, denylist: dict[str, set[str]], expect_available: bool
) -> None:
"""``DBS_AVAILABLE_DENYLIST`` disables Druid by its "rest" driver name."""
from superset.db_engine_specs import get_available_engine_specs
from superset.db_engine_specs.druid import DruidEngineSpec
mocker.patch(
"superset.db_engine_specs.load_engine_specs",
return_value=[DruidEngineSpec],
)
mocker.patch("superset.db_engine_specs.entry_points", return_value=[])
app = mocker.patch("superset.db_engine_specs.app")
app.config = {"DBS_AVAILABLE_DENYLIST": dict(denylist)}
available = get_available_engine_specs()
assert (DruidEngineSpec in available) is expect_available