mirror of
https://github.com/apache/superset.git
synced 2026-04-30 13:34:20 +00:00
Compare commits
1 Commits
fix-duckdb
...
better-db-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f97b0ead9c |
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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...]"
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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__]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user