Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add connector for CouchbaseDB #29225

Merged
merged 5 commits into from
Jul 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions docs/docs/configuration/databases.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ are compatible with Superset.
| [Azure MS SQL](/docs/configuration/databases#sql-server) | `pip install pymssql` | `mssql+pymssql://UserName@presetSQL:[email protected]:1433/TestSchema` |
| [ClickHouse](/docs/configuration/databases#clickhouse) | `pip install clickhouse-connect` | `clickhousedb://{username}:{password}@{hostname}:{port}/{database}` |
| [CockroachDB](/docs/configuration/databases#cockroachdb) | `pip install cockroachdb` | `cockroachdb://root@{hostname}:{port}/{database}?sslmode=disable` |
| [CouchbaseDB](/docs/configuration/databases#couchbaseDB) | `pip install couchbase-sqlalchemy` | `couchbasedb://{username}:{password}@{hostname}:{port}?truststorepath={ssl certificate path}` |
| [Dremio](/docs/configuration/databases#dremio) | `pip install sqlalchemy_dremio` | `dremio://user:pwd@host:31010/` |
| [Elasticsearch](/docs/configuration/databases#elasticsearch) | `pip install elasticsearch-dbapi` | `elasticsearch+http://{user}:{password}@{host}:9200/` |
| [Exasol](/docs/configuration/databases#exasol) | `pip install sqlalchemy-exasol` | `exa+pyodbc://{username}:{password}@{hostname}:{port}/my_schema?CONNECTIONLCALL=en_US.UTF-8&driver=EXAODBC` |
Expand Down Expand Up @@ -373,6 +374,22 @@ cockroachdb://root@{hostname}:{port}/{database}?sslmode=disable
```



#### CouchbaseDB

The recommended connector library for CouchbaseDB is
[couchbase-sqlalchemy](https://github.com/couchbase/couchbase-sqlalchemy).
```
pip install couchbase-sqlalchemy
```

The expected connection string is formatted as follows:

```
couchbasedb://{username}:{password}@{hostname}:{port}?truststorepath={certificate path}?ssl={true/false}
```


#### CrateDB

The recommended connector library for CrateDB is
Expand Down
5 changes: 5 additions & 0 deletions docs/src/resources/data.js
Original file line number Diff line number Diff line change
Expand Up @@ -127,4 +127,9 @@ export const Databases = [
href: 'https://www.oceanbase.com/',
imgName: 'oceanbase.svg',
},
{
title: 'Couchbase',
href: 'https://www.couchbase.com/',
imgName: 'couchbase.svg',
},
];
19 changes: 19 additions & 0 deletions docs/static/img/databases/couchbase.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
19 changes: 19 additions & 0 deletions superset-frontend/src/assets/images/couchbase.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
257 changes: 257 additions & 0 deletions superset/db_engine_specs/couchbasedb.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,257 @@
# 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.
# pylint: disable=too-many-lines

from __future__ import annotations

from datetime import datetime
from typing import Any, Optional, TypedDict
from urllib import parse

from flask_babel import gettext as __
from marshmallow import fields, Schema
from sqlalchemy.engine.url import URL

from superset.constants import TimeGrain
from superset.databases.utils import make_url_safe
from superset.db_engine_specs.base import (
BaseEngineSpec,
BasicParametersMixin,
BasicParametersType as BaseBasicParametersType,
BasicPropertiesType as BaseBasicPropertiesType,
)
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
from superset.utils.network import is_hostname_valid, is_port_open


class BasicParametersType(TypedDict, total=False):
username: Optional[str]
password: Optional[str]
host: str
database: str
port: Optional[int]
query: dict[str, Any]
encryption: bool


class BasicPropertiesType(TypedDict):
parameters: BasicParametersType


class CouchbaseParametersSchema(Schema):
username = fields.String(allow_none=True, metadata={"description": __("Username")})
password = fields.String(allow_none=True, metadata={"description": __("Password")})
host = fields.String(
required=True, metadata={"description": __("Hostname or IP address")}
)
database = fields.String(
allow_none=True, metadata={"description": __("Database name")}
)
port = fields.Integer(
allow_none=True, metadata={"description": __("Database port")}
)
encryption = fields.Boolean(
dump_default=False,
metadata={"description": __("Use an encrypted connection to the database")},
)
query = fields.Dict(
keys=fields.Str(),
values=fields.Raw(),
metadata={"description": __("Additional parameters")},
)


class CouchbaseDbEngineSpec(BasicParametersMixin, BaseEngineSpec):
engine = "couchbasedb"
engine_name = "Couchbase"
default_driver = "couchbasedb"
allows_joins = False
allows_subqueries = False
sqlalchemy_uri_placeholder = (
"couchbasedb://user:password@host[:port]?truststorepath=value?ssl=value"
)
parameters_schema = CouchbaseParametersSchema()

_time_grain_expressions = {
None: "{col}",
TimeGrain.SECOND: "DATE_TRUNC_STR(TOSTRING({col}),'second')",
TimeGrain.MINUTE: "DATE_TRUNC_STR(TOSTRING({col}),'minute')",
TimeGrain.HOUR: "DATE_TRUNC_STR(TOSTRING({col}),'hour')",
TimeGrain.DAY: "DATE_TRUNC_STR(TOSTRING({col}),'day')",
TimeGrain.MONTH: "DATE_TRUNC_STR(TOSTRING({col}),'month')",
TimeGrain.YEAR: "DATE_TRUNC_STR(TOSTRING({col}),'year')",
TimeGrain.QUARTER: "DATE_TRUNC_STR(TOSTRING({col}),'quarter')",
}

@classmethod
def epoch_to_dttm(cls) -> str:
return "MILLIS_TO_STR({col} * 1000)"

@classmethod
def epoch_ms_to_dttm(cls) -> str:
return "MILLIS_TO_STR({col})"

@classmethod
def convert_dttm(
cls, target_type: str, dttm: datetime, db_extra: Optional[dict[str, Any]] = None
) -> Optional[str]:
if target_type.lower() == "date":
formatted_date = dttm.date().isoformat()
else:
formatted_date = dttm.replace(microsecond=0).isoformat()
return f"DATETIME(DATE_FORMAT_STR(STR_TO_UTC('{formatted_date}'), 'iso8601'))"

@classmethod
def build_sqlalchemy_uri(
cls,
parameters: BaseBasicParametersType,
encrypted_extra: Optional[dict[str, Any]] = None,
) -> str:
query_params = parameters.get("query", {}).copy()
if parameters.get("encryption"):
query_params["ssl"] = "true"

Check warning on line 126 in superset/db_engine_specs/couchbasedb.py

View check run for this annotation

Codecov / codecov/patch

superset/db_engine_specs/couchbasedb.py#L124-L126

Added lines #L124 - L126 were not covered by tests
else:
query_params["ssl"] = "false"

Check warning on line 128 in superset/db_engine_specs/couchbasedb.py

View check run for this annotation

Codecov / codecov/patch

superset/db_engine_specs/couchbasedb.py#L128

Added line #L128 was not covered by tests

if parameters.get("port") is None:
uri = URL.create(

Check warning on line 131 in superset/db_engine_specs/couchbasedb.py

View check run for this annotation

Codecov / codecov/patch

superset/db_engine_specs/couchbasedb.py#L130-L131

Added lines #L130 - L131 were not covered by tests
"couchbasedb",
username=parameters.get("username"),
password=parameters.get("password"),
host=parameters["host"],
port=None,
query=query_params,
)
else:
uri = URL.create(

Check warning on line 140 in superset/db_engine_specs/couchbasedb.py

View check run for this annotation

Codecov / codecov/patch

superset/db_engine_specs/couchbasedb.py#L140

Added line #L140 was not covered by tests
"couchbasedb",
username=parameters.get("username"),
password=parameters.get("password"),
host=parameters["host"],
port=parameters.get("port"),
query=query_params,
)
print(uri)
return str(uri)

Check warning on line 149 in superset/db_engine_specs/couchbasedb.py

View check run for this annotation

Codecov / codecov/patch

superset/db_engine_specs/couchbasedb.py#L148-L149

Added lines #L148 - L149 were not covered by tests

@classmethod
def get_parameters_from_uri(
cls, uri: str, encrypted_extra: Optional[dict[str, Any]] = None
) -> BaseBasicParametersType:
print("get_parameters is called : ", uri)
url = make_url_safe(uri)
query = {

Check warning on line 157 in superset/db_engine_specs/couchbasedb.py

View check run for this annotation

Codecov / codecov/patch

superset/db_engine_specs/couchbasedb.py#L155-L157

Added lines #L155 - L157 were not covered by tests
key: value
for key, value in url.query.items()
if (key, value) not in cls.encryption_parameters.items()
}
ssl_value = url.query.get("ssl", "false").lower()
encryption = ssl_value == "true"
return BaseBasicParametersType(

Check warning on line 164 in superset/db_engine_specs/couchbasedb.py

View check run for this annotation

Codecov / codecov/patch

superset/db_engine_specs/couchbasedb.py#L162-L164

Added lines #L162 - L164 were not covered by tests
username=url.username,
password=url.password,
host=url.host,
port=url.port,
database=url.database,
query=query,
encryption=encryption,
)

@classmethod
def validate_parameters(
cls, properties: BaseBasicPropertiesType
) -> list[SupersetError]:
"""
Couchbase local server needs hostname and port but on cloud we need only connection String along with credentials to connect.
"""
errors: list[SupersetError] = []

Check warning on line 181 in superset/db_engine_specs/couchbasedb.py

View check run for this annotation

Codecov / codecov/patch

superset/db_engine_specs/couchbasedb.py#L181

Added line #L181 was not covered by tests

required = {"host", "username", "password", "database"}
parameters = properties.get("parameters", {})
present = {key for key in parameters if parameters.get(key, ())}

Check warning on line 185 in superset/db_engine_specs/couchbasedb.py

View check run for this annotation

Codecov / codecov/patch

superset/db_engine_specs/couchbasedb.py#L183-L185

Added lines #L183 - L185 were not covered by tests

if missing := sorted(required - present):
errors.append(

Check warning on line 188 in superset/db_engine_specs/couchbasedb.py

View check run for this annotation

Codecov / codecov/patch

superset/db_engine_specs/couchbasedb.py#L187-L188

Added lines #L187 - L188 were not covered by tests
SupersetError(
message=f'One or more parameters are missing: {", ".join(missing)}',
error_type=SupersetErrorType.CONNECTION_MISSING_PARAMETERS_ERROR,
level=ErrorLevel.WARNING,
extra={"missing": missing},
),
)

host = parameters.get("host", None)
if not host:
return errors

Check warning on line 199 in superset/db_engine_specs/couchbasedb.py

View check run for this annotation

Codecov / codecov/patch

superset/db_engine_specs/couchbasedb.py#L197-L199

Added lines #L197 - L199 were not covered by tests
# host can be a connection string in case of couchbase cloud. So Connection Check is not required in that case.
if not is_hostname_valid(host):
errors.append(

Check warning on line 202 in superset/db_engine_specs/couchbasedb.py

View check run for this annotation

Codecov / codecov/patch

superset/db_engine_specs/couchbasedb.py#L201-L202

Added lines #L201 - L202 were not covered by tests
SupersetError(
message="The hostname provided can't be resolved.",
error_type=SupersetErrorType.CONNECTION_INVALID_HOSTNAME_ERROR,
level=ErrorLevel.ERROR,
extra={"invalid": ["host"]},
),
)
return errors

Check warning on line 210 in superset/db_engine_specs/couchbasedb.py

View check run for this annotation

Codecov / codecov/patch

superset/db_engine_specs/couchbasedb.py#L210

Added line #L210 was not covered by tests

if port := parameters.get("port", None):
try:
port = int(port)
except (ValueError, TypeError):
errors.append(

Check warning on line 216 in superset/db_engine_specs/couchbasedb.py

View check run for this annotation

Codecov / codecov/patch

superset/db_engine_specs/couchbasedb.py#L212-L216

Added lines #L212 - L216 were not covered by tests
SupersetError(
message="Port must be a valid integer.",
error_type=SupersetErrorType.CONNECTION_INVALID_PORT_ERROR,
level=ErrorLevel.ERROR,
extra={"invalid": ["port"]},
),
)
if not (isinstance(port, int) and 0 <= port < 2**16):
errors.append(

Check warning on line 225 in superset/db_engine_specs/couchbasedb.py

View check run for this annotation

Codecov / codecov/patch

superset/db_engine_specs/couchbasedb.py#L224-L225

Added lines #L224 - L225 were not covered by tests
SupersetError(
message=(
"The port must be an integer between 0 and 65535 "
"(inclusive)."
),
error_type=SupersetErrorType.CONNECTION_INVALID_PORT_ERROR,
level=ErrorLevel.ERROR,
extra={"invalid": ["port"]},
),
)
elif not is_port_open(host, port):
errors.append(

Check warning on line 237 in superset/db_engine_specs/couchbasedb.py

View check run for this annotation

Codecov / codecov/patch

superset/db_engine_specs/couchbasedb.py#L236-L237

Added lines #L236 - L237 were not covered by tests
SupersetError(
message="The port is closed.",
error_type=SupersetErrorType.CONNECTION_PORT_CLOSED_ERROR,
level=ErrorLevel.ERROR,
extra={"invalid": ["port"]},
),
)

return errors

Check warning on line 246 in superset/db_engine_specs/couchbasedb.py

View check run for this annotation

Codecov / codecov/patch

superset/db_engine_specs/couchbasedb.py#L246

Added line #L246 was not covered by tests

@classmethod
def get_schema_from_engine_params(
cls,
sqlalchemy_uri: URL,
connect_args: dict[str, Any],
) -> Optional[str]:
"""
Return the configured schema.
"""
return parse.unquote(sqlalchemy_uri.database)

Check warning on line 257 in superset/db_engine_specs/couchbasedb.py

View check run for this annotation

Codecov / codecov/patch

superset/db_engine_specs/couchbasedb.py#L257

Added line #L257 was not covered by tests
1 change: 1 addition & 0 deletions superset/sql_parse.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@
"clickhouse": Dialects.CLICKHOUSE,
"clickhousedb": Dialects.CLICKHOUSE,
"cockroachdb": Dialects.POSTGRES,
"couchbasedb": Dialects.MYSQL,
# "crate": ???
# "databend": ???
"databricks": Dialects.DATABRICKS,
Expand Down
Loading
Loading