Compare commits

...

1 Commits

Author SHA1 Message Date
Beto Dealmeida
f97b0ead9c WIP 2025-04-02 20:27:18 -04:00
18 changed files with 425 additions and 259 deletions

View File

@@ -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 @@ const StyledInput = styled(Input)`
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 @@ const iconReset = css`
}
`;
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 @@ const LabeledErrorBoundInput = ({
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"

View File

@@ -55,21 +55,19 @@ export const portField = ({
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,

View File

@@ -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}
/>
);
};

View File

@@ -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 @@ const DatabaseConnectionForm = ({
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;

View File

@@ -315,13 +315,12 @@ export interface FieldPropTypes {
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 @@ export interface DatabaseConnectionFormProps {
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;
}

View File

@@ -1148,8 +1148,8 @@ SQLLAB_CTAS_NO_LIMIT = False
# 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 @@ EXCLUDE_USERS_FROM_LISTS: list[str] | None = None
# 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

View File

@@ -243,3 +243,8 @@ class CacheRegion(StrEnum):
DEFAULT = "default"
DATA = "data"
THUMBNAIL = "thumbnail"
DEFAULT_SQLALCHEMY_PLACEHOLDER = (
"engine+driver://user:password@host:port/dbname[?key1=value1&key2=value2...]"
)

View File

@@ -72,7 +72,10 @@ from superset.commands.importers.exceptions import (
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 @@ from superset.utils.core import (
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 @@ class DatabaseRestApi(BaseSupersetModelRestApi):
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)

View File

@@ -118,13 +118,11 @@ def make_url_safe(raw_url: str | URL) -> URL:
: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

View File

@@ -61,9 +61,26 @@ def is_engine_spec(obj: Any) -> bool:
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 @@ def load_engine_specs() -> list[type[BaseEngineSpec]]:
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

View File

@@ -40,8 +40,6 @@ from uuid import uuid4
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 sqlalchemy.types import TypeEngine
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 @@ builtin_time_grains: dict[str | None, str] = {
}
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 @@ class BaseEngineSpec: # pylint: disable=too-many-public-methods
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 @@ class BaseEngineSpec: # pylint: disable=too-many-public-methods
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.
@@ -613,6 +620,46 @@ class BaseEngineSpec: # pylint: disable=too-many-public-methods
"""
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:
"""
@@ -2385,13 +2432,6 @@ class BasicParametersMixin:
# 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 @@ class BasicParametersMixin:
@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 @@ class BasicParametersMixin:
extra={"invalid": ["port"]},
),
)
return errors
if not (isinstance(port, int) and 0 <= port < 2**16):
errors.append(
SupersetError(
@@ -2519,20 +2563,3 @@ class BasicParametersMixin:
)
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__]

View File

@@ -639,26 +639,6 @@ class BigQueryEngineSpec(BaseEngineSpec): # pylint: disable=too-many-public-met
) -> list[SupersetError]:
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,

View File

@@ -415,22 +415,6 @@ class DatabricksNativeEngineSpec(DatabricksDynamicBaseEngineSpec):
"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(

View File

@@ -171,23 +171,6 @@ class DuckDBParametersMixin:
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"

View File

@@ -205,26 +205,6 @@ class GSheetsEngineSpec(ShillelaghEngineSpec):
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,

View File

@@ -350,24 +350,6 @@ class SnowflakeEngineSpec(PostgresBaseEngineSpec):
)
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(

View File

@@ -127,7 +127,9 @@ class ConfigurationMethod(StrEnum):
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 @@ class Database(Model, AuditMixinNullable, ImportExportMixin): # pylint: disable
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 @@ class Database(Model, AuditMixinNullable, ImportExportMixin): # pylint: disable
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

View File

@@ -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 @@ def remove_database(database: Database) -> None:
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