diff --git a/superset-frontend/src/components/Form/LabeledErrorBoundInput.tsx b/superset-frontend/src/components/Form/LabeledErrorBoundInput.tsx index 6b0feb7268f7e..c882511395547 100644 --- a/superset-frontend/src/components/Form/LabeledErrorBoundInput.tsx +++ b/superset-frontend/src/components/Form/LabeledErrorBoundInput.tsx @@ -21,6 +21,7 @@ import { Input, Tooltip } from 'antd'; import { styled, css, SupersetTheme, t } from '@superset-ui/core'; import InfoTooltip from 'src/components/InfoTooltip'; import Icons from 'src/components/Icons'; +import Button from 'src/components/Button'; import errorIcon from 'src/assets/images/icons/error.svg'; import FormItem from './FormItem'; import FormLabel from './FormLabel'; @@ -109,6 +110,8 @@ const LabeledErrorBoundInput = ({ id, className, visibilityToggle, + get_url, + description, ...props }: LabeledErrorBoundInputProps) => ( @@ -149,6 +152,21 @@ const LabeledErrorBoundInput = ({ ) : ( )} + {get_url && description ? ( + + ) : ( +
+ )}
); diff --git a/superset-frontend/src/features/databases/DatabaseModal/DatabaseConnectionForm/CommonParameters.tsx b/superset-frontend/src/features/databases/DatabaseModal/DatabaseConnectionForm/CommonParameters.tsx index 3f1f5f9625fef..529fc18419e4c 100644 --- a/superset-frontend/src/features/databases/DatabaseModal/DatabaseConnectionForm/CommonParameters.tsx +++ b/superset-frontend/src/features/databases/DatabaseModal/DatabaseConnectionForm/CommonParameters.tsx @@ -163,6 +163,8 @@ export const accessTokenField = ({ validationErrors, db, isEditMode, + default_value, + description, }: FieldPropTypes) => ( diff --git a/superset-frontend/src/features/databases/DatabaseModal/DatabaseConnectionForm/index.tsx b/superset-frontend/src/features/databases/DatabaseModal/DatabaseConnectionForm/index.tsx index fc076b624f0e8..509103ea29fe5 100644 --- a/superset-frontend/src/features/databases/DatabaseModal/DatabaseConnectionForm/index.tsx +++ b/superset-frontend/src/features/databases/DatabaseModal/DatabaseConnectionForm/index.tsx @@ -169,6 +169,8 @@ const DatabaseConnectionForm = ({ db, key: field, field, + default_value: parameters.properties[field]?.default, + description: parameters.properties[field]?.description, isEditMode, sslForced, editNewDb, diff --git a/superset-frontend/src/features/databases/DatabaseModal/ModalHeader.tsx b/superset-frontend/src/features/databases/DatabaseModal/ModalHeader.tsx index 9825489a7b80a..6245edb145280 100644 --- a/superset-frontend/src/features/databases/DatabaseModal/ModalHeader.tsx +++ b/superset-frontend/src/features/databases/DatabaseModal/ModalHeader.tsx @@ -149,8 +149,7 @@ const ModalHeader = ({ target="_blank" rel="noopener noreferrer" > - {t('connecting to %(dbModelName)s.', { dbModelName: dbModel.name })} - . + {t('connecting to %(dbModelName)s', { dbModelName: dbModel.name })}.

diff --git a/superset-frontend/src/features/databases/types.ts b/superset-frontend/src/features/databases/types.ts index 2dff61d5e8a35..06799ebaface1 100644 --- a/superset-frontend/src/features/databases/types.ts +++ b/superset-frontend/src/features/databases/types.ts @@ -291,6 +291,8 @@ export interface FieldPropTypes { db?: DatabaseObject; dbModel?: DatabaseForm; field: string; + default_value?: any; + description?: string; isEditMode?: boolean; sslForced?: boolean; defaultDBName?: string; diff --git a/superset/db_engine_specs/duckdb.py b/superset/db_engine_specs/duckdb.py index fc8efdaa31616..56539d6652065 100644 --- a/superset/db_engine_specs/duckdb.py +++ b/superset/db_engine_specs/duckdb.py @@ -19,16 +19,21 @@ import re from datetime import datetime from re import Pattern -from typing import Any, TYPE_CHECKING +from typing import Any, TYPE_CHECKING, TypedDict -from flask_babel import gettext as __ +from apispec import APISpec +from apispec.ext.marshmallow import MarshmallowPlugin +from flask_babel import gettext as __, lazy_gettext as _ +from marshmallow import fields, Schema from sqlalchemy import types from sqlalchemy.engine.reflection import Inspector +from sqlalchemy.engine.url import URL from superset.config import VERSION_STRING from superset.constants import TimeGrain, USER_AGENT +from superset.databases.utils import make_url_safe from superset.db_engine_specs.base import BaseEngineSpec -from superset.errors import SupersetErrorType +from superset.errors import ErrorLevel, SupersetError, SupersetErrorType if TYPE_CHECKING: # prevent circular imports @@ -36,11 +41,158 @@ COLUMN_DOES_NOT_EXIST_REGEX = re.compile("no such column: (?P.+)") +DEFAULT_ACCESS_TOKEN_URL = ( + "https://app.motherduck.com/token-request?appName=Superset&close=y" +) -class DuckDBEngineSpec(BaseEngineSpec): +# schema for adding a database by providing parameters instead of the +# full SQLAlchemy URI +class DuckDBParametersSchema(Schema): + access_token = fields.String( + allow_none=True, + metadata={"description": __("MotherDuck token")}, + load_default=DEFAULT_ACCESS_TOKEN_URL, + ) + database = fields.String( + required=False, metadata={"description": __("Database name")} + ) + query = fields.Dict( + keys=fields.Str(), + values=fields.Raw(), + metadata={"description": __("Additional parameters")}, + ) + + +class DuckDBParametersType(TypedDict, total=False): + access_token: str | None + database: str + query: dict[str, Any] + + +class DuckDBPropertiesType(TypedDict): + parameters: DuckDBParametersType + + +class DuckDBParametersMixin: + """ + Mixin for configuring DB engine specs via a dictionary. + + With this mixin the SQLAlchemy engine can be configured through + individual parameters, instead of the full SQLAlchemy URI. This + mixin is for DuckDB: + + duckdb:///file_path[?key=value&key=value...] + duckdb:///md:database[?key=value&key=value...] + + """ + + engine = "duckdb" + + # schema describing the parameters used to configure the DB + parameters_schema = DuckDBParametersSchema() + + # 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] = {} + + @staticmethod + def _is_motherduck(database: str) -> bool: + return "md:" in database + + @classmethod + def build_sqlalchemy_uri( # pylint: disable=unused-argument + cls, + parameters: DuckDBParametersType, + encrypted_extra: dict[str, str] | None = None, + ) -> str: + """ + Build SQLAlchemy URI for connecting to a DuckDB database. + If an access token is specified, return a URI to connect to a MotherDuck database. + """ + if parameters is None: + parameters = {} + query = parameters.get("query", {}) + database = parameters.get("database", ":memory:") + token = parameters.get("access_token") + + if cls._is_motherduck(database) or ( + token and token != DEFAULT_ACCESS_TOKEN_URL + ): + return MotherDuckEngineSpec.build_sqlalchemy_uri(parameters) + + return str(URL(drivername=cls.engine, database=database, query=query)) + + @classmethod + def get_parameters_from_uri( # pylint: disable=unused-argument + cls, uri: str, encrypted_extra: dict[str, Any] | None = None + ) -> DuckDBParametersType: + url = make_url_safe(uri) + query = { + key: value + for (key, value) in url.query.items() + if (key, value) not in cls.encryption_parameters.items() + } + access_token = query.pop("motherduck_token", "") + return { + "access_token": access_token, + "database": url.database, + "query": query, + } + + @classmethod + def validate_parameters( + cls, properties: DuckDBPropertiesType + ) -> list[SupersetError]: + """ + Validates any number of parameters, for progressive validation. + """ + errors: list[SupersetError] = [] + + parameters = properties.get("parameters", {}) + if cls._is_motherduck(parameters.get("database", "")): + required = {"access_token"} + else: + required = set() + present = {key for key in parameters if parameters.get(key, ())} + + if missing := sorted(required - present): + errors.append( + SupersetError( + message=f'One or more parameters are missing: {", ".join(missing)}', + error_type=SupersetErrorType.CONNECTION_MISSING_PARAMETERS_ERROR, + level=ErrorLevel.WARNING, + extra={"missing": missing}, + ), + ) + + 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" engine_name = "DuckDB" + default_driver = "duckdb_engine" sqlalchemy_uri_placeholder = "duckdb:////path/to/duck.db" @@ -103,9 +255,41 @@ def get_extra_params(database: Database) -> dict[str, Any]: class MotherDuckEngineSpec(DuckDBEngineSpec): - engine = "duckdb" + engine = "motherduck" engine_name = "MotherDuck" + engine_aliases: set[str] = {"duckdb"} sqlalchemy_uri_placeholder = ( "duckdb:///md:{database_name}?motherduck_token={SERVICE_TOKEN}" ) + + @staticmethod + def _is_motherduck(database: str) -> bool: + return True + + @classmethod + def build_sqlalchemy_uri( + cls, + parameters: DuckDBParametersType, + encrypted_extra: dict[str, str] | None = None, + ) -> str: + """ + Build SQLAlchemy URI for connecting to a MotherDuck database + """ + # make a copy so that we don't update the original + query = parameters.get("query", {}).copy() + database = parameters.get("database", "") + token = parameters.get("access_token", "") + + if not database.startswith("md:"): + database = f"md:{database}" + if token and token != DEFAULT_ACCESS_TOKEN_URL: + query["motherduck_token"] = token + else: + raise ValueError( + f"Need MotherDuck token to connect to database '{database}'." + ) + + return str( + URL(drivername=DuckDBEngineSpec.engine, database=database, query=query) + ) diff --git a/tests/unit_tests/db_engine_specs/test_duckdb.py b/tests/unit_tests/db_engine_specs/test_duckdb.py index 39c70470f4ede..1e33522def4af 100644 --- a/tests/unit_tests/db_engine_specs/test_duckdb.py +++ b/tests/unit_tests/db_engine_specs/test_duckdb.py @@ -72,3 +72,56 @@ def test_get_extra_params(mocker: MockerFixture) -> None: } } } + + +def test_build_sqlalchemy_uri() -> None: + """Test DuckDBEngineSpec.build_sqlalchemy_uri""" + from superset.db_engine_specs.duckdb import DuckDBEngineSpec, DuckDBParametersType + + # No database provided, default to :memory: + parameters = DuckDBParametersType() + uri = DuckDBEngineSpec.build_sqlalchemy_uri(parameters) + assert "duckdb:///:memory:" == uri + + # Database provided + parameters = DuckDBParametersType(database="/path/to/duck.db") + uri = DuckDBEngineSpec.build_sqlalchemy_uri(parameters) + assert "duckdb:////path/to/duck.db" == uri + + +def test_md_build_sqlalchemy_uri() -> None: + """Test MotherDuckEngineSpec.build_sqlalchemy_uri""" + from superset.db_engine_specs.duckdb import ( + DuckDBParametersType, + MotherDuckEngineSpec, + ) + + # No access token provided, throw ValueError + parameters = DuckDBParametersType(database="my_db") + with pytest.raises(ValueError): + MotherDuckEngineSpec.build_sqlalchemy_uri(parameters) + + # No database provided, default to "md:" + parameters = DuckDBParametersType(access_token="token") + uri = MotherDuckEngineSpec.build_sqlalchemy_uri(parameters) + assert "duckdb:///md:?motherduck_token=token" + + # Database and access_token provided + parameters = DuckDBParametersType(database="my_db", access_token="token") + uri = MotherDuckEngineSpec.build_sqlalchemy_uri(parameters) + assert "duckdb:///md:my_db?motherduck_token=token" == uri + + +def test_get_parameters_from_uri() -> None: + from superset.db_engine_specs.duckdb import DuckDBEngineSpec + + uri = "duckdb:////path/to/duck.db" + parameters = DuckDBEngineSpec.get_parameters_from_uri(uri) + + assert parameters["database"] == "/path/to/duck.db" + + uri = "duckdb:///md:my_db?motherduck_token=token" + parameters = DuckDBEngineSpec.get_parameters_from_uri(uri) + + assert parameters["database"] == "md:my_db" + assert parameters["access_token"] == "token"