diff --git a/docs/docs/configuration/databases.mdx b/docs/docs/configuration/databases.mdx index 911cd11da7237..fa83e2b26772e 100644 --- a/docs/docs/configuration/databases.mdx +++ b/docs/docs/configuration/databases.mdx @@ -64,6 +64,7 @@ are compatible with Superset. | [IBM Db2](/docs/configuration/databases#ibm-db2) | `pip install ibm_db_sa` | `db2+ibm_db://` | | [IBM Netezza Performance Server](/docs/configuration/databases#ibm-netezza-performance-server) | `pip install nzalchemy` | `netezza+nzpy://:@/` | | [MySQL](/docs/configuration/databases#mysql) | `pip install mysqlclient` | `mysql://:@/` | +| [OceanBase](/docs/configuration/databases#oceanbase) | `pip install oceanbase_py` | `oceanbase://:@/` | | [Oracle](/docs/configuration/databases#oracle) | `pip install cx_Oracle` | `oracle://` | | [PostgreSQL](/docs/configuration/databases#postgres) | `pip install psycopg2` | `postgresql://:@/` | | [Presto](/docs/configuration/databases#presto) | `pip install pyhive` | `presto://` | @@ -988,6 +989,19 @@ Here's the recommended connection string: netezza+nzpy://{username}:{password}@{hostname}:{port}/{database} ``` +#### OceanBase + +The [sqlalchemy-oceanbase](https://pypi.org/project/oceanbase_py/) library is the recommended +way to connect to OceanBase through SQLAlchemy. + + +The connection string for OceanBase looks like this: + +``` +oceanbase://:@:/ +``` + + #### Ocient DB The recommended connector library for Ocient is [sqlalchemy-ocient](https://pypi.org/project/sqlalchemy-ocient). diff --git a/docs/src/resources/data.js b/docs/src/resources/data.js index 42cf835a495b4..766e32c5dd1b6 100644 --- a/docs/src/resources/data.js +++ b/docs/src/resources/data.js @@ -122,4 +122,9 @@ export const Databases = [ href: 'https://doris.apache.org/', imgName: 'doris.png', }, + { + title: 'OceanBase', + href: 'https://www.oceanbase.com/', + imgName: 'oceanbase.svg', + }, ]; diff --git a/docs/static/img/databases/oceanbase.svg b/docs/static/img/databases/oceanbase.svg new file mode 100644 index 0000000000000..a243a2a9dd4ff --- /dev/null +++ b/docs/static/img/databases/oceanbase.svg @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/pyproject.toml b/pyproject.toml index 7717611308361..8be39a2200475 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -173,6 +173,7 @@ vertica = ["sqlalchemy-vertica-python>=0.5.9, < 0.6"] netezza = ["nzalchemy>=11.0.2"] starrocks = ["starrocks>=1.0.0"] doris = ["pydoris>=1.0.0, <2.0.0"] +oceanbase = ["oceanbase_py>=0.0.1"] development = [ "docker", "flask-testing", diff --git a/superset-frontend/src/assets/images/oceanbase.svg b/superset-frontend/src/assets/images/oceanbase.svg new file mode 100644 index 0000000000000..a243a2a9dd4ff --- /dev/null +++ b/superset-frontend/src/assets/images/oceanbase.svg @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/superset/db_engine_specs/oceanbase.py b/superset/db_engine_specs/oceanbase.py new file mode 100644 index 0000000000000..3f4e2d4ed9ce4 --- /dev/null +++ b/superset/db_engine_specs/oceanbase.py @@ -0,0 +1,183 @@ +# 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 logging +import re +from re import Pattern +from typing import Any, Optional + +from flask_babel import gettext as __ +from sqlalchemy import Numeric, TEXT, types +from sqlalchemy.sql.type_api import TypeEngine + +from superset.db_engine_specs.mysql import MySQLEngineSpec +from superset.errors import SupersetErrorType +from superset.utils.core import GenericDataType + +# Regular expressions to catch custom errors +CONNECTION_ACCESS_DENIED_REGEX = re.compile( + "Access denied for user '(?P.*?)'" +) +CONNECTION_INVALID_HOSTNAME_REGEX = re.compile( + "Unknown OceanBase server host '(?P.*?)'" +) +CONNECTION_UNKNOWN_DATABASE_REGEX = re.compile("Unknown database '(?P.*?)'") +CONNECTION_HOST_DOWN_REGEX = re.compile( + "Can't connect to OceanBase server on '(?P.*?)'" +) +SYNTAX_ERROR_REGEX = re.compile( + "check the manual that corresponds to your OceanBase server " + "version for the right syntax to use near '(?P.*)" +) + +logger = logging.getLogger(__name__) + + +class NUMBER(Numeric): + __visit_name__ = "NUMBER" + + +class NUMERIC(Numeric): + __visit_name__ = "NUMERIC" + + +class ARRAY(TypeEngine): + __visit_name__ = "ARRAY" + + @property + def python_type(self) -> Optional[type[list[Any]]]: + return list + + +class MAP(TypeEngine): + __visit_name__ = "MAP" + + @property + def python_type(self) -> Optional[type[dict[Any, Any]]]: + return dict + + +class OceanBaseEngineSpec(MySQLEngineSpec): + engine = "oceanbase" + engine_aliases = {"oceanbase", "oceanbase_py"} + engine_name = "OceanBase" + max_column_name_length = 128 + default_driver = "oceanbase" + + sqlalchemy_uri_placeholder = ( + "oceanbase://user:password@host:port/db[?key=value&key=value...]" + ) + encryption_parameters = {"ssl": "0"} + supports_dynamic_schema = True + + column_type_mappings = ( # type: ignore + ( + re.compile(r"^tinyint", re.IGNORECASE), + types.SMALLINT(), + GenericDataType.NUMERIC, + ), + ( + re.compile(r"^largeint", re.IGNORECASE), + types.BIGINT(), + GenericDataType.NUMERIC, + ), + ( + re.compile(r"^decimal.*", re.IGNORECASE), + types.DECIMAL(), + GenericDataType.NUMERIC, + ), + ( + re.compile(r"^double", re.IGNORECASE), + types.FLOAT(), + GenericDataType.NUMERIC, + ), + ( + re.compile(r"^varchar(\((\d+)\))*$", re.IGNORECASE), + types.VARCHAR(), + GenericDataType.STRING, + ), + ( + re.compile(r"^char(\((\d+)\))*$", re.IGNORECASE), + types.CHAR(), + GenericDataType.STRING, + ), + ( + re.compile(r"^json.*", re.IGNORECASE), + types.JSON(), + GenericDataType.STRING, + ), + ( + re.compile(r"^binary.*", re.IGNORECASE), + types.BINARY(), + GenericDataType.STRING, + ), + ( + re.compile(r"^array.*", re.IGNORECASE), + ARRAY(), + GenericDataType.STRING, + ), + ( + re.compile(r"^map.*", re.IGNORECASE), + MAP(), + GenericDataType.STRING, + ), + ( + re.compile(r"^text.*", re.IGNORECASE), + TEXT(), + GenericDataType.STRING, + ), + ( + re.compile(r"^number.*", re.IGNORECASE), + NUMBER(), + GenericDataType.NUMERIC, + ), + ( + re.compile(r"^numeric.*", re.IGNORECASE), + NUMERIC(), + GenericDataType.NUMERIC, + ), + ) + + custom_errors: dict[Pattern[str], tuple[str, SupersetErrorType, dict[str, Any]]] = { + CONNECTION_ACCESS_DENIED_REGEX: ( + __('Either the username "%(username)s" or the password is incorrect.'), + SupersetErrorType.CONNECTION_ACCESS_DENIED_ERROR, + {"invalid": ["username", "password"]}, + ), + CONNECTION_INVALID_HOSTNAME_REGEX: ( + __('Unknown OceanBase server host "%(hostname)s".'), + SupersetErrorType.CONNECTION_INVALID_HOSTNAME_ERROR, + {"invalid": ["host"]}, + ), + CONNECTION_HOST_DOWN_REGEX: ( + __('The host "%(hostname)s" might be down and can\'t be reached.'), + SupersetErrorType.CONNECTION_HOST_DOWN_ERROR, + {"invalid": ["host", "port"]}, + ), + CONNECTION_UNKNOWN_DATABASE_REGEX: ( + __('Unable to connect to database "%(database)s".'), + SupersetErrorType.CONNECTION_UNKNOWN_DATABASE_ERROR, + {"invalid": ["database"]}, + ), + SYNTAX_ERROR_REGEX: ( + __( + 'Please check your query for syntax errors near "%(server_error)s". ' + "Then, try running your query again." + ), + SupersetErrorType.SYNTAX_ERROR, + {}, + ), + } diff --git a/tests/unit_tests/db_engine_specs/test_oceanbase.py b/tests/unit_tests/db_engine_specs/test_oceanbase.py new file mode 100644 index 0000000000000..87fddb63ec44e --- /dev/null +++ b/tests/unit_tests/db_engine_specs/test_oceanbase.py @@ -0,0 +1,59 @@ +# 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 typing import Any, Optional + +import pytest +from sqlalchemy import JSON, types + +from superset.db_engine_specs.oceanbase import ARRAY, MAP, NUMBER, NUMERIC +from superset.utils.core import GenericDataType +from tests.unit_tests.db_engine_specs.utils import assert_column_spec + + +@pytest.mark.parametrize( + "native_type,sqla_type,attrs,generic_type,is_dttm", + [ + # Numeric + ("tinyint", types.SMALLINT, None, GenericDataType.NUMERIC, False), + ("largeint", types.BIGINT, None, GenericDataType.NUMERIC, False), + ("decimal(38,18)", types.DECIMAL, None, GenericDataType.NUMERIC, False), + ("number(38,18)", NUMBER, None, GenericDataType.NUMERIC, False), + ("numeric(38,18)", NUMERIC, None, GenericDataType.NUMERIC, False), + ("double", types.FLOAT, None, GenericDataType.NUMERIC, False), + # String + ("char(10)", types.CHAR, None, GenericDataType.STRING, False), + ("varchar(65533)", types.VARCHAR, None, GenericDataType.STRING, False), + ("binary", types.BINARY, None, GenericDataType.STRING, False), + ("text", types.TEXT, None, GenericDataType.STRING, False), + # Complex type + ("array", ARRAY, None, GenericDataType.STRING, False), + ("map", MAP, None, GenericDataType.STRING, False), + ("json", JSON, None, GenericDataType.STRING, False), + ("jsonb", JSON, None, GenericDataType.STRING, False), + ], +) +def test_get_column_spec( + native_type: str, + sqla_type: type[types.TypeEngine], + attrs: Optional[dict[str, Any]], + generic_type: GenericDataType, + is_dttm: bool, +) -> None: + from superset.db_engine_specs.oceanbase import OceanBaseEngineSpec as spec + + assert_column_spec(spec, native_type, sqla_type, attrs, generic_type, is_dttm)