Compare commits
8 Commits
master
...
sc-120998-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c57796b03 | ||
|
|
4948b3bb48 | ||
|
|
18d0aba84b | ||
|
|
d3575ca792 | ||
|
|
cb99cdd0f7 | ||
|
|
c08094a64d | ||
|
|
9bf71150a6 | ||
|
|
abf54cb4a5 |
@@ -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();
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user