This is an automated email from the ASF dual-hosted git repository.
beto pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new e7ad03d feat: add endpoint to fetch available DBs (#14208)
e7ad03d is described below
commit e7ad03d44f9ed2e92784b629d854100e919cf8e7
Author: Beto Dealmeida <[email protected]>
AuthorDate: Fri Apr 23 10:51:47 2021 -0700
feat: add endpoint to fetch available DBs (#14208)
* feat: add endpoint to fetch available DBs
* Fix lint
---
superset/config.py | 12 +++
superset/databases/api.py | 72 +++++++++++++++++-
superset/databases/schemas.py | 78 ++++++++++++++++++--
superset/db_engine_specs/__init__.py | 25 ++++++-
superset/db_engine_specs/base.py | 97 ++++++++++++++++++++++++-
superset/db_engine_specs/postgres.py | 12 ++-
tests/databases/api_tests.py | 67 ++++++++++++++++-
tests/databases/schema_tests.py | 125 ++++++++++++++++++++++++++++++++
tests/db_engine_specs/postgres_tests.py | 41 +++++++++++
9 files changed, 511 insertions(+), 18 deletions(-)
diff --git a/superset/config.py b/superset/config.py
index 2a27055..7143a0a 100644
--- a/superset/config.py
+++ b/superset/config.py
@@ -1064,6 +1064,18 @@ SQL_VALIDATORS_BY_ENGINE = {
"postgresql": "PostgreSQLValidator",
}
+# A list of preferred databases, in order. These databases will be
+# displayed prominently in the "Add Database" dialog. You should
+# use the "engine" attribute of the corresponding DB engine spec in
+# `superset/db_engine_specs/`.
+PREFERRED_DATABASES: List[str] = [
+ # "postgresql",
+ # "presto",
+ # "mysql",
+ # "sqlite",
+ # etc.
+]
+
# Do you want Talisman enabled?
TALISMAN_ENABLED = False
# If you want Talisman, how do you want it configured??
diff --git a/superset/databases/api.py b/superset/databases/api.py
index 970f61a..390d346 100644
--- a/superset/databases/api.py
+++ b/superset/databases/api.py
@@ -18,7 +18,7 @@ import json
import logging
from datetime import datetime
from io import BytesIO
-from typing import Any, Optional
+from typing import Any, Dict, List, Optional
from zipfile import ZipFile
from flask import g, request, Response, send_file
@@ -27,7 +27,7 @@ from flask_appbuilder.models.sqla.interface import
SQLAInterface
from marshmallow import ValidationError
from sqlalchemy.exc import NoSuchTableError, OperationalError, SQLAlchemyError
-from superset import event_logger
+from superset import app, event_logger
from superset.commands.exceptions import CommandInvalidError
from superset.commands.importers.v1.utils import get_contents_from_bundle
from superset.constants import MODEL_API_RW_METHOD_PERMISSION_MAP, RouteMethod
@@ -63,6 +63,8 @@ from superset.databases.schemas import (
TableMetadataResponseSchema,
)
from superset.databases.utils import get_table_metadata
+from superset.db_engine_specs import get_available_engine_specs
+from superset.db_engine_specs.base import BaseParametersMixin
from superset.extensions import security_manager
from superset.models.core import Database
from superset.typing import FlaskResponse
@@ -84,6 +86,7 @@ class DatabaseRestApi(BaseSupersetModelRestApi):
"test_connection",
"related_objects",
"function_names",
+ "available",
}
resource_name = "database"
class_permission_name = "Database"
@@ -822,7 +825,6 @@ class DatabaseRestApi(BaseSupersetModelRestApi):
type: integer
responses:
200:
- 200:
description: Query result
content:
application/json:
@@ -839,3 +841,67 @@ class DatabaseRestApi(BaseSupersetModelRestApi):
if not database:
return self.response_404()
return self.response(200, function_names=database.function_names,)
+
+ @expose("/available/", methods=["GET"])
+ @protect()
+ @statsd_metrics
+ @event_logger.log_this_with_context(
+ action=lambda self, *args, **kwargs: f"{self.__class__.__name__}"
f".available",
+ log_to_statsd=False,
+ )
+ def available(self) -> Response:
+ """Return names of databases currently available
+ ---
+ get:
+ description:
+ Get names of databases currently available
+ responses:
+ 200:
+ description: Database names
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ type: object
+ properties:
+ name:
+ description: Name of the database
+ type: string
+ preferred:
+ description: Is the database preferred?
+ type: bool
+ sqlalchemy_uri_placeholder:
+ description: Example placeholder for the SQLAlchemy
URI
+ type: string
+ parameters:
+ description: JSON schema defining the needed
parameters
+ 400:
+ $ref: '#/components/responses/400'
+ 500:
+ $ref: '#/components/responses/500'
+ """
+ preferred_databases: List[str] = app.config.get("PREFERRED_DATABASES",
[])
+ available_databases = []
+ for engine_spec in get_available_engine_specs():
+ payload: Dict[str, Any] = {
+ "name": engine_spec.engine_name,
+ "engine": engine_spec.engine,
+ "preferred": engine_spec.engine in preferred_databases,
+ }
+
+ if issubclass(engine_spec, BaseParametersMixin):
+ payload["parameters"] = engine_spec.parameters_json_schema()
+ payload[
+ "sqlalchemy_uri_placeholder"
+ ] = engine_spec.sqlalchemy_uri_placeholder
+
+ available_databases.append(payload)
+
+ available_databases.sort(
+ key=lambda payload: preferred_databases.index(payload["engine"])
+ if payload["engine"] in preferred_databases
+ else len(preferred_databases)
+ )
+
+ return self.response(200, databases=available_databases)
diff --git a/superset/databases/schemas.py b/superset/databases/schemas.py
index 6c6acc7..dcb2579 100644
--- a/superset/databases/schemas.py
+++ b/superset/databases/schemas.py
@@ -21,12 +21,14 @@ from typing import Any, Dict
from flask import current_app
from flask_babel import lazy_gettext as _
-from marshmallow import fields, Schema, validates_schema
+from marshmallow import fields, pre_load, Schema, validates_schema
from marshmallow.validate import Length, ValidationError
from sqlalchemy import MetaData
from sqlalchemy.engine.url import make_url
from sqlalchemy.exc import ArgumentError
+from superset.db_engine_specs import get_engine_specs
+from superset.db_engine_specs.base import BaseParametersMixin
from superset.exceptions import CertificateException, SupersetSecurityException
from superset.models.core import PASSWORD_MASK
from superset.security.analytics_db_safety import check_sqlalchemy_uri
@@ -207,7 +209,72 @@ def extra_validator(value: str) -> str:
return value
-class DatabasePostSchema(Schema):
+class DatabaseParametersSchemaMixin:
+ """
+ Allow SQLAlchemy URI to be passed as separate parameters.
+
+ This mixing is a first step in allowing the users to test, create and
+ edit databases without having to know how to write a SQLAlchemy URI.
+ Instead, each databases defines the parameters that it takes (eg,
+ username, password, host, etc.) and the SQLAlchemy URI is built from
+ these parameters.
+
+ When using this mixin make sure that `sqlalchemy_uri` is not required.
+ """
+
+ parameters = fields.Dict(
+ keys=fields.Str(),
+ values=fields.Raw(),
+ description="DB-specific parameters for configuration",
+ )
+
+ # pylint: disable=no-self-use, unused-argument
+ @pre_load
+ def build_sqlalchemy_uri(
+ self, data: Dict[str, Any], **kwargs: Any
+ ) -> Dict[str, Any]:
+ """
+ Build SQLAlchemy URI from separate parameters.
+
+ This is used for databases that support being configured by individual
+ parameters (eg, username, password, host, etc.), instead of requiring
+ the constructed SQLAlchemy URI to be passed.
+ """
+ parameters = data.pop("parameters", None)
+ if parameters:
+ if "engine" not in parameters:
+ raise ValidationError(
+ [
+ _(
+ "An engine must be specified when passing "
+ "individual parameters to a database."
+ )
+ ]
+ )
+ engine = parameters["engine"]
+
+ engine_specs = get_engine_specs()
+ if engine not in engine_specs:
+ raise ValidationError(
+ [_('Engine "%(engine)s" is not a valid engine.',
engine=engine,)]
+ )
+ engine_spec = engine_specs[engine]
+ if not issubclass(engine_spec, BaseParametersMixin):
+ raise ValidationError(
+ [
+ _(
+ 'Engine spec "%(engine_spec)s" does not support '
+ "being configured via individual parameters.",
+ engine_spec=engine_spec.__name__,
+ )
+ ]
+ )
+
+ data["sqlalchemy_uri"] =
engine_spec.build_sqlalchemy_url(parameters)
+ return data
+
+
+class DatabasePostSchema(Schema, DatabaseParametersSchemaMixin):
database_name = fields.String(
description=database_name_description, required=True,
validate=Length(1, 250),
)
@@ -242,12 +309,11 @@ class DatabasePostSchema(Schema):
)
sqlalchemy_uri = fields.String(
description=sqlalchemy_uri_description,
- required=True,
validate=[Length(1, 1024), sqlalchemy_uri_validator],
)
-class DatabasePutSchema(Schema):
+class DatabasePutSchema(Schema, DatabaseParametersSchemaMixin):
database_name = fields.String(
description=database_name_description, allow_none=True,
validate=Length(1, 250),
)
@@ -282,12 +348,11 @@ class DatabasePutSchema(Schema):
)
sqlalchemy_uri = fields.String(
description=sqlalchemy_uri_description,
- allow_none=True,
validate=[Length(0, 1024), sqlalchemy_uri_validator],
)
-class DatabaseTestConnectionSchema(Schema):
+class DatabaseTestConnectionSchema(Schema, DatabaseParametersSchemaMixin):
database_name = fields.String(
description=database_name_description, allow_none=True,
validate=Length(1, 250),
)
@@ -305,7 +370,6 @@ class DatabaseTestConnectionSchema(Schema):
)
sqlalchemy_uri = fields.String(
description=sqlalchemy_uri_description,
- required=True,
validate=[Length(1, 1024), sqlalchemy_uri_validator],
)
diff --git a/superset/db_engine_specs/__init__.py
b/superset/db_engine_specs/__init__.py
index 747543f..a4e083c 100644
--- a/superset/db_engine_specs/__init__.py
+++ b/superset/db_engine_specs/__init__.py
@@ -32,8 +32,9 @@ import logging
import pkgutil
from importlib import import_module
from pathlib import Path
-from typing import Any, Dict, List, Type
+from typing import Any, Dict, List, Set, Type
+import sqlalchemy.databases
from pkg_resources import iter_entry_points
from superset.db_engine_specs.base import BaseEngineSpec
@@ -67,7 +68,7 @@ def get_engine_specs() -> Dict[str, Type[BaseEngineSpec]]:
try:
engine_spec = ep.load()
except Exception: # pylint: disable=broad-except
- logger.warning("Unable to load engine spec: %s", engine_spec)
+ logger.warning("Unable to load Superset DB engine spec: %s",
engine_spec)
continue
engine_specs.append(engine_spec)
@@ -82,3 +83,23 @@ def get_engine_specs() -> Dict[str, Type[BaseEngineSpec]]:
engine_specs_map[name] = engine_spec
return engine_specs_map
+
+
+def get_available_engine_specs() -> List[Type[BaseEngineSpec]]:
+ # native SQLAlchemy dialects
+ backends: Set[str] = {
+ getattr(sqlalchemy.databases, attr).dialect.name
+ for attr in sqlalchemy.databases.__all__
+ }
+
+ # installed 3rd-party dialects
+ for ep in iter_entry_points("sqlalchemy.dialects"):
+ try:
+ dialect = ep.load()
+ except Exception: # pylint: disable=broad-except
+ logger.warning("Unable to load SQLAlchemy dialect: %s", dialect)
+ else:
+ backends.add(dialect.name)
+
+ engine_specs = get_engine_specs()
+ return [engine_specs[backend] for backend in backends if backend in
engine_specs]
diff --git a/superset/db_engine_specs/base.py b/superset/db_engine_specs/base.py
index 5cdb2a0..970f510 100644
--- a/superset/db_engine_specs/base.py
+++ b/superset/db_engine_specs/base.py
@@ -30,6 +30,7 @@ from typing import (
NamedTuple,
Optional,
Pattern,
+ Set,
Tuple,
Type,
TYPE_CHECKING,
@@ -38,18 +39,22 @@ from typing import (
import pandas as pd
import sqlparse
+from apispec import APISpec
+from apispec.ext.marshmallow import MarshmallowPlugin
from flask import g
from flask_babel import gettext as __, lazy_gettext as _
+from marshmallow import fields, Schema
from sqlalchemy import column, DateTime, select, types
from sqlalchemy.engine.base import Engine
from sqlalchemy.engine.interfaces import Compiled, Dialect
from sqlalchemy.engine.reflection import Inspector
-from sqlalchemy.engine.url import URL
+from sqlalchemy.engine.url import make_url, URL
from sqlalchemy.ext.compiler import compiles
from sqlalchemy.orm import Session
from sqlalchemy.sql import quoted_name, text
from sqlalchemy.sql.expression import ColumnClause, Select, TextAsFrom
from sqlalchemy.types import String, TypeEngine, UnicodeText
+from typing_extensions import TypedDict
from superset import app, security_manager, sql_parse
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
@@ -150,7 +155,7 @@ class BaseEngineSpec: # pylint:
disable=too-many-public-methods
"""
engine = "base" # str as defined in sqlalchemy.engine.engine
- engine_aliases: Optional[Tuple[str]] = None
+ engine_aliases: Set[str] = set()
engine_name: Optional[
str
] = None # used for user messages, overridden in child classes
@@ -937,6 +942,7 @@ class BaseEngineSpec: # pylint:
disable=too-many-public-methods
:param cols: Columns to include in query
:return: SQL query
"""
+ # pylint: disable=redefined-outer-name
fields: Union[str, List[Any]] = "*"
cols = cols or []
if (show_cols or latest_partition) and not cols:
@@ -1293,3 +1299,90 @@ class BaseEngineSpec: # pylint:
disable=too-many-public-methods
sqla_type=column_type, generic_type=generic_type,
is_dttm=is_dttm
)
return None
+
+
+# schema for adding a database by providing parameters instead of the
+# full SQLAlchemy URI
+class BaseParametersSchema(Schema):
+ username = fields.String(allow_none=True, description=__("Username"))
+ password = fields.String(allow_none=True, description=__("Password"))
+ host = fields.String(required=True, description=__("Hostname or IP
address"))
+ port = fields.Integer(required=True, description=__("Database port"))
+ database = fields.String(required=True, description=__("Database name"))
+ query = fields.Dict(
+ keys=fields.Str(), values=fields.Raw(), description=__("Additinal
parameters")
+ )
+
+
+class BaseParametersType(TypedDict, total=False):
+ username: Optional[str]
+ password: Optional[str]
+ host: str
+ port: int
+ database: str
+ query: Dict[str, Any]
+
+
+class BaseParametersMixin:
+
+ """
+ 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 the most common pattern of URI:
+
+ drivername://user:password@host:port/dbname[?key=value&key=value...]
+
+ """
+
+ # schema describing the parameters used to configure the DB
+ parameters_schema = BaseParametersSchema()
+
+ # recommended driver name for the DB engine spec
+ drivername = ""
+
+ # placeholder with the SQLAlchemy URI template
+ sqlalchemy_uri_placeholder = (
+ "drivername://user:password@host:port/dbname[?key=value&key=value...]"
+ )
+
+ @classmethod
+ def build_sqlalchemy_url(cls, parameters: BaseParametersType) -> str:
+ return str(
+ URL(
+ cls.drivername,
+ username=parameters.get("username"),
+ password=parameters.get("password"),
+ host=parameters["host"],
+ port=parameters["port"],
+ database=parameters["database"],
+ query=parameters.get("query", {}),
+ )
+ )
+
+ @classmethod
+ def get_parameters_from_uri(cls, uri: str) -> BaseParametersType:
+ url = make_url(uri)
+ return {
+ "username": url.username,
+ "password": url.password,
+ "host": url.host,
+ "port": url.port,
+ "database": url.database,
+ "query": url.query,
+ }
+
+ @classmethod
+ def parameters_json_schema(cls) -> Any:
+ """
+ Return configuration parameters as OpenAPI.
+ """
+ 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/postgres.py
b/superset/db_engine_specs/postgres.py
index 92c0001..c2e6776 100644
--- a/superset/db_engine_specs/postgres.py
+++ b/superset/db_engine_specs/postgres.py
@@ -37,7 +37,7 @@ from sqlalchemy.dialects.postgresql import ARRAY,
DOUBLE_PRECISION, ENUM, JSON
from sqlalchemy.dialects.postgresql.base import PGInspector
from sqlalchemy.types import String, TypeEngine
-from superset.db_engine_specs.base import BaseEngineSpec
+from superset.db_engine_specs.base import BaseEngineSpec, BaseParametersMixin
from superset.errors import SupersetErrorType
from superset.exceptions import SupersetException
from superset.utils import core as utils
@@ -143,9 +143,15 @@ class PostgresBaseEngineSpec(BaseEngineSpec):
return "(timestamp 'epoch' + {col} * interval '1 second')"
-class PostgresEngineSpec(PostgresBaseEngineSpec):
+class PostgresEngineSpec(PostgresBaseEngineSpec, BaseParametersMixin):
engine = "postgresql"
- engine_aliases = ("postgres",)
+ engine_aliases = {"postgres"}
+
+ drivername = "postgresql+psycopg2"
+ sqlalchemy_uri_placeholder = (
+
"postgresql+psycopg2://user:password@host:port/dbname[?key=value&key=value...]"
+ )
+
max_column_name_length = 63
try_remove_schema_from_table_name = False
diff --git a/tests/databases/api_tests.py b/tests/databases/api_tests.py
index 39a73e6..fc12fe0 100644
--- a/tests/databases/api_tests.py
+++ b/tests/databases/api_tests.py
@@ -33,6 +33,8 @@ from sqlalchemy.sql import func
from superset import db, security_manager
from superset.connectors.sqla.models import SqlaTable
+from superset.db_engine_specs.mysql import MySQLEngineSpec
+from superset.db_engine_specs.postgres import PostgresEngineSpec
from superset.errors import SupersetError
from superset.models.core import Database
from superset.models.reports import ReportSchedule, ReportScheduleType
@@ -613,7 +615,8 @@ class TestDatabaseApi(SupersetTestCase):
assert "can_read" in data["permissions"]
assert "can_write" in data["permissions"]
assert "can_function_names" in data["permissions"]
- assert len(data["permissions"]) == 3
+ assert "can_available" in data["permissions"]
+ assert len(data["permissions"]) == 4
def test_get_invalid_database_table_metadata(self):
"""
@@ -1245,3 +1248,65 @@ class TestDatabaseApi(SupersetTestCase):
assert rv.status_code == 200
assert response == {"function_names": ["AVG", "MAX", "SUM"]}
+
+ @mock.patch("superset.databases.api.get_available_engine_specs")
+ @mock.patch("superset.databases.api.app")
+ def test_available(self, app, get_available_engine_specs):
+ app.config = {"PREFERRED_DATABASES": ["postgresql"]}
+ get_available_engine_specs.return_value = [
+ MySQLEngineSpec,
+ PostgresEngineSpec,
+ ]
+
+ self.login(username="admin")
+ uri = "api/v1/database/available/"
+
+ rv = self.client.get(uri)
+ response = json.loads(rv.data.decode("utf-8"))
+
+ assert rv.status_code == 200
+ assert response == {
+ "databases": [
+ {
+ "engine": "postgresql",
+ "name": "PostgreSQL",
+ "parameters": {
+ "properties": {
+ "database": {
+ "description": "Database name",
+ "type": "string",
+ },
+ "host": {
+ "description": "Hostname or IP address",
+ "type": "string",
+ },
+ "password": {
+ "description": "Password",
+ "nullable": True,
+ "type": "string",
+ },
+ "port": {
+ "description": "Database port",
+ "format": "int32",
+ "type": "integer",
+ },
+ "query": {
+ "additionalProperties": {},
+ "description": "Additinal parameters",
+ "type": "object",
+ },
+ "username": {
+ "description": "Username",
+ "nullable": True,
+ "type": "string",
+ },
+ },
+ "required": ["database", "host", "port"],
+ "type": "object",
+ },
+ "preferred": True,
+ "sqlalchemy_uri_placeholder":
"postgresql+psycopg2://user:password@host:port/dbname[?key=value&key=value...]",
+ },
+ {"engine": "mysql", "name": "MySQL", "preferred": False},
+ ]
+ }
diff --git a/tests/databases/schema_tests.py b/tests/databases/schema_tests.py
new file mode 100644
index 0000000..6d173cc
--- /dev/null
+++ b/tests/databases/schema_tests.py
@@ -0,0 +1,125 @@
+# 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.
+
+from unittest import mock
+
+from marshmallow import fields, Schema, ValidationError
+
+from superset.databases.schemas import DatabaseParametersSchemaMixin
+from superset.db_engine_specs.base import BaseParametersMixin
+
+
+class DummySchema(Schema, DatabaseParametersSchemaMixin):
+ sqlalchemy_uri = fields.String()
+
+
+class DummyEngine(BaseParametersMixin):
+ drivername = "dummy"
+
+
+class InvalidEngine:
+ pass
+
+
[email protected]("superset.databases.schemas.get_engine_specs")
+def test_database_parameters_schema_mixin(get_engine_specs):
+ get_engine_specs.return_value = {"dummy_engine": DummyEngine}
+ payload = {
+ "parameters": {
+ "engine": "dummy_engine",
+ "username": "username",
+ "password": "password",
+ "host": "localhost",
+ "port": 12345,
+ "database": "dbname",
+ }
+ }
+ schema = DummySchema()
+ result = schema.load(payload)
+ assert result == {
+ "sqlalchemy_uri": "dummy://username:password@localhost:12345/dbname"
+ }
+
+
+def test_database_parameters_schema_mixin_no_engine():
+ payload = {
+ "parameters": {
+ "username": "username",
+ "password": "password",
+ "host": "localhost",
+ "port": 12345,
+ "dbname": "dbname",
+ }
+ }
+ schema = DummySchema()
+ try:
+ schema.load(payload)
+ except ValidationError as err:
+ assert err.messages == {
+ "_schema": [
+ "An engine must be specified when passing individual
parameters to a database."
+ ]
+ }
+
+
[email protected]("superset.databases.schemas.get_engine_specs")
+def test_database_parameters_schema_mixin_invalid_engine(get_engine_specs):
+ get_engine_specs.return_value = {}
+ payload = {
+ "parameters": {
+ "engine": "dummy_engine",
+ "username": "username",
+ "password": "password",
+ "host": "localhost",
+ "port": 12345,
+ "dbname": "dbname",
+ }
+ }
+ schema = DummySchema()
+ try:
+ schema.load(payload)
+ except ValidationError as err:
+ assert err.messages == {
+ "_schema": ['Engine "dummy_engine" is not a valid engine.']
+ }
+
+
[email protected]("superset.databases.schemas.get_engine_specs")
+def test_database_parameters_schema_no_mixin(get_engine_specs):
+ get_engine_specs.return_value = {"invalid_engine": InvalidEngine}
+ payload = {
+ "parameters": {
+ "engine": "invalid_engine",
+ "username": "username",
+ "password": "password",
+ "host": "localhost",
+ "port": 12345,
+ "database": "dbname",
+ }
+ }
+ schema = DummySchema()
+ try:
+ schema.load(payload)
+ except ValidationError as err:
+ assert err.messages == {
+ "_schema": [
+ (
+ 'Engine spec "InvalidEngine" does not support '
+ "being configured via individual parameters."
+ )
+ ]
+ }
diff --git a/tests/db_engine_specs/postgres_tests.py
b/tests/db_engine_specs/postgres_tests.py
index b9fe0ca..6c8b8e5 100644
--- a/tests/db_engine_specs/postgres_tests.py
+++ b/tests/db_engine_specs/postgres_tests.py
@@ -388,3 +388,44 @@ psql: error: could not connect to server: Operation timed
out
},
)
]
+
+
+def test_base_parameters_mixin():
+ parameters = {
+ "username": "username",
+ "password": "password",
+ "host": "localhost",
+ "port": 5432,
+ "database": "dbname",
+ "query": {"foo": "bar"},
+ }
+ sqlalchemy_uri = PostgresEngineSpec.build_sqlalchemy_url(parameters)
+ assert (
+ sqlalchemy_uri
+ ==
"postgresql+psycopg2://username:password@localhost:5432/dbname?foo=bar"
+ )
+
+ parameters_from_uri =
PostgresEngineSpec.get_parameters_from_uri(sqlalchemy_uri)
+ assert parameters_from_uri == parameters
+
+ json_schema = PostgresEngineSpec.parameters_json_schema()
+ assert json_schema == {
+ "type": "object",
+ "properties": {
+ "host": {"type": "string", "description": "Hostname or IP
address"},
+ "username": {"type": "string", "nullable": True, "description":
"Username"},
+ "password": {"type": "string", "nullable": True, "description":
"Password"},
+ "database": {"type": "string", "description": "Database name"},
+ "query": {
+ "type": "object",
+ "description": "Additinal parameters",
+ "additionalProperties": {},
+ },
+ "port": {
+ "type": "integer",
+ "format": "int32",
+ "description": "Database port",
+ },
+ },
+ "required": ["database", "host", "port"],
+ }