WIP
diff --git a/superset-frontend/src/components/Form/LabeledErrorBoundInput.tsx b/superset-frontend/src/components/Form/LabeledErrorBoundInput.tsx
index 5ef9024..0d92d4b 100644
--- a/superset-frontend/src/components/Form/LabeledErrorBoundInput.tsx
+++ b/superset-frontend/src/components/Form/LabeledErrorBoundInput.tsx
@@ -18,7 +18,7 @@
*/
import { styled, css, SupersetTheme, t } from '@superset-ui/core';
import { Tooltip } from 'src/components/Tooltip';
-import { Input } from 'src/components/Input';
+import { Input, InputNumber } from 'src/components/Input';
import InfoTooltip from 'src/components/InfoTooltip';
import { Icons } from 'src/components/Icons';
import Button from 'src/components/Button';
@@ -46,6 +46,10 @@
margin: ${({ theme }) => `${theme.gridUnit}px 0 ${theme.gridUnit * 2}px`};
`;
+const StyledInputNumber = styled(InputNumber)`
+ margin: ${({ theme }) => `${theme.gridUnit}px 0 ${theme.gridUnit * 2}px`};
+`;
+
const StyledInputPassword = styled(Input.Password)`
margin: ${({ theme }) => `${theme.gridUnit}px 0 ${theme.gridUnit * 2}px`};
`;
@@ -99,6 +103,48 @@
}
`;
+interface GetInputComponentProps {
+ name?: string;
+ type?: string;
+}
+
+const getInputComponent = (
+ props: GetInputComponentProps,
+ validationMethods: Record<string, unknown>,
+ visibilityToggle: boolean,
+): JSX.Element => {
+ if (visibilityToggle || props.name === 'password') {
+ return (
+ <StyledInputPassword
+ {...props}
+ {...validationMethods}
+ iconRender={visible =>
+ visible ? (
+ <Tooltip title={t('Hide password.')}>
+ <Icons.EyeInvisibleOutlined iconSize="m" css={iconReset} />
+ </Tooltip>
+ ) : (
+ <Tooltip title={t('Show password.')}>
+ <Icons.EyeOutlined
+ iconSize="m"
+ css={iconReset}
+ data-test="icon-eye"
+ />
+ </Tooltip>
+ )
+ }
+ role="textbox"
+ />
+ );
+ }
+
+ if (props.type === 'number') {
+ return <StyledInputNumber {...props} {...validationMethods} />;
+ }
+
+ return <StyledInput {...props} {...validationMethods} />;
+};
+
const LabeledErrorBoundInput = ({
label,
validationMethods,
@@ -128,30 +174,7 @@
help={errorMessage || helpText}
hasFeedback={!!errorMessage}
>
- {visibilityToggle || props.name === 'password' ? (
- <StyledInputPassword
- {...props}
- {...validationMethods}
- iconRender={visible =>
- visible ? (
- <Tooltip title={t('Hide password.')}>
- <Icons.EyeInvisibleOutlined iconSize="m" css={iconReset} />
- </Tooltip>
- ) : (
- <Tooltip title={t('Show password.')}>
- <Icons.EyeOutlined
- iconSize="m"
- css={iconReset}
- data-test="icon-eye"
- />
- </Tooltip>
- )
- }
- role="textbox"
- />
- ) : (
- <StyledInput {...props} {...validationMethods} />
- )}
+ {getInputComponent(props, validationMethods, visibilityToggle)}
{get_url && description ? (
<Button
type="link"
diff --git a/superset-frontend/src/features/databases/DatabaseModal/DatabaseConnectionForm/CommonParameters.tsx b/superset-frontend/src/features/databases/DatabaseModal/DatabaseConnectionForm/CommonParameters.tsx
index 696f52b..c86aea0 100644
--- a/superset-frontend/src/features/databases/DatabaseModal/DatabaseConnectionForm/CommonParameters.tsx
+++ b/superset-frontend/src/features/databases/DatabaseModal/DatabaseConnectionForm/CommonParameters.tsx
@@ -55,21 +55,19 @@
validationErrors,
db,
}: FieldPropTypes) => (
- <>
- <ValidatedInput
- id="port"
- name="port"
- type="number"
- required={required}
- value={db?.parameters?.port as number}
- validationMethods={{ onBlur: getValidation }}
- errorMessage={validationErrors?.port}
- placeholder={t('e.g. 5432')}
- className="form-group-w-50"
- label={t('Port')}
- onChange={changeMethods.onParametersChange}
- />
- </>
+ <ValidatedInput
+ id="port"
+ name="port"
+ type="number"
+ required={required}
+ value={db?.parameters?.port as number}
+ validationMethods={{ onBlur: getValidation }}
+ errorMessage={validationErrors?.port}
+ placeholder={t('e.g. 5432')}
+ className="form-group-w-50"
+ label={t('Port')}
+ onChange={changeMethods.onParametersChange}
+ />
);
export const httpPath = ({
required,
diff --git a/superset-frontend/src/features/databases/DatabaseModal/DatabaseConnectionForm/GenericField.tsx b/superset-frontend/src/features/databases/DatabaseModal/DatabaseConnectionForm/GenericField.tsx
new file mode 100644
index 0000000..24a130e
--- /dev/null
+++ b/superset-frontend/src/features/databases/DatabaseModal/DatabaseConnectionForm/GenericField.tsx
@@ -0,0 +1,127 @@
+/**
+ * import InfoTooltip from 'src/components/InfoTooltip'm
+ * 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 { useEffect } from 'react';
+import { styled } from '@superset-ui/core';
+// eslint-disable-next-line no-restricted-imports
+import { SelectValue } from 'antd/lib/select';
+import InfoTooltip from 'src/components/InfoTooltip';
+import Select from 'src/components/Select/Select';
+import FormItem from 'src/components/Form/FormItem';
+import FormLabel from 'src/components/Form/FormLabel';
+import ValidatedInput from 'src/components/Form/LabeledErrorBoundInput';
+import { DatabaseParameters, FieldPropTypes } from '../../types';
+
+const StyledFormGroup = styled('div')`
+ input::-webkit-outer-spin-button,
+ input::-webkit-inner-spin-button {
+ -webkit-appearance: none;
+ margin: 0;
+ }
+ margin-bottom: ${({ theme }) => theme.gridUnit * 3}px;
+ .ant-form-item {
+ margin-bottom: 0;
+ }
+`;
+
+const StyledAlignment = styled.div`
+ display: flex;
+ align-items: center;
+`;
+
+const StyledFormLabel = styled(FormLabel)`
+ margin-bottom: 0;
+`;
+
+export const GenericField = ({
+ required,
+ changeMethods,
+ getValidation,
+ validationErrors,
+ db,
+ field,
+ parameter,
+}: FieldPropTypes) => {
+ // set default values
+ useEffect(() => {
+ if (!db?.parameters?.[field as keyof DatabaseParameters] && parameter?.default !== undefined) {
+ changeMethods.onParametersChange({
+ target: { name: field, value: parameter.default },
+ });
+ }
+ }, []);
+
+ const handleOptionChange = (value: SelectValue) => {
+ changeMethods.onParametersChange({
+ target: { name: field, value },
+ });
+ };
+
+ // enums are mapped to select inputs
+ if (parameter?.enum) {
+ return (
+ <StyledFormGroup>
+ <StyledAlignment>
+ <StyledFormLabel htmlFor={field} required={required}>
+ {parameter.title}
+ </StyledFormLabel>
+ {parameter?.description && (
+ <InfoTooltip tooltip={parameter.description} />
+ )}
+ </StyledAlignment>
+ <FormItem>
+ <Select
+ onChange={handleOptionChange}
+ options={parameter.enum.map((value: string) => ({
+ value,
+ label: value,
+ }))}
+ value={
+ db?.parameters?.[field as keyof DatabaseParameters] ||
+ parameter?.default
+ }
+ />
+ </FormItem>
+ </StyledFormGroup>
+ );
+ }
+
+ // text/number inputs
+ return (
+ <ValidatedInput
+ id={field}
+ name={field}
+ type={parameter.type === 'integer' ? 'number' : 'text'}
+ required={required}
+ value={
+ db?.parameters?.[field as keyof DatabaseParameters] ||
+ parameter?.default
+ }
+ validationMethods={{ onBlur: getValidation }}
+ errorMessage={validationErrors?.[field]}
+ placeholder={parameter?.['x-placeholder']}
+ helpText={parameter?.['x-help-text']}
+ label={parameter.title}
+ hasTooltip={!!parameter?.description}
+ tooltipText={parameter?.description}
+ onChange={changeMethods.onParametersChange}
+ />
+ );
+};
diff --git a/superset-frontend/src/features/databases/DatabaseModal/DatabaseConnectionForm/index.tsx b/superset-frontend/src/features/databases/DatabaseModal/DatabaseConnectionForm/index.tsx
index 0f375f3..19ca0d4 100644
--- a/superset-frontend/src/features/databases/DatabaseModal/DatabaseConnectionForm/index.tsx
+++ b/superset-frontend/src/features/databases/DatabaseModal/DatabaseConnectionForm/index.tsx
@@ -17,16 +17,25 @@
* under the License.
*/
import { SupersetTheme } from '@superset-ui/core';
+import { Col, Row } from 'src/components';
import { Form } from 'src/components/Form';
-import { FormFieldOrder, FORM_FIELD_MAP } from './constants';
+import { FORM_FIELD_MAP } from './constants';
import { formScrollableStyles, validatedFormStyles } from '../styles';
-import { DatabaseConnectionFormProps } from '../../types';
+import { DatabaseConnectionFormProps, ParameterFieldSchema } from '../../types';
+import { GenericField } from './GenericField';
+
+interface ParametersSchema {
+ order: string[];
+ properties: {
+ [key: string]: ParameterFieldSchema;
+ };
+ required?: string[];
+}
const DatabaseConnectionForm = ({
dbModel,
db,
editNewDb,
- getPlaceholder,
getValidation,
isEditMode = false,
onAddTableCatalog,
@@ -41,62 +50,63 @@
validationErrors,
clearValidationErrors,
}: DatabaseConnectionFormProps) => {
- const parameters = dbModel?.parameters as {
- properties: {
- [key: string]: {
- default?: any;
- description?: string;
- };
- };
- required?: string[];
- };
+ const parameters = dbModel?.parameters as ParametersSchema;
+
+ let orderedEntries = Object.entries(parameters?.properties || []).sort(
+ ([keyA], [keyB]) =>
+ parameters.order.indexOf(keyA) - parameters.order.indexOf(keyB),
+ );
+
+ // database name should come first
+ orderedEntries = [
+ ['database_name', { title: 'Name', type: 'string' }],
+ ...orderedEntries,
+ ];
+
+ const components = orderedEntries.map(([key, value]) => {
+ const FormComponent = FORM_FIELD_MAP[key] || GenericField;
+
+ return (
+ <Col span={(value?.['x-width'] || 1) * 24}>
+ {FormComponent({
+ required: parameters.required?.includes(key),
+ changeMethods: {
+ onParametersChange,
+ onChange,
+ onQueryChange,
+ onParametersUploadFileChange,
+ onAddTableCatalog,
+ onRemoveTableCatalog,
+ onExtraInputChange,
+ onEncryptedExtraInputChange,
+ },
+ validationErrors,
+ getValidation,
+ clearValidationErrors,
+ db,
+ key,
+ field: key,
+ isEditMode,
+ sslForced,
+ editNewDb,
+ parameter: value,
+ })}
+ </Col>
+ );
+ });
return (
<Form>
<div
- // @ts-ignore
css={(theme: SupersetTheme) => [
formScrollableStyles,
validatedFormStyles(theme),
]}
>
- {parameters &&
- FormFieldOrder.filter(
- (key: string) =>
- Object.keys(parameters.properties).includes(key) ||
- key === 'database_name',
- ).map(field =>
- // @ts-ignore TODO: fix ComponentClass for SSHTunnelSwitchComponent not having call signature.
- FORM_FIELD_MAP[field]({
- required: parameters.required?.includes(field),
- changeMethods: {
- onParametersChange,
- onChange,
- onQueryChange,
- onParametersUploadFileChange,
- onAddTableCatalog,
- onRemoveTableCatalog,
- onExtraInputChange,
- onEncryptedExtraInputChange,
- },
- validationErrors,
- getValidation,
- clearValidationErrors,
- db,
- key: field,
- field,
- default_value: parameters.properties[field]?.default,
- description: parameters.properties[field]?.description,
- isEditMode,
- sslForced,
- editNewDb,
- placeholder: getPlaceholder ? getPlaceholder(field) : undefined,
- }),
- )}
+ <Row gutter={[4, 4]}>{components}</Row>
</div>
</Form>
);
};
-export const FormFieldMap = FORM_FIELD_MAP;
export default DatabaseConnectionForm;
diff --git a/superset-frontend/src/features/databases/types.ts b/superset-frontend/src/features/databases/types.ts
index a3a87b1..ccd4f29 100644
--- a/superset-frontend/src/features/databases/types.ts
+++ b/superset-frontend/src/features/databases/types.ts
@@ -315,13 +315,12 @@
clearValidationErrors: () => void;
db?: DatabaseObject;
dbModel?: DatabaseForm;
- field: string;
- default_value?: any;
- description?: string;
isEditMode?: boolean;
sslForced?: boolean;
defaultDBName?: string;
editNewDb?: boolean;
+ field: string;
+ parameter: ParameterFieldSchema;
}
type ChangeMethodsType = FieldPropTypes['changeMethods'];
@@ -369,3 +368,15 @@
clearValidationErrors: () => void;
getPlaceholder?: (field: string) => string | undefined;
}
+
+/* Type for a field in the DB engine spec parameters schema */
+export interface ParameterFieldSchema {
+ title: string;
+ type: string;
+ enum?: any[];
+ default?: any;
+ description?: string;
+ ['x-help-text']?: string;
+ ['x-placeholder']?: string;
+ ['x-width']?: number;
+}
diff --git a/superset/config.py b/superset/config.py
index 8be07ed..70087a5 100644
--- a/superset/config.py
+++ b/superset/config.py
@@ -1148,8 +1148,8 @@
# else:
# return f'tmp_{schema}'
# Function accepts database object, user object, schema name and sql that will be run.
-SQLLAB_CTAS_SCHEMA_NAME_FUNC: (
- None | (Callable[[Database, models.User, str, str], str])
+SQLLAB_CTAS_SCHEMA_NAME_FUNC: None | (
+ Callable[[Database, models.User, str, str], str]
) = None
# If enabled, it can be used to store the results of long-running queries
@@ -1430,6 +1430,20 @@
# e.g., DBS_AVAILABLE_DENYLIST: Dict[str, Set[str]] = {"databricks": {"pyhive", "pyodbc"}} # noqa: E501
DBS_AVAILABLE_DENYLIST: dict[str, set[str]] = {}
+# Allow Superset to use 3rd party DB engine specs, distributed in separate Python
+# packages, and registered under the `superset.db_engine_specs` entry point. Before
+# enabling this you should audit packages in your system that would be loaded, to
+# prevent a malicious package from injecting a DB engine spec that could steal database
+# credentials and have full access to your data. You can do this by running:
+#
+# import importlib.metadata
+#
+# entry_points = importlib.metadata.entry_points().get("superset.db_engine_specs", [])
+# for ep in entry_points:
+# print(ep.module)
+#
+ALLOW_3RD_PARTY_DB_ENGINE_SPECS = False
+
# This auth provider is used by background (offline) tasks that need to access
# protected resources. Can be overridden by end users in order to support
# custom auth mechanisms
diff --git a/superset/constants.py b/superset/constants.py
index f60b79a..df29b98 100644
--- a/superset/constants.py
+++ b/superset/constants.py
@@ -243,3 +243,8 @@
DEFAULT = "default"
DATA = "data"
THUMBNAIL = "thumbnail"
+
+
+DEFAULT_SQLALCHEMY_PLACEHOLDER = (
+ "engine+driver://user:password@host:port/dbname[?key1=value1&key2=value2...]"
+)
diff --git a/superset/databases/api.py b/superset/databases/api.py
index f879351..53cce2a 100644
--- a/superset/databases/api.py
+++ b/superset/databases/api.py
@@ -72,7 +72,10 @@
NoValidFilesFoundError,
)
from superset.commands.importers.v1.utils import get_contents_from_bundle
-from superset.constants import MODEL_API_RW_METHOD_PERMISSION_MAP, RouteMethod
+from superset.constants import (
+ MODEL_API_RW_METHOD_PERMISSION_MAP,
+ RouteMethod,
+)
from superset.daos.database import DatabaseDAO
from superset.databases.decorators import check_table_access
from superset.databases.filters import DatabaseFilter, DatabaseUploadEnabledFilter
@@ -126,6 +129,7 @@
get_username,
parse_js_uri_path_item,
)
+from superset.utils.database import parameters_json_schema
from superset.utils.decorators import transaction
from superset.utils.oauth2 import decode_oauth2_state
from superset.utils.ssh_tunnel import mask_password_info
@@ -1896,14 +1900,10 @@
payload["default_driver"] = engine_spec.default_driver
# show configuration parameters for DBs that support it
- if (
- hasattr(engine_spec, "parameters_json_schema")
- and hasattr(engine_spec, "sqlalchemy_uri_placeholder")
- and engine_spec.default_driver in drivers
- ):
- payload["parameters"] = engine_spec.parameters_json_schema()
- payload["sqlalchemy_uri_placeholder"] = (
- engine_spec.sqlalchemy_uri_placeholder
+ if engine_spec.parameters_schema:
+ payload["parameters"] = parameters_json_schema(
+ engine_spec.__name__,
+ engine_spec.parameters_schema,
)
available_databases.append(payload)
diff --git a/superset/databases/utils.py b/superset/databases/utils.py
index 526cf80..9d6f74d 100644
--- a/superset/databases/utils.py
+++ b/superset/databases/utils.py
@@ -118,13 +118,11 @@
:param raw_url:
:return:
"""
-
- if isinstance(raw_url, str):
- url = raw_url.strip()
- try:
- return make_url(url) # noqa
- except Exception as ex:
- raise DatabaseInvalidError() from ex
-
- else:
+ if not isinstance(raw_url, str):
return raw_url
+
+ url = raw_url.strip()
+ try:
+ return make_url(url) # noqa
+ except Exception as ex:
+ raise DatabaseInvalidError() from ex
diff --git a/superset/db_engine_specs/__init__.py b/superset/db_engine_specs/__init__.py
index 3c0911c..388c7cf 100644
--- a/superset/db_engine_specs/__init__.py
+++ b/superset/db_engine_specs/__init__.py
@@ -61,9 +61,26 @@
def load_engine_specs() -> list[type[BaseEngineSpec]]:
"""
Load all engine specs, native and 3rd party.
+
+ For context, DB engine specs can be installed from 3rd party Python packages via
+ entry points. This allows DB vendor to maintain their own engine specs in a release
+ cycle that's independent from Superset's.
+
+ These DB engine specs can replace the ones that come with Superset, by specifying
+ the `replaces` class attribute.
"""
engine_specs: list[type[BaseEngineSpec]] = []
+ # load 3rd party engine specs first, so they have prioerity
+ if app.config["ALLOW_3RD_PARTY_DB_ENGINE_SPECS"]:
+ for ep in entry_points(group="superset.db_engine_specs"):
+ try:
+ engine_spec = ep.load()
+ except Exception: # pylint: disable=broad-except
+ logger.warning("Unable to load Superset DB engine spec: %s", ep.name)
+ continue
+ engine_specs.append(engine_spec)
+
# load standard engines
db_engine_spec_dir = str(Path(__file__).parent)
for module_info in pkgutil.iter_modules([db_engine_spec_dir], prefix="."):
@@ -73,14 +90,16 @@
for attr in module.__dict__
if is_engine_spec(getattr(module, attr))
)
- # load additional engines from external modules
- for ep in entry_points(group="superset.db_engine_specs"):
- try:
- engine_spec = ep.load()
- except Exception: # pylint: disable=broad-except
- logger.warning("Unable to load Superset DB engine spec: %s", ep.name)
- continue
- engine_specs.append(engine_spec)
+
+ # remove replaced engine specs
+ replaced = {
+ replaced_spec
+ for engine_spec in engine_specs
+ for replaced_spec in engine_spec.replaces
+ }
+ engine_specs = [
+ engine_spec for engine_spec in engine_specs if engine_spec not in replaced
+ ]
return engine_specs
diff --git a/superset/db_engine_specs/base.py b/superset/db_engine_specs/base.py
index 79e0eb3..d727286 100644
--- a/superset/db_engine_specs/base.py
+++ b/superset/db_engine_specs/base.py
@@ -40,8 +40,6 @@
import pandas as pd
import requests
import sqlparse
-from apispec import APISpec
-from apispec.ext.marshmallow import MarshmallowPlugin
from deprecation import deprecated
from flask import current_app, g, url_for
from flask_appbuilder.security.sqla.models import User
@@ -60,7 +58,11 @@
from sqlparse.tokens import CTE
from superset import db, sql_parse
-from superset.constants import QUERY_CANCEL_KEY, TimeGrain as TimeGrainConstants
+from superset.constants import (
+ DEFAULT_SQLALCHEMY_PLACEHOLDER,
+ QUERY_CANCEL_KEY,
+ TimeGrain as TimeGrainConstants,
+)
from superset.databases.utils import get_table_metadata, make_url_safe
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
from superset.exceptions import DisallowedSQLFunction, OAuth2Error, OAuth2RedirectError
@@ -139,7 +141,9 @@
}
-class TimestampExpression(ColumnClause): # pylint: disable=abstract-method, too-many-ancestors
+class TimestampExpression(
+ ColumnClause
+): # pylint: disable=abstract-method, too-many-ancestors
def __init__(self, expr: str, col: ColumnClause, **kwargs: Any) -> None:
"""Sqlalchemy class that can be used to render native column elements respecting
engine-specific quoting rules as part of a string-based expression.
@@ -212,11 +216,14 @@
engine_aliases: set[str] = set()
drivers: dict[str, str] = {}
default_driver: str | None = None
+ sqlalchemy_uri_placeholder = DEFAULT_SQLALCHEMY_PLACEHOLDER
- # placeholder with the SQLAlchemy URI template
- sqlalchemy_uri_placeholder = (
- "engine+driver://user:password@host:port/dbname[?key=value&key=value...]"
- )
+ # 3rd-party DB engine specs can define this when they replace a built-in engine spec
+ replaces: set["BaseEngineSpec"] = set()
+
+ # schema of parameters needed to build the SQLAlchemy URI
+ parameters_schema: Schema | None = None
+ encryption_parameters: dict[str, str] = {}
disable_ssh_tunneling = False
@@ -398,9 +405,9 @@
max_column_name_length: int | None = None
try_remove_schema_from_table_name = True # pylint: disable=invalid-name
run_multiple_statements_as_one = False
- custom_errors: dict[
- Pattern[str], tuple[str, SupersetErrorType, dict[str, Any]]
- ] = {}
+ custom_errors: dict[Pattern[str], tuple[str, SupersetErrorType, dict[str, Any]]] = (
+ {}
+ )
# List of JSON path to fields in `encrypted_extra` that should be masked when the
# database is edited. By default everything is masked.
@@ -614,6 +621,46 @@
return cls.allows_alias_in_select
@classmethod
+ def build_sqlalchemy_uri( # pylint: disable=unused-argument
+ cls,
+ parameters: dict[str, Any],
+ encrypted_extra: dict[str, str] | None = None,
+ ) -> str:
+ """
+ Method to build SQLAlchemy URI from discrete parameters.
+
+ This requires a Marshmallow schema to be set in the `parameters_schema` class
+ attribute.
+ """
+ raise NotImplementedError()
+
+ @classmethod
+ def get_parameters_from_uri( # pylint: disable=unused-argument
+ cls,
+ uri: str,
+ encrypted_extra: dict[str, Any] | None = None,
+ ) -> dict[str, Any]:
+ """
+ Method to extract parameters from SQLAlchemy URI.
+
+ This is the opposite of `build_sqlalchemy_uri`.
+ """
+ raise NotImplementedError()
+
+ @classmethod
+ def validate_parameters(
+ cls,
+ properties: BasicPropertiesType,
+ ) -> list[SupersetError]:
+ """
+ Validates parameters.
+
+ See the `BasicParametersMixin` class for an example of progressive validation of
+ parameters.
+ """
+ return []
+
+ @classmethod
def supports_url(cls, url: URL) -> bool:
"""
Returns true if the DB engine spec supports a given SQLAlchemy URL.
@@ -2385,13 +2432,6 @@
# schema describing the parameters used to configure the DB
parameters_schema = BasicParametersSchema()
- # recommended driver name for the DB engine spec
- default_driver = ""
-
- # query parameter to enable encryption in the database connection
- # for Postgres this would be `{"sslmode": "verify-ca"}`, eg.
- encryption_parameters: dict[str, str] = {}
-
@classmethod
def build_sqlalchemy_uri( # pylint: disable=unused-argument
cls,
@@ -2422,7 +2462,9 @@
@classmethod
def get_parameters_from_uri( # pylint: disable=unused-argument
- cls, uri: str, encrypted_extra: dict[str, Any] | None = None
+ cls,
+ uri: str,
+ encrypted_extra: dict[str, Any] | None = None,
) -> BasicParametersType:
url = make_url_safe(uri)
query = {
@@ -2497,6 +2539,8 @@
extra={"invalid": ["port"]},
),
)
+ return errors
+
if not (isinstance(port, int) and 0 <= port < 2**16):
errors.append(
SupersetError(
@@ -2519,20 +2563,3 @@
)
return errors
-
- @classmethod
- def parameters_json_schema(cls) -> Any:
- """
- Return configuration parameters as OpenAPI.
- """
- if not cls.parameters_schema:
- return None
-
- spec = APISpec(
- title="Database Parameters",
- version="1.0.0",
- openapi_version="3.0.2",
- plugins=[MarshmallowPlugin()],
- )
- spec.components.schema(cls.__name__, schema=cls.parameters_schema)
- return spec.to_dict()["components"]["schemas"][cls.__name__]
diff --git a/superset/db_engine_specs/bigquery.py b/superset/db_engine_specs/bigquery.py
index c6c57b1..959b2ac 100644
--- a/superset/db_engine_specs/bigquery.py
+++ b/superset/db_engine_specs/bigquery.py
@@ -640,26 +640,6 @@
return []
@classmethod
- def parameters_json_schema(cls) -> Any:
- """
- Return configuration parameters as OpenAPI.
- """
- if not cls.parameters_schema:
- return None
-
- spec = APISpec(
- title="Database Parameters",
- version="1.0.0",
- openapi_version="3.0.0",
- plugins=[ma_plugin],
- )
-
- ma_plugin.init_spec(spec)
- ma_plugin.converter.add_attribute_function(encrypted_field_properties)
- spec.components.schema(cls.__name__, schema=cls.parameters_schema)
- return spec.to_dict()["components"]["schemas"][cls.__name__]
-
- @classmethod
def select_star( # pylint: disable=too-many-arguments
cls,
database: Database,
diff --git a/superset/db_engine_specs/databricks.py b/superset/db_engine_specs/databricks.py
index 7574989..25d580f 100644
--- a/superset/db_engine_specs/databricks.py
+++ b/superset/db_engine_specs/databricks.py
@@ -415,22 +415,6 @@
"encryption": encryption,
}
- @classmethod
- def parameters_json_schema(cls) -> Any:
- """
- Return configuration parameters as OpenAPI.
- """
- if not cls.properties_schema:
- return None
-
- spec = APISpec(
- title="Database Parameters",
- version="1.0.0",
- openapi_version="3.0.2",
- plugins=[MarshmallowPlugin()],
- )
- spec.components.schema(cls.__name__, schema=cls.properties_schema)
- return spec.to_dict()["components"]["schemas"][cls.__name__]
@classmethod
def get_default_catalog(
diff --git a/superset/db_engine_specs/duckdb.py b/superset/db_engine_specs/duckdb.py
index a5eba36..3a39844 100644
--- a/superset/db_engine_specs/duckdb.py
+++ b/superset/db_engine_specs/duckdb.py
@@ -171,23 +171,6 @@
return errors
- @classmethod
- def parameters_json_schema(cls) -> Any:
- """
- Return configuration parameters as OpenAPI.
- """
- if not cls.parameters_schema:
- return None
-
- spec = APISpec(
- title="Database Parameters",
- version="1.0.0",
- openapi_version="3.0.2",
- plugins=[MarshmallowPlugin()],
- )
- spec.components.schema(cls.__name__, schema=cls.parameters_schema)
- return spec.to_dict()["components"]["schemas"][cls.__name__]
-
class DuckDBEngineSpec(DuckDBParametersMixin, BaseEngineSpec):
engine = "duckdb"
diff --git a/superset/db_engine_specs/gsheets.py b/superset/db_engine_specs/gsheets.py
index 27416fc..176f6ec 100644
--- a/superset/db_engine_specs/gsheets.py
+++ b/superset/db_engine_specs/gsheets.py
@@ -206,26 +206,6 @@
raise ValidationError("Invalid service credentials")
@classmethod
- def parameters_json_schema(cls) -> Any:
- """
- Return configuration parameters as OpenAPI.
- """
- if not cls.parameters_schema:
- return None
-
- spec = APISpec(
- title="Database Parameters",
- version="1.0.0",
- openapi_version="3.0.0",
- plugins=[ma_plugin],
- )
-
- ma_plugin.init_spec(spec)
- ma_plugin.converter.add_attribute_function(encrypted_field_properties)
- spec.components.schema(cls.__name__, schema=cls.parameters_schema)
- return spec.to_dict()["components"]["schemas"][cls.__name__]
-
- @classmethod
def validate_parameters(
cls,
properties: GSheetsPropertiesType,
diff --git a/superset/db_engine_specs/snowflake.py b/superset/db_engine_specs/snowflake.py
index 5c9c42e..4be4e60 100644
--- a/superset/db_engine_specs/snowflake.py
+++ b/superset/db_engine_specs/snowflake.py
@@ -350,24 +350,6 @@
)
return errors
- @classmethod
- def parameters_json_schema(cls) -> Any:
- """
- Return configuration parameters as OpenAPI.
- """
- if not cls.parameters_schema:
- return None
-
- ma_plugin = MarshmallowPlugin()
- spec = APISpec(
- title="Database Parameters",
- version="1.0.0",
- openapi_version="3.0.0",
- plugins=[ma_plugin],
- )
-
- spec.components.schema(cls.__name__, schema=cls.parameters_schema)
- return spec.to_dict()["components"]["schemas"][cls.__name__]
@staticmethod
def update_params_from_encrypted_extra(
diff --git a/superset/models/core.py b/superset/models/core.py
index 9378452..86c9088 100755
--- a/superset/models/core.py
+++ b/superset/models/core.py
@@ -127,7 +127,9 @@
DYNAMIC_FORM = "dynamic_form"
-class Database(Model, AuditMixinNullable, ImportExportMixin): # pylint: disable=too-many-public-methods
+class Database(
+ Model, AuditMixinNullable, ImportExportMixin
+): # pylint: disable=too-many-public-methods
"""An ORM object that stores Database related information"""
__tablename__ = "dbs"
@@ -306,16 +308,11 @@
if (masked_encrypted_extra := self.masked_encrypted_extra) is not None:
with suppress(TypeError, json.JSONDecodeError):
encrypted_config = json.loads(masked_encrypted_extra)
- try:
- # pylint: disable=useless-suppression
- parameters = self.db_engine_spec.get_parameters_from_uri( # type: ignore
- masked_uri,
- encrypted_extra=encrypted_config,
- )
- except Exception: # pylint: disable=broad-except
- parameters = {}
- return parameters
+ return self.db_engine_spec.get_parameters_from_uri(
+ masked_uri,
+ encrypted_extra=encrypted_config,
+ )
@property
def parameters_schema(self) -> dict[str, Any]:
@@ -402,9 +399,7 @@
return (
username
if (username := get_username())
- else object_url.username
- if self.impersonate_user
- else None
+ else object_url.username if self.impersonate_user else None
)
@contextmanager
diff --git a/superset/utils/database.py b/superset/utils/database.py
index 719e7f2..a19fc9a 100644
--- a/superset/utils/database.py
+++ b/superset/utils/database.py
@@ -17,15 +17,20 @@
from __future__ import annotations
import logging
-from typing import TYPE_CHECKING
+from typing import Any, TYPE_CHECKING
+from apispec import APISpec
+from apispec.ext.marshmallow import MarshmallowPlugin
from flask import current_app
from superset.constants import EXAMPLES_DB_UUID
if TYPE_CHECKING:
+ from marshmallow import Schema
+
from superset.connectors.sqla.models import Database
+
logging.getLogger("MARKDOWN").setLevel(logging.INFO)
logger = logging.getLogger(__name__)
@@ -80,3 +85,28 @@
db.session.delete(database)
db.session.flush()
+
+
+def parameters_json_schema(
+ name: str,
+ parameters_schema: Schema | None,
+) -> dict[str, Any]:
+ """
+ Return configuration parameters as OpenAPI.
+ """
+ if not parameters_schema:
+ return {}
+
+ spec = APISpec(
+ title="Database Parameters",
+ version="1.0.0",
+ openapi_version="3.0.2",
+ plugins=[MarshmallowPlugin()],
+ )
+ spec.components.schema(name, schema=parameters_schema)
+ json_schema = spec.to_dict()["components"]["schemas"][name]
+
+ # preserve field order
+ json_schema["order"] = list(json_schema["properties"].keys())
+
+ return json_schema