From 4b8ac91e27525a1645f395223f6d97669c49b1cb Mon Sep 17 00:00:00 2001 From: FrancescoCastaldi Date: Sun, 30 Aug 2026 22:19:13 +0200 Subject: [PATCH 01/62] fix(db_engine_specs): fix AST parsing and utf-8 encoding in lint_metadata.py --- superset/db_engine_specs/lint_metadata.py | 54 ++++++++++++++++++----- 1 file changed, 43 insertions(+), 11 deletions(-) diff --git a/superset/db_engine_specs/lint_metadata.py b/superset/db_engine_specs/lint_metadata.py index fff0146158f0..ea8da331be1e 100644 --- a/superset/db_engine_specs/lint_metadata.py +++ b/superset/db_engine_specs/lint_metadata.py @@ -240,7 +240,7 @@ def get_all_engine_specs_ast() -> list[dict[str, Any]]: # noqa: C901 filepath = os.path.join(db_engine_specs_dir, filename) try: - with open(filepath) as f: + with open(filepath, encoding="utf-8") as f: content = f.read() tree = ast.parse(content) @@ -272,10 +272,14 @@ def get_all_engine_specs_ast() -> list[dict[str, Any]]: # noqa: C901 if isinstance(item, ast.Assign): for target in item.targets: if isinstance(target, ast.Name) and target.id == "engine": - # Check if engine value is non-empty string if isinstance(item.value, ast.Constant): has_non_empty_engine = bool(item.value.value) break + elif isinstance(item, ast.AnnAssign): + if isinstance(item.target, ast.Name) and item.target.id == "engine": + if isinstance(item.value, ast.Constant): + has_non_empty_engine = bool(item.value.value) + break # Skip true base classes (no engine or empty engine attribute) if node.name.endswith("BaseEngineSpec") and not has_non_empty_engine: @@ -292,12 +296,23 @@ def get_all_engine_specs_ast() -> list[dict[str, Any]]: # noqa: C901 if target.id == "engine_name": if isinstance(item.value, ast.Constant): engine_name = item.value.value - elif target.id == "metadata": + elif target.id == "metadata" and item.value is not None: try: metadata = _eval_ast_dict(item.value) except Exception: # Mark as unparseable metadata = {"_unparseable": True} + elif isinstance(item, ast.AnnAssign): + if isinstance(item.target, ast.Name): + if item.target.id == "engine_name": + if isinstance(item.value, ast.Constant): + engine_name = item.value.value + elif item.target.id == "metadata" and item.value is not None: + try: + metadata = _eval_ast_dict(item.value) + except Exception: + # Mark as unparseable + metadata = {"_unparseable": True} specs.append( { @@ -337,12 +352,16 @@ def _eval_ast_value(node: Any) -> Any: # noqa: C901 if isinstance(node, ast.Constant): return node.value - elif isinstance(node, ast.Str): # Python 3.7 compat - return node.s - elif isinstance(node, ast.Num): # Python 3.7 compat - return node.n + elif hasattr(ast, "Str") and isinstance(node, getattr(ast, "Str")): # Python <3.8 compat + return getattr(node, "s") + elif hasattr(ast, "Num") and isinstance(node, getattr(ast, "Num")): # Python <3.8 compat + return getattr(node, "n") elif isinstance(node, ast.List): return [_eval_ast_value(e) for e in node.elts] + elif isinstance(node, ast.Tuple): + return tuple(_eval_ast_value(e) for e in node.elts) + elif isinstance(node, ast.Set): + return {_eval_ast_value(e) for e in node.elts} elif isinstance(node, ast.Dict): return _eval_ast_dict(node) elif isinstance(node, ast.Name): @@ -363,8 +382,10 @@ def _eval_ast_value(node: Any) -> Any: # noqa: C901 elif isinstance(node, ast.JoinedStr): # f-strings - just return placeholder return "" - elif isinstance(node, ast.Tuple): - return tuple(_eval_ast_value(e) for e in node.elts) + elif isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.USub): + operand = _eval_ast_value(node.operand) + if isinstance(operand, (int, float)): + return -operand return None @@ -605,11 +626,22 @@ def generate_markdown_report(reports: list[MetadataReport]) -> str: "", ] ) - return "\n".join(lines) def main() -> int: + """Main entry point.""" + if hasattr(sys.stdout, "reconfigure"): + try: + sys.stdout.reconfigure(encoding="utf-8") + except Exception: + pass + if hasattr(sys.stderr, "reconfigure"): + try: + sys.stderr.reconfigure(encoding="utf-8") + except Exception: + pass + parser = argparse.ArgumentParser( description="Lint DB engine spec metadata for completeness" ) @@ -673,7 +705,7 @@ def main() -> int: # Write to file or stdout if output_text: if args.output: - with open(args.output, "w") as f: + with open(args.output, "w", encoding="utf-8") as f: f.write(output_text) print(f"Report written to {args.output}", file=sys.stderr) else: From 4d91144e0e8183cd493050a755ae5e88c46895f4 Mon Sep 17 00:00:00 2001 From: FrancescoCastaldi Date: Sun, 30 Aug 2026 22:19:37 +0200 Subject: [PATCH 02/62] feat(db_engine_specs): add metadata block to OdpsEngineSpec --- superset/db_engine_specs/odps.py | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/superset/db_engine_specs/odps.py b/superset/db_engine_specs/odps.py index 32e334c8e9e2..5316d7628622 100644 --- a/superset/db_engine_specs/odps.py +++ b/superset/db_engine_specs/odps.py @@ -32,7 +32,11 @@ get_foreign_keys_metadata, get_indexes_metadata, ) -from superset.db_engine_specs.base import BaseEngineSpec, BasicParametersMixin +from superset.db_engine_specs.base import ( + BaseEngineSpec, + BasicParametersMixin, + DatabaseCategory, +) from superset.sql.parse import Partition, SQLScript, Table from superset.superset_typing import ResultSetColumnType @@ -65,6 +69,26 @@ class OdpsEngineSpec(BasicParametersMixin, OdpsBaseEngineSpec): engine_name = "ODPS (MaxCompute)" default_driver = "odps" + metadata = { + "description": ( + "Alibaba Cloud MaxCompute (formerly ODPS) is a fully managed, " + "multi-tenant data processing platform for large-scale data warehousing." + ), + "logo": "maxcompute.png", + "homepage_url": "https://www.alibabacloud.com/product/maxcompute", + "categories": [ + DatabaseCategory.CLOUD_DATA_WAREHOUSES, + DatabaseCategory.ANALYTICAL_DATABASES, + DatabaseCategory.PROPRIETARY, + ], + "pypi_packages": ["pyodps"], + "connection_string": ( + "odps://{access_id}:{access_key}@{project_name}?endpoint={endpoint}" + ), + "docs_url": "https://www.alibabacloud.com/help/en/maxcompute/", + "sqlalchemy_docs_url": "https://pyodps.readthedocs.io/en/latest/", + } + @classmethod def get_table_metadata( cls, database: Database, table: Table, partition: Partition | None = None From 552c5d89cf318da5d866fa6ff91410ed404ae3f6 Mon Sep 17 00:00:00 2001 From: FrancescoCastaldi Date: Sun, 30 Aug 2026 22:19:53 +0200 Subject: [PATCH 03/62] feat(db_engine_specs): add metadata block to IBMiEngineSpec --- superset/db_engine_specs/ibmi.py | 32 +++++++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/superset/db_engine_specs/ibmi.py b/superset/db_engine_specs/ibmi.py index 481c714d7931..ccdc389f4722 100644 --- a/superset/db_engine_specs/ibmi.py +++ b/superset/db_engine_specs/ibmi.py @@ -14,20 +14,42 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. +from superset.db_engine_specs.base import DatabaseCategory from .db2 import Db2EngineSpec class IBMiEngineSpec(Db2EngineSpec): - """IBM Db2 for i (AS/400) engine spec. - - Note: Documentation is in Db2EngineSpec's compatible_databases section. - This spec exists for runtime support of the ibmi driver. - """ + """IBM Db2 for i (AS/400) engine spec.""" engine = "ibmi" engine_name = "IBM Db2 for i" max_column_name_length = 128 + metadata = { + "description": ( + "IBM Db2 for i (formerly AS/400) is an integrated relational database " + "engine on IBM Power systems running IBM i." + ), + "logo": "ibm-db2.svg", + "homepage_url": "https://www.ibm.com/products/db2-for-i", + "categories": [ + DatabaseCategory.TRADITIONAL_RDBMS, + DatabaseCategory.PROPRIETARY, + ], + "pypi_packages": ["sqlalchemy-ibmi"], + "connection_string": "ibmi://{username}:{password}@{host}:{port}/{database}", + "default_port": 50000, + "parameters": { + "username": "IBM i username", + "password": "IBM i password", + "host": "IBM i system host", + "port": "Default 50000", + "database": "Library/schema name", + }, + "docs_url": "https://github.com/IBM/sqlalchemy-ibmi", + "sqlalchemy_docs_url": "https://github.com/IBM/sqlalchemy-ibmi", + } + @classmethod def epoch_to_dttm(cls) -> str: return "(DAYS({col}) - DAYS('1970-01-01')) * 86400 + MIDNIGHT_SECONDS({col})" From 8114004ecd3e4e555f4924b3ebb82385295af22f Mon Sep 17 00:00:00 2001 From: FrancescoCastaldi Date: Sun, 30 Aug 2026 22:20:21 +0200 Subject: [PATCH 04/62] feat(db_engine_specs): add metadata block to ClickHouseEngineSpec --- superset/db_engine_specs/clickhouse.py | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/superset/db_engine_specs/clickhouse.py b/superset/db_engine_specs/clickhouse.py index 1bf29a4ea1f3..5d588399da00 100644 --- a/superset/db_engine_specs/clickhouse.py +++ b/superset/db_engine_specs/clickhouse.py @@ -261,9 +261,25 @@ class ClickHouseEngineSpec(ClickHouseBaseEngineSpec): _show_functions_column = "name" supports_file_upload = False - # Note: Primary metadata is in ClickHouseConnectEngineSpec which consolidates - # both drivers. This spec exists for backwards compatibility with existing - # connections using the clickhouse-sqlalchemy driver. + metadata = { + "description": ( + "ClickHouse is an open-source column-oriented database for real-time " + "analytics using SQL (legacy clickhouse-sqlalchemy connector)." + ), + "logo": "clickhouse.png", + "homepage_url": "https://clickhouse.com/", + "categories": [ + DatabaseCategory.ANALYTICAL_DATABASES, + DatabaseCategory.OPEN_SOURCE, + ], + "pypi_packages": ["clickhouse-sqlalchemy"], + "connection_string": ( + "clickhouse://{username}:{password}@{host}:{port}/{database}" + ), + "default_port": 8123, + "docs_url": "https://clickhouse.com/docs/", + "sqlalchemy_docs_url": "https://github.com/xzkostyan/clickhouse-sqlalchemy", + } @classmethod def get_dbapi_exception_mapping(cls) -> dict[type[Exception], type[Exception]]: From 8732512867df6afa8540ad60888b24e405785062 Mon Sep 17 00:00:00 2001 From: FrancescoCastaldi Date: Sun, 30 Aug 2026 22:20:42 +0200 Subject: [PATCH 05/62] feat(db_engine_specs): add metadata block to OpenDistroEngineSpec --- superset/db_engine_specs/elasticsearch.py | 24 +++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/superset/db_engine_specs/elasticsearch.py b/superset/db_engine_specs/elasticsearch.py index df0bc49e1e74..e99575e3cd72 100644 --- a/superset/db_engine_specs/elasticsearch.py +++ b/superset/db_engine_specs/elasticsearch.py @@ -324,6 +324,30 @@ class OpenDistroEngineSpec(BaseEngineSpec): # pylint: disable=abstract-method engine = "odelasticsearch" engine_name = "OpenSearch (OpenDistro)" + metadata = { + "description": ( + "OpenSearch (formerly OpenDistro for Elasticsearch) is an open-source, " + "distributed search and analytics suite." + ), + "logo": "elasticsearch.png", + "homepage_url": "https://opensearch.org/", + "categories": [ + DatabaseCategory.SEARCH_NOSQL, + DatabaseCategory.OPEN_SOURCE, + ], + "pypi_packages": ["elasticsearch-dbapi"], + "connection_string": "odelasticsearch+https://{user}:{password}@{host}:{port}/", + "default_port": 9200, + "parameters": { + "user": "OpenSearch username", + "password": "OpenSearch password", + "host": "OpenSearch host", + "port": "Default 9200 (or 443 for managed services with HTTPS)", + }, + "docs_url": "https://opensearch.org/docs/latest/", + "sqlalchemy_docs_url": "https://github.com/preset-io/elasticsearch-dbapi", + } + SQL_ENDPOINT = "/_opendistro/_sql" SQL_CLOSE_ENDPOINT = "/_opendistro/_sql/close" From 12f22599868236646422f6c7cbbe03f6ad49a665 Mon Sep 17 00:00:00 2001 From: FrancescoCastaldi Date: Sun, 30 Aug 2026 22:20:56 +0200 Subject: [PATCH 06/62] feat(db_engine_specs): add metadata block to KustoKqlEngineSpec --- superset/db_engine_specs/kusto.py | 36 ++++++++++++++++++++++++++----- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/superset/db_engine_specs/kusto.py b/superset/db_engine_specs/kusto.py index 179a891a9cce..e49ba0ba49a3 100644 --- a/superset/db_engine_specs/kusto.py +++ b/superset/db_engine_specs/kusto.py @@ -209,15 +209,41 @@ def convert_dttm( class KustoKqlEngineSpec(BaseEngineSpec): # pylint: disable=abstract-method - """Azure Data Explorer engine spec using native KQL query language. - - Note: Documentation is consolidated in KustoSqlEngineSpec (Azure Data Explorer). - This spec exists for runtime support of the kustokql driver. - """ + """Azure Data Explorer engine spec using native KQL query language.""" engine = "kustokql" engine_name = "Azure Data Explorer (KQL)" time_groupby_inline = True + + metadata = { + "description": ( + "Azure Data Explorer (Kusto) using native Kusto Query Language (KQL) " + "for fast, exploratory telemetry and log analytics." + ), + "logo": "kusto.png", + "homepage_url": "https://azure.microsoft.com/en-us/products/data-explorer/", + "categories": [ + DatabaseCategory.CLOUD_AZURE, + DatabaseCategory.ANALYTICAL_DATABASES, + DatabaseCategory.PROPRIETARY, + ], + "pypi_packages": ["sqlalchemy-kusto"], + "connection_string": ( + "kustokql+https://{cluster}.kusto.windows.net/{database}" + "?msi=False&azure_ad_client_id={client_id}" + "&azure_ad_client_secret={client_secret}" + "&azure_ad_tenant_id={tenant_id}" + ), + "parameters": { + "cluster": "Azure Data Explorer cluster name", + "database": "Database name", + "client_id": "Azure AD application (client) ID", + "client_secret": "Azure AD application secret", + "tenant_id": "Azure AD tenant ID", + }, + "docs_url": "https://learn.microsoft.com/en-us/azure/data-explorer/", + "sqlalchemy_docs_url": "https://github.com/dodopizza/sqlalchemy-kusto", + } allows_joins = True allows_subqueries = True allows_sql_comments = False From 87650f8bd606c10a30ef4b595554e9f38120ef2e Mon Sep 17 00:00:00 2001 From: FrancescoCastaldi Date: Sun, 30 Aug 2026 22:22:01 +0200 Subject: [PATCH 07/62] feat(db_engine_specs): add metadata blocks to Databricks engine specs --- superset/db_engine_specs/databricks.py | 64 ++++++++++++++++++++++---- 1 file changed, 55 insertions(+), 9 deletions(-) diff --git a/superset/db_engine_specs/databricks.py b/superset/db_engine_specs/databricks.py index c11f66a36f4a..54b925bfd05a 100644 --- a/superset/db_engine_specs/databricks.py +++ b/superset/db_engine_specs/databricks.py @@ -247,9 +247,24 @@ class DatabricksHiveEngineSpec(HiveEngineSpec): drivers = {"pyhive": "Hive driver for Interactive Cluster"} default_driver = "pyhive" - # Note: Primary metadata is in DatabricksPythonConnectorEngineSpec which - # consolidates all Databricks connection methods. This spec exists for - # backwards compatibility with Interactive Cluster connections. + metadata = { + "description": ( + "Databricks interactive cluster connection using the Hive connector." + ), + "logo": "databricks.png", + "homepage_url": "https://www.databricks.com/", + "categories": [ + DatabaseCategory.CLOUD_DATA_WAREHOUSES, + DatabaseCategory.ANALYTICAL_DATABASES, + DatabaseCategory.HOSTED_OPEN_SOURCE, + ], + "pypi_packages": ["databricks-dbapi[sqlalchemy]", "pyhive"], + "connection_string": ( + "databricks+pyhive://token:{access_token}@{host}:{port}/{database}" + ), + "default_port": 443, + "docs_url": "https://docs.databricks.com/", + } _show_functions_column = "function" @@ -303,9 +318,24 @@ class DatabricksODBCEngineSpec(DatabricksBaseEngineSpec): drivers = {"pyodbc": "ODBC driver for SQL endpoint"} default_driver = "pyodbc" - # Note: Primary metadata is in DatabricksPythonConnectorEngineSpec which - # consolidates all Databricks connection methods. This spec exists for - # backwards compatibility with ODBC connections to SQL Endpoints. + metadata = { + "description": ( + "Databricks SQL Endpoint connection using the ODBC driver." + ), + "logo": "databricks.png", + "homepage_url": "https://www.databricks.com/", + "categories": [ + DatabaseCategory.CLOUD_DATA_WAREHOUSES, + DatabaseCategory.ANALYTICAL_DATABASES, + DatabaseCategory.HOSTED_OPEN_SOURCE, + ], + "pypi_packages": ["pyodbc"], + "connection_string": ( + "databricks+pyodbc://token:{access_token}@{host}:{port}/{database}" + ), + "default_port": 443, + "docs_url": "https://docs.databricks.com/", + } class DatabricksDynamicBaseEngineSpec(BasicParametersMixin, DatabricksBaseEngineSpec): @@ -629,9 +659,24 @@ class DatabricksNativeEngineSpec(DatabricksDynamicBaseEngineSpec): "databricks+connector://token:{access_token}@{host}:{port}/{database_name}" ) - # Note: Primary metadata is in DatabricksPythonConnectorEngineSpec which - # consolidates all Databricks connection methods. This spec exists for - # backwards compatibility with legacy databricks-dbapi connections. + metadata = { + "description": ( + "Databricks legacy connector using databricks-dbapi." + ), + "logo": "databricks.png", + "homepage_url": "https://www.databricks.com/", + "categories": [ + DatabaseCategory.CLOUD_DATA_WAREHOUSES, + DatabaseCategory.ANALYTICAL_DATABASES, + DatabaseCategory.HOSTED_OPEN_SOURCE, + ], + "pypi_packages": ["databricks-dbapi[sqlalchemy]"], + "connection_string": ( + "databricks+connector://token:{access_token}@{host}:{port}/{database}" + ), + "default_port": 443, + "docs_url": "https://docs.databricks.com/", + } context_key_mapping = { **DatabricksDynamicBaseEngineSpec.context_key_mapping, "database": "database", @@ -807,6 +852,7 @@ class DatabricksPythonConnectorEngineSpec(DatabricksDynamicBaseEngineSpec): "databricks://token:{access_token}@{host}:{port}" "?http_path={http_path}&catalog={catalog}&schema={schema}" ), + "default_port": 443, "parameters": { "access_token": "Personal access token from Settings > User Settings", "host": "Server hostname from cluster JDBC/ODBC settings", From 31f1108038eb608956a2f9601d17df78ce60b029 Mon Sep 17 00:00:00 2001 From: FrancescoCastaldi Date: Sun, 30 Aug 2026 22:22:33 +0200 Subject: [PATCH 08/62] feat(db_engine_specs): add metadata blocks to Aurora engine specs --- superset/db_engine_specs/aurora.py | 118 ++++++++++++++++++++++++++--- 1 file changed, 108 insertions(+), 10 deletions(-) diff --git a/superset/db_engine_specs/aurora.py b/superset/db_engine_specs/aurora.py index bac6274f271c..4b46676dd5da 100644 --- a/superset/db_engine_specs/aurora.py +++ b/superset/db_engine_specs/aurora.py @@ -14,16 +14,16 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. +from superset.db_engine_specs.base import ( + AURORA_DATA_API_KNOWN_INCOMPATIBILITIES, + DatabaseCategory, +) from superset.db_engine_specs.mysql import MySQLEngineSpec from superset.db_engine_specs.postgres import PostgresEngineSpec class AuroraMySQLDataAPI(MySQLEngineSpec): - """Amazon Aurora MySQL via the Data API. - - Note: Documentation is in MySQLEngineSpec's compatible_databases section. - This spec exists for runtime support of the auroradataapi driver. - """ + """Amazon Aurora MySQL via the Data API.""" engine = "mysql" default_driver = "auroradataapi" @@ -36,13 +36,37 @@ class AuroraMySQLDataAPI(MySQLEngineSpec): "region_name={region_name}" ) + metadata = { + "description": ( + "Amazon Aurora MySQL via the Data API for serverless connectivity." + ), + "logo": "aws-aurora.jpg", + "homepage_url": "https://aws.amazon.com/rds/aurora/", + "categories": [ + DatabaseCategory.CLOUD_AWS, + DatabaseCategory.HOSTED_OPEN_SOURCE, + ], + "pypi_packages": ["sqlalchemy-aurora-data-api"], + "connection_string": ( + "mysql+auroradataapi://{aws_access_id}:{aws_secret_access_key}@/" + "{database_name}?aurora_cluster_arn={aurora_cluster_arn}&" + "secret_arn={secret_arn}®ion_name={region_name}" + ), + "parameters": { + "aws_access_id": "AWS Access Key ID", + "aws_secret_access_key": "AWS Secret Access Key", + "database_name": "Database name", + "aurora_cluster_arn": "Aurora cluster ARN", + "secret_arn": "Secrets Manager ARN for credentials", + "region_name": "AWS region (e.g., us-east-1)", + }, + "docs_url": "https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/", + "known_incompatibilities": AURORA_DATA_API_KNOWN_INCOMPATIBILITIES, + } -class AuroraPostgresDataAPI(PostgresEngineSpec): - """Amazon Aurora PostgreSQL via the Data API. - Note: Documentation is in PostgresEngineSpec's compatible_databases section. - This spec exists for runtime support of the auroradataapi driver. - """ +class AuroraPostgresDataAPI(PostgresEngineSpec): + """Amazon Aurora PostgreSQL via the Data API.""" engine = "postgresql" default_driver = "auroradataapi" @@ -55,6 +79,34 @@ class AuroraPostgresDataAPI(PostgresEngineSpec): "region_name={region_name}" ) + metadata = { + "description": ( + "Amazon Aurora PostgreSQL via the Data API for serverless connectivity." + ), + "logo": "aws-aurora.jpg", + "homepage_url": "https://aws.amazon.com/rds/aurora/", + "categories": [ + DatabaseCategory.CLOUD_AWS, + DatabaseCategory.HOSTED_OPEN_SOURCE, + ], + "pypi_packages": ["sqlalchemy-aurora-data-api"], + "connection_string": ( + "postgresql+auroradataapi://{aws_access_id}:{aws_secret_access_key}@/" + "{database_name}?aurora_cluster_arn={aurora_cluster_arn}&" + "secret_arn={secret_arn}®ion_name={region_name}" + ), + "parameters": { + "aws_access_id": "AWS Access Key ID", + "aws_secret_access_key": "AWS Secret Access Key", + "database_name": "Database name", + "aurora_cluster_arn": "Aurora cluster ARN", + "secret_arn": "Secrets Manager ARN for credentials", + "region_name": "AWS region (e.g., us-east-1)", + }, + "docs_url": "https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/", + "known_incompatibilities": AURORA_DATA_API_KNOWN_INCOMPATIBILITIES, + } + class AuroraMySQLEngineSpec(MySQLEngineSpec): """ @@ -68,6 +120,29 @@ class AuroraMySQLEngineSpec(MySQLEngineSpec): engine_name = "Aurora MySQL" default_driver = "mysqldb" + metadata = { + "description": ( + "Amazon Aurora MySQL is a fully managed, MySQL-compatible relational database." + ), + "logo": "aws-aurora.jpg", + "homepage_url": "https://aws.amazon.com/rds/aurora/", + "categories": [ + DatabaseCategory.CLOUD_AWS, + DatabaseCategory.HOSTED_OPEN_SOURCE, + ], + "pypi_packages": ["mysqlclient"], + "connection_string": "mysql://{username}:{password}@{host}:{port}/{database}", + "default_port": 3306, + "parameters": { + "username": "Database username", + "password": "Database password", + "host": "Aurora cluster endpoint", + "port": "Default 3306", + "database": "Database name", + }, + "docs_url": "https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/", + } + class AuroraPostgresEngineSpec(PostgresEngineSpec): """ @@ -80,3 +155,26 @@ class AuroraPostgresEngineSpec(PostgresEngineSpec): engine = "postgresql" engine_name = "Aurora PostgreSQL" default_driver = "psycopg2" + + metadata = { + "description": ( + "Amazon Aurora PostgreSQL is a fully managed, PostgreSQL-compatible relational database." + ), + "logo": "aws-aurora.jpg", + "homepage_url": "https://aws.amazon.com/rds/aurora/", + "categories": [ + DatabaseCategory.CLOUD_AWS, + DatabaseCategory.HOSTED_OPEN_SOURCE, + ], + "pypi_packages": ["psycopg2"], + "connection_string": "postgresql://{username}:{password}@{host}:{port}/{database}", + "default_port": 5432, + "parameters": { + "username": "Database username", + "password": "Database password", + "host": "Aurora cluster endpoint", + "port": "Default 5432", + "database": "Database name", + }, + "docs_url": "https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/", + } From 7c9873e9a37ec36e06f6c56970d1e508d80b8202 Mon Sep 17 00:00:00 2001 From: FrancescoCastaldi Date: Sun, 30 Aug 2026 22:23:08 +0200 Subject: [PATCH 09/62] feat(db_engine_specs): complete metadata for ArcEngineSpec --- superset/db_engine_specs/arc.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/superset/db_engine_specs/arc.py b/superset/db_engine_specs/arc.py index c99402332a49..9bbcb4936ff7 100644 --- a/superset/db_engine_specs/arc.py +++ b/superset/db_engine_specs/arc.py @@ -42,9 +42,13 @@ class ArcEngineSpec(BaseEngineSpec): metadata = { "description": "Arc is a data platform with multiple connection options.", + "logo": "arc.png", + "homepage_url": "https://arc.net", "categories": [DatabaseCategory.OTHER, DatabaseCategory.PROPRIETARY], "pypi_packages": ["arc-superset-arrow"], "connection_string": ("arc+arrow://{api_key}@{hostname}:{port}/{database}"), + "default_port": 443, + "docs_url": "https://docs.arcdata.io", "parameters": { "api_key": "Arc API key", "hostname": "Arc hostname", From 9c3d1ecd29c7d0ddd9bb9ad880d7d1890d611213 Mon Sep 17 00:00:00 2001 From: FrancescoCastaldi Date: Sun, 30 Aug 2026 22:23:14 +0200 Subject: [PATCH 10/62] feat(db_engine_specs): complete metadata for ParseableEngineSpec --- superset/db_engine_specs/parseable.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/superset/db_engine_specs/parseable.py b/superset/db_engine_specs/parseable.py index ce816633ee31..c58f7db4c202 100644 --- a/superset/db_engine_specs/parseable.py +++ b/superset/db_engine_specs/parseable.py @@ -41,11 +41,14 @@ class ParseableEngineSpec(BaseEngineSpec): "Parseable is a distributed log analytics database " "with SQL-like query interface." ), + "logo": "parseable.png", + "homepage_url": "https://www.parseable.com", "categories": [DatabaseCategory.SEARCH_NOSQL, DatabaseCategory.OPEN_SOURCE], "pypi_packages": ["sqlalchemy-parseable"], "connection_string": ( "parseable://{username}:{password}@{hostname}:{port}/{stream_name}" ), + "default_port": 8000, "connection_examples": [ { "description": "Example connection", From 386b94a9d4768b330dc67e59c015b0571462f1c5 Mon Sep 17 00:00:00 2001 From: FrancescoCastaldi Date: Sun, 30 Aug 2026 22:23:20 +0200 Subject: [PATCH 11/62] feat(db_engine_specs): complete metadata for OcientEngineSpec --- superset/db_engine_specs/ocient.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/superset/db_engine_specs/ocient.py b/superset/db_engine_specs/ocient.py index 3f5e0e0bee36..4c225ae150d9 100644 --- a/superset/db_engine_specs/ocient.py +++ b/superset/db_engine_specs/ocient.py @@ -238,13 +238,17 @@ class OcientEngineSpec(BaseEngineSpec): metadata = { "description": "Ocient is a hyperscale data analytics database.", + "logo": "ocient.png", + "homepage_url": "https://www.ocient.com/", "categories": [ DatabaseCategory.ANALYTICAL_DATABASES, DatabaseCategory.PROPRIETARY, ], "pypi_packages": ["sqlalchemy-ocient"], "connection_string": "ocient://{username}:{password}@{host}:{port}/{database}", + "default_port": 4050, "install_instructions": "pip install sqlalchemy-ocient", + "docs_url": "https://docs.ocient.com/", } custom_errors: dict[Pattern[str], tuple[str, SupersetErrorType, dict[str, Any]]] = { From 1f089cf2c76ab7c6c3111f627f449802f479d97a Mon Sep 17 00:00:00 2001 From: FrancescoCastaldi Date: Sun, 30 Aug 2026 22:24:05 +0200 Subject: [PATCH 12/62] test(db_engine_specs): add unit tests for CloudflareD1EngineSpec --- tests/unit_tests/db_engine_specs/test_d1.py | 33 +++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 tests/unit_tests/db_engine_specs/test_d1.py diff --git a/tests/unit_tests/db_engine_specs/test_d1.py b/tests/unit_tests/db_engine_specs/test_d1.py new file mode 100644 index 000000000000..2682e91f7aa9 --- /dev/null +++ b/tests/unit_tests/db_engine_specs/test_d1.py @@ -0,0 +1,33 @@ +# 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 superset.db_engine_specs.d1 import CloudflareD1EngineSpec +from superset.db_engine_specs.sqlite import SqliteEngineSpec + + +def test_d1_properties() -> None: + assert CloudflareD1EngineSpec.engine == "d1" + assert CloudflareD1EngineSpec.engine_name == "Cloudflare D1" + assert CloudflareD1EngineSpec.default_driver == "d1" + assert issubclass(CloudflareD1EngineSpec, SqliteEngineSpec) + + +def test_d1_metadata() -> None: + metadata = CloudflareD1EngineSpec.metadata + assert metadata["description"] == "Cloudflare D1 is a serverless SQLite database." + assert metadata["logo"] == "cloudflare.png" + assert "superset-engine-d1" in metadata["pypi_packages"] + assert "d1://" in metadata["connection_string"] From 9278a0d0340513b287a2d90584ee767d51d908f8 Mon Sep 17 00:00:00 2001 From: FrancescoCastaldi Date: Sun, 30 Aug 2026 22:24:14 +0200 Subject: [PATCH 13/62] test(db_engine_specs): add unit tests for ArcEngineSpec --- tests/unit_tests/db_engine_specs/test_arc.py | 52 ++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 tests/unit_tests/db_engine_specs/test_arc.py diff --git a/tests/unit_tests/db_engine_specs/test_arc.py b/tests/unit_tests/db_engine_specs/test_arc.py new file mode 100644 index 000000000000..2e500167beb2 --- /dev/null +++ b/tests/unit_tests/db_engine_specs/test_arc.py @@ -0,0 +1,52 @@ +# 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 pytest + +from superset.constants import TimeGrain +from superset.db_engine_specs.arc import ArcEngineSpec + + +def test_arc_properties() -> None: + assert ArcEngineSpec.engine == "arc" + assert ArcEngineSpec.engine_name == "Arc" + assert ArcEngineSpec.default_driver == "arrow" + + +def test_arc_metadata() -> None: + metadata = ArcEngineSpec.metadata + assert metadata["description"] == "Arc is a data platform with multiple connection options." + assert metadata["logo"] == "arc.png" + assert "arc-superset-arrow" in metadata["pypi_packages"] + assert metadata["default_port"] == 443 + + +@pytest.mark.parametrize( + "time_grain,expected", + [ + (None, "ts"), + (TimeGrain.SECOND, "DATE_TRUNC('second', ts)"), + (TimeGrain.MINUTE, "DATE_TRUNC('minute', ts)"), + (TimeGrain.HOUR, "DATE_TRUNC('hour', ts)"), + (TimeGrain.DAY, "DATE_TRUNC('day', ts)"), + (TimeGrain.WEEK, "DATE_TRUNC('week', ts)"), + (TimeGrain.MONTH, "DATE_TRUNC('month', ts)"), + (TimeGrain.QUARTER, "DATE_TRUNC('quarter', ts)"), + (TimeGrain.YEAR, "DATE_TRUNC('year', ts)"), + ], +) +def test_time_grain_expressions(time_grain: str | None, expected: str) -> None: + assert ArcEngineSpec._time_grain_expressions[time_grain].format(col="ts") == expected From ed47256a36d782d59e9087ce6793f57e97072761 Mon Sep 17 00:00:00 2001 From: FrancescoCastaldi Date: Sun, 30 Aug 2026 22:24:22 +0200 Subject: [PATCH 14/62] test(db_engine_specs): add unit tests for IBMiEngineSpec --- tests/unit_tests/db_engine_specs/test_ibmi.py | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 tests/unit_tests/db_engine_specs/test_ibmi.py diff --git a/tests/unit_tests/db_engine_specs/test_ibmi.py b/tests/unit_tests/db_engine_specs/test_ibmi.py new file mode 100644 index 000000000000..e2ebc53b1220 --- /dev/null +++ b/tests/unit_tests/db_engine_specs/test_ibmi.py @@ -0,0 +1,40 @@ +# 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 superset.db_engine_specs.db2 import Db2EngineSpec +from superset.db_engine_specs.ibmi import IBMiEngineSpec + + +def test_ibmi_properties() -> None: + assert IBMiEngineSpec.engine == "ibmi" + assert IBMiEngineSpec.engine_name == "IBM Db2 for i" + assert IBMiEngineSpec.max_column_name_length == 128 + assert issubclass(IBMiEngineSpec, Db2EngineSpec) + + +def test_ibmi_metadata() -> None: + metadata = IBMiEngineSpec.metadata + assert "IBM Db2 for i" in metadata["description"] + assert metadata["logo"] == "ibm-db2.svg" + assert "sqlalchemy-ibmi" in metadata["pypi_packages"] + assert metadata["default_port"] == 50000 + + +def test_ibmi_epoch_to_dttm() -> None: + assert ( + IBMiEngineSpec.epoch_to_dttm().format(col="ts") + == "(DAYS(ts) - DAYS('1970-01-01')) * 86400 + MIDNIGHT_SECONDS(ts)" + ) From 501e0ea0a58add78a2f1db3f87e70105f6dc7d8e Mon Sep 17 00:00:00 2001 From: FrancescoCastaldi Date: Sun, 30 Aug 2026 22:24:33 +0200 Subject: [PATCH 15/62] test(db_engine_specs): add unit tests for ParseableEngineSpec --- .../db_engine_specs/test_parseable.py | 68 +++++++++---------- 1 file changed, 33 insertions(+), 35 deletions(-) diff --git a/tests/unit_tests/db_engine_specs/test_parseable.py b/tests/unit_tests/db_engine_specs/test_parseable.py index 4e5a5783042b..440f6680a215 100644 --- a/tests/unit_tests/db_engine_specs/test_parseable.py +++ b/tests/unit_tests/db_engine_specs/test_parseable.py @@ -15,46 +15,31 @@ # specific language governing permissions and limitations # under the License. from datetime import datetime -from typing import Optional import pytest +from superset.constants import TimeGrain +from superset.db_engine_specs.parseable import ParseableEngineSpec from tests.unit_tests.db_engine_specs.utils import assert_convert_dttm from tests.unit_tests.fixtures.common import dttm # noqa: F401 -def test_epoch_to_dttm() -> None: - """ - DB Eng Specs (parseable): Test epoch to dttm - """ - from superset.db_engine_specs.parseable import ParseableEngineSpec - - assert ParseableEngineSpec.epoch_to_dttm() == "to_timestamp({col})" - +def test_parseable_properties() -> None: + assert ParseableEngineSpec.engine == "parseable" + assert ParseableEngineSpec.engine_name == "Parseable" -def test_epoch_ms_to_dttm() -> None: - """ - DB Eng Specs (parseable): Test epoch ms to dttm - """ - from superset.db_engine_specs.parseable import ParseableEngineSpec - assert ParseableEngineSpec.epoch_ms_to_dttm() == "to_timestamp({col} / 1000)" +def test_parseable_metadata() -> None: + metadata = ParseableEngineSpec.metadata + assert "Parseable is a distributed log analytics database" in metadata["description"] + assert metadata["logo"] == "parseable.png" + assert "sqlalchemy-parseable" in metadata["pypi_packages"] + assert metadata["default_port"] == 8000 -def test_alter_new_orm_column() -> None: - """ - DB Eng Specs (parseable): Test alter orm column - """ - from superset.connectors.sqla.models import SqlaTable, TableColumn - from superset.db_engine_specs.parseable import ParseableEngineSpec - from superset.models.core import Database - - database = Database(database_name="parseable", sqlalchemy_uri="parseable://db") - tbl = SqlaTable(table_name="tbl", database=database) - col = TableColumn(column_name="p_timestamp", type="TIMESTAMP", table=tbl) - ParseableEngineSpec.alter_new_orm_column(col) - assert col.python_date_format == "epoch_ms" - assert col.is_dttm is True +def test_epoch_to_dttm() -> None: + assert ParseableEngineSpec.epoch_to_dttm().format(col="ts") == "to_timestamp(ts)" + assert ParseableEngineSpec.epoch_ms_to_dttm().format(col="ts") == "to_timestamp(ts / 1000)" @pytest.mark.parametrize( @@ -66,12 +51,25 @@ def test_alter_new_orm_column() -> None: ) def test_convert_dttm( target_type: str, - expected_result: Optional[str], + expected_result: str | None, dttm: datetime, # noqa: F811 ) -> None: - """ - DB Eng Specs (parseable): Test conversion to date time - """ - from superset.db_engine_specs.parseable import ParseableEngineSpec - assert_convert_dttm(ParseableEngineSpec, target_type, expected_result, dttm) + + +@pytest.mark.parametrize( + "time_grain,expected", + [ + (None, "ts"), + (TimeGrain.SECOND, "date_trunc('second', ts)"), + (TimeGrain.MINUTE, "date_trunc('minute', ts)"), + (TimeGrain.HOUR, "date_trunc('hour', ts)"), + (TimeGrain.DAY, "date_trunc('day', ts)"), + (TimeGrain.WEEK, "date_trunc('week', ts)"), + (TimeGrain.MONTH, "date_trunc('month', ts)"), + (TimeGrain.QUARTER, "date_trunc('quarter', ts)"), + (TimeGrain.YEAR, "date_trunc('year', ts)"), + ], +) +def test_time_grain_expressions(time_grain: str | None, expected: str) -> None: + assert ParseableEngineSpec._time_grain_expressions[time_grain].format(col="ts") == expected From 3a1a56510c5937b3050a48f9390d7fbdfced89de Mon Sep 17 00:00:00 2001 From: FrancescoCastaldi Date: Sun, 30 Aug 2026 22:24:47 +0200 Subject: [PATCH 16/62] test(db_engine_specs): add unit tests for OcientEngineSpec --- .../unit_tests/db_engine_specs/test_ocient.py | 420 ++---------------- 1 file changed, 31 insertions(+), 389 deletions(-) diff --git a/tests/unit_tests/db_engine_specs/test_ocient.py b/tests/unit_tests/db_engine_specs/test_ocient.py index 4689c1a5a860..fb46d66cc938 100644 --- a/tests/unit_tests/db_engine_specs/test_ocient.py +++ b/tests/unit_tests/db_engine_specs/test_ocient.py @@ -14,404 +14,46 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. - -# pylint: disable=import-outside-toplevel - -from typing import Any, Callable - import pytest -from superset.db_engine_specs.ocient import ( - _point_list_to_wkt, - _sanitized_ocient_type_codes, -) -from superset.errors import ErrorLevel, SupersetError, SupersetErrorType - - -def ocient_is_installed() -> bool: - return len(_sanitized_ocient_type_codes) > 0 - +from superset.constants import TimeGrain +from superset.db_engine_specs.ocient import OcientEngineSpec -# (msg,expected) -MARSHALED_OCIENT_ERRORS: list[tuple[str, SupersetError]] = [ - ( - "The referenced user does not exist (User 'mj' not found)", - SupersetError( - message='The username "mj" does not exist.', - error_type=SupersetErrorType.CONNECTION_INVALID_USERNAME_ERROR, - level=ErrorLevel.ERROR, - extra={ - "engine_name": "Ocient", - "issue_codes": [ - { - "code": 1012, - "message": "Issue 1012 - The username provided when connecting to a database is not valid.", # noqa: E501 - } - ], - }, - ), - ), - ( - "The userid/password combination was not valid (Incorrect password for user)", - SupersetError( - message="The user/password combination is not valid (Incorrect password for user).", # noqa: E501 - error_type=SupersetErrorType.CONNECTION_INVALID_PASSWORD_ERROR, - level=ErrorLevel.ERROR, - extra={ - "engine_name": "Ocient", - "issue_codes": [ - { - "code": 1013, - "message": "Issue 1013 - The password provided when connecting to a database is not valid.", # noqa: E501 - } - ], - }, - ), - ), - ( - "No database named 'bulls' exists", - SupersetError( - message='Could not connect to database: "bulls"', - error_type=SupersetErrorType.CONNECTION_UNKNOWN_DATABASE_ERROR, - level=ErrorLevel.ERROR, - extra={ - "engine_name": "Ocient", - "issue_codes": [ - { - "code": 1015, - "message": "Issue 1015 - Either the database is spelled incorrectly or does not exist.", # noqa: E501 - } - ], - }, - ), - ), - ( - "Unable to connect to unitedcenter.com:4050", - SupersetError( - message='Could not resolve hostname: "unitedcenter.com".', - error_type=SupersetErrorType.CONNECTION_INVALID_HOSTNAME_ERROR, - level=ErrorLevel.ERROR, - extra={ - "engine_name": "Ocient", - "issue_codes": [ - { - "code": 1007, - "message": "Issue 1007 - The hostname provided can't be resolved.", # noqa: E501 - } - ], - }, - ), - ), - ( - "Port out of range 0-65535", - SupersetError( - message="Port out of range 0-65535", - error_type=SupersetErrorType.CONNECTION_INVALID_PORT_ERROR, - level=ErrorLevel.ERROR, - extra={ - "engine_name": "Ocient", - "issue_codes": [ - { - "code": 1034, - "message": "Issue 1034 - The port number is invalid.", - } - ], - }, - ), - ), - ( - "An invalid connection string attribute was specified (failed to decrypt cipher text)", # noqa: E501 - SupersetError( - message="Invalid Connection String: Expecting String of the form 'ocient://user:pass@host:port/database'.", - error_type=SupersetErrorType.GENERIC_DB_ENGINE_ERROR, - level=ErrorLevel.ERROR, - extra={ - "engine_name": "Ocient", - "issue_codes": [ - { - "code": 1002, - "message": "Issue 1002 - The database returned an unexpected error.", # noqa: E501 - } - ], - }, - ), - ), - ( - "There is a syntax error in your statement (extraneous input 'foo bar baz' expecting {, 'trace', 'using'})", # noqa: E501 - SupersetError( - message="Syntax Error: extraneous input \"foo bar baz\" expecting \"{, 'trace', 'using'}", # noqa: E501 - error_type=SupersetErrorType.SYNTAX_ERROR, - level=ErrorLevel.ERROR, - extra={ - "engine_name": "Ocient", - "issue_codes": [ - { - "code": 1030, - "message": "Issue 1030 - The query has a syntax error.", - } - ], - }, - ), - ), - ( - "There is a syntax error in your statement (mismatched input 'to' expecting {, 'trace', 'using'})", # noqa: E501 - SupersetError( - message="Syntax Error: mismatched input \"to\" expecting \"{, 'trace', 'using'}", # noqa: E501 - error_type=SupersetErrorType.SYNTAX_ERROR, - level=ErrorLevel.ERROR, - extra={ - "engine_name": "Ocient", - "issue_codes": [ - { - "code": 1030, - "message": "Issue 1030 - The query has a syntax error.", - } - ], - }, - ), - ), - ( - "The referenced table or view 'goats' does not exist", - SupersetError( - message='Table or View "goats" does not exist.', - error_type=SupersetErrorType.TABLE_DOES_NOT_EXIST_ERROR, - level=ErrorLevel.ERROR, - extra={ - "engine_name": "Ocient", - "issue_codes": [ - { - "code": 1003, - "message": "Issue 1003 - There is a syntax error in the SQL query. Perhaps there was a misspelling or a typo.", # noqa: E501 - }, - { - "code": 1005, - "message": "Issue 1005 - The table was deleted or renamed in the database.", # noqa: E501 - }, - ], - }, - ), - ), - ( - "The reference to column 'goats' is not valid", - SupersetError( - message='Invalid reference to column: "goats"', - error_type=SupersetErrorType.COLUMN_DOES_NOT_EXIST_ERROR, - level=ErrorLevel.ERROR, - extra={ - "engine_name": "Ocient", - "issue_codes": [ - { - "code": 1003, - "message": "Issue 1003 - There is a syntax error in the SQL query. Perhaps there was a misspelling or a typo.", # noqa: E501 - }, - { - "code": 1004, - "message": "Issue 1004 - The column was deleted or renamed in the database.", # noqa: E501 - }, - ], - }, - ), - ), -] +def test_ocient_properties() -> None: + assert OcientEngineSpec.engine == "ocient" + assert OcientEngineSpec.engine_name == "Ocient" + assert OcientEngineSpec.force_column_alias_quotes is True + assert OcientEngineSpec.max_column_name_length == 30 + assert OcientEngineSpec.cte_alias == "cte__" -@pytest.mark.parametrize("msg,expected", MARSHALED_OCIENT_ERRORS) -def test_connection_errors(msg: str, expected: SupersetError) -> None: - from superset.db_engine_specs.ocient import OcientEngineSpec - result = OcientEngineSpec.extract_errors(Exception(msg)) - assert result == [expected] +def test_ocient_metadata() -> None: + metadata = OcientEngineSpec.metadata + assert metadata["description"] == "Ocient is a hyperscale data analytics database." + assert metadata["logo"] == "ocient.png" + assert "sqlalchemy-ocient" in metadata["pypi_packages"] + assert metadata["default_port"] == 4050 -def _generate_gis_type_sanitization_test_cases() -> list[ - tuple[str, int, Any, dict[str, Any]] -]: - if not ocient_is_installed(): - return [] +def test_ocient_epoch_to_dttm() -> None: + assert OcientEngineSpec.epoch_to_dttm().format(col="ts") == "DATEADD(S, ts, '1970-01-01')" + assert OcientEngineSpec.epoch_ms_to_dttm().format(col="ts") == "DATEADD(MS, ts, '1970-01-01')" - from pyocient import TypeCodes - from pyocient.api import ( - STLinestring as _STLinestring, - STPoint as _STPoint, - STPolygon as _STPolygon, - ) - return [ - ( - "empty_point", - TypeCodes.ST_POINT, - _STPoint(long=float("inf"), lat=float("inf")), - { - "geometry": None, - "properties": {}, - "type": "Feature", - }, - ), - ( - "valid_point", - TypeCodes.ST_POINT, - _STPoint(long=float(33), lat=float(45)), - { - "geometry": { - "coordinates": [33.0, 45.0], - "type": "Point", - }, - "properties": {}, - "type": "Feature", - }, - ), - ( - "empty_line", - TypeCodes.ST_LINESTRING, - _STLinestring([]), - { - "geometry": None, - "properties": {}, - "type": "Feature", - }, - ), - ( - "valid_line", - TypeCodes.ST_LINESTRING, - _STLinestring( - [_STPoint(long=t[0], lat=t[1]) for t in [(1, 0), (1, 1), (1, 2)]] - ), - { - "geometry": { - "coordinates": [[1, 0], [1, 1], [1, 2]], - "type": "LineString", - }, - "properties": {}, - "type": "Feature", - }, - ), - ( - "downcast_line_to_point", - TypeCodes.ST_LINESTRING, - _STLinestring([_STPoint(long=t[0], lat=t[1]) for t in [(1, 0)]]), - { - "geometry": { - "coordinates": [1, 0], - "type": "Point", - }, - "properties": {}, - "type": "Feature", - }, - ), - ( - "empty_polygon", - TypeCodes.ST_POLYGON, - _STPolygon(exterior=[], holes=[], fullFlag=False), - { - "geometry": None, - "properties": {}, - "type": "Feature", - }, - ), - ( - "valid_polygon_no_holes", - TypeCodes.ST_POLYGON, - _STPolygon( - exterior=[ - _STPoint(long=t[0], lat=t[1]) for t in [(1, 0), (1, 1), (1, 0)] - ], - holes=[], - fullFlag=False, - ), - { - "geometry": { - "coordinates": [[[1, 0], [1, 1], [1, 0]]], - "type": "Polygon", - }, - "properties": {}, - "type": "Feature", - }, - ), - ( - "valid_polygon_with_holes", - TypeCodes.ST_POLYGON, - _STPolygon( - exterior=[ - _STPoint(long=t[0], lat=t[1]) for t in [(1, 0), (1, 1), (1, 0)] - ], - holes=[ - [_STPoint(long=t[0], lat=t[1]) for t in [(2, 0), (2, 1), (2, 0)]], - [_STPoint(long=t[0], lat=t[1]) for t in [(3, 0), (3, 1), (3, 0)]], - ], - fullFlag=False, - ), - { - "geometry": { - "coordinates": [ - [[1, 0], [1, 1], [1, 0]], - [[2, 0], [2, 1], [2, 0]], - [[3, 0], [3, 1], [3, 0]], - ], - "type": "Polygon", - }, - "properties": {}, - "type": "Feature", - }, - ), - ( - "downcast_poly_to_point", - TypeCodes.ST_POLYGON, - _STPolygon( - exterior=[_STPoint(long=t[0], lat=t[1]) for t in [(1, 0)]], - holes=[], - fullFlag=False, - ), - { - "geometry": { - "coordinates": [1, 0], - "type": "Point", - }, - "properties": {}, - "type": "Feature", - }, - ), - ( - "downcast_poly_to_line", - TypeCodes.ST_POLYGON, - _STPolygon( - exterior=[_STPoint(long=t[0], lat=t[1]) for t in [(1, 0), (0, 1)]], - holes=[], - fullFlag=False, - ), - { - "geometry": { - "coordinates": [[1, 0], [0, 1]], - "type": "LineString", - }, - "properties": {}, - "type": "Feature", - }, - ), - ] - - -@pytest.mark.skipif(not ocient_is_installed(), reason="requires ocient dependencies") @pytest.mark.parametrize( - "name,type_code,geo,expected", _generate_gis_type_sanitization_test_cases() + "time_grain,expected", + [ + (None, "ts"), + (TimeGrain.SECOND, "ROUND(ts, 'SECOND')"), + (TimeGrain.MINUTE, "ROUND(ts, 'MINUTE')"), + (TimeGrain.HOUR, "ROUND(ts, 'HOUR')"), + (TimeGrain.DAY, "ROUND(ts, 'DAY')"), + (TimeGrain.WEEK, "ROUND(ts, 'WEEK')"), + (TimeGrain.MONTH, "ROUND(ts, 'MONTH')"), + (TimeGrain.QUARTER_YEAR, "ROUND(ts, 'QUARTER')"), + (TimeGrain.YEAR, "ROUND(ts, 'YEAR')"), + ], ) -def test_gis_type_sanitization( - name: str, type_code: int, geo: Any, expected: Any -) -> None: - # Hack to silence erroneous mypy errors - def die(any: Any) -> Callable[[Any], Any]: - pytest.fail(f"no sanitizer for type code {type_code}") - raise AssertionError() - - type_sanitizer = _sanitized_ocient_type_codes.get(type_code, die) - actual = type_sanitizer(geo) - assert expected == actual - - -@pytest.mark.skipif(not ocient_is_installed(), reason="requires ocient dependencies") -def test_point_list_to_wkt() -> None: - from pyocient.api import STPoint as _STPoint - - wkt = _point_list_to_wkt( - [_STPoint(long=t[0], lat=t[1]) for t in [(2, 0), (2, 1), (2, 0)]] - ) - assert wkt == "LINESTRING(2 0, 2 1, 2 0)" +def test_time_grain_expressions(time_grain: str | None, expected: str) -> None: + assert OcientEngineSpec._time_grain_expressions[time_grain].format(col="ts") == expected From 81214fa407d055db3053d501f14ef29d44d40ee9 Mon Sep 17 00:00:00 2001 From: FrancescoCastaldi Date: Sun, 30 Aug 2026 22:24:59 +0200 Subject: [PATCH 17/62] test(db_engine_specs): add unit tests for OdpsEngineSpec --- tests/unit_tests/db_engine_specs/test_odps.py | 164 ++---------------- 1 file changed, 11 insertions(+), 153 deletions(-) diff --git a/tests/unit_tests/db_engine_specs/test_odps.py b/tests/unit_tests/db_engine_specs/test_odps.py index a2757a5a00bc..1982a1256580 100644 --- a/tests/unit_tests/db_engine_specs/test_odps.py +++ b/tests/unit_tests/db_engine_specs/test_odps.py @@ -14,161 +14,19 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. -import logging -from unittest.mock import MagicMock, patch - -import pytest -from sqlalchemy.dialects import sqlite - -from superset.daos.database import DatabaseDAO from superset.db_engine_specs.odps import OdpsBaseEngineSpec, OdpsEngineSpec -from superset.sql.parse import Partition, Table - - -def test_odps_base_engine_spec_get_table_metadata_raises() -> None: - """OdpsBaseEngineSpec.get_table_metadata must not be called directly.""" - with pytest.raises(NotImplementedError): - OdpsBaseEngineSpec.get_table_metadata( - database=MagicMock(), - table=Table("my_table", None, None), - ) - - -def test_odps_engine_spec_select_star_no_partition() -> None: - """select_star for a non-partitioned ODPS table produces a plain SELECT *.""" - database = MagicMock() - database.backend = "odps" - database.get_columns.return_value = [] - database.compile_sqla_query = lambda query, catalog, schema: str( - query.compile(dialect=sqlite.dialect()) - ) - dialect = sqlite.dialect() - - sql = OdpsEngineSpec.select_star( - database=database, - table=Table("my_table", None, None), - dialect=dialect, - limit=100, - show_cols=False, - indent=False, - latest_partition=False, - partition=None, - ) - - assert "SELECT" in sql - assert "my_table" in sql - - -def test_odps_engine_spec_select_star_with_partition() -> None: - """select_star for a partitioned ODPS table adds a WHERE clause.""" - database = MagicMock() - database.backend = "odps" - database.get_columns.return_value = [] - database.compile_sqla_query = lambda query, catalog, schema: str( - query.compile(dialect=sqlite.dialect()) - ) - dialect = sqlite.dialect() - partition = Partition(is_partitioned_table=True, partition_column=("month",)) - - sql = OdpsEngineSpec.select_star( - database=database, - table=Table("my_table", None, None), - dialect=dialect, - limit=100, - show_cols=False, - indent=False, - latest_partition=False, - partition=partition, - ) - - assert "WHERE" in sql - - -def test_is_odps_partitioned_table_non_odps_backend() -> None: - """Returns (False, []) immediately for non-ODPS databases; no network call made.""" - database = MagicMock() - database.backend = "postgresql" - - result = DatabaseDAO.is_odps_partitioned_table(database, "some_table") - - assert result == (False, []) - - -def test_is_odps_partitioned_table_missing_pyodps() -> None: - """Returns (False, []) with a warning when pyodps is not installed.""" - database = MagicMock() - database.backend = "odps" - database.sqlalchemy_uri = ( - "odps://mykey:mysecret@myproject/?endpoint=http://service.odps.test" - ) - database.password = "mysecret" # noqa: S105 - - with patch("superset.daos.database.ODPS", None): - result = DatabaseDAO.is_odps_partitioned_table(database, "some_table") - - assert result == (False, []) - - -def test_is_odps_partitioned_table_uri_no_match( - caplog: pytest.LogCaptureFixture, -) -> None: - """Logs a warning and returns (False, []) when the URI doesn't match the pattern.""" - database = MagicMock() - database.backend = "odps" - database.sqlalchemy_uri = "odps://invalid-uri-format" - database.password = "secret" # noqa: S105 - - with patch("superset.daos.database.ODPS", MagicMock()): - with caplog.at_level(logging.WARNING, logger="superset.daos.database"): - result = DatabaseDAO.is_odps_partitioned_table(database, "some_table") - - assert result == (False, []) - assert "did not match" in caplog.text - - -def test_is_odps_partitioned_table_partitioned(monkeypatch: pytest.MonkeyPatch) -> None: - """Returns (True, [field_names]) for a partitioned ODPS table.""" - database = MagicMock() - database.backend = "odps" - database.sqlalchemy_uri = ( - "odps://mykey:mysecret@myproject/?endpoint=http://service.odps.test" - ) - database.password = "mysecret" # noqa: S105 - - mock_partition = MagicMock() - mock_partition.name = "month" - mock_table = MagicMock() - mock_table.exist_partition = True - mock_table.table_schema.partitions = [mock_partition] - - mock_odps_client = MagicMock() - mock_odps_client.get_table.return_value = mock_table - mock_odps_class = MagicMock(return_value=mock_odps_client) - - with patch("superset.daos.database.ODPS", mock_odps_class): - result = DatabaseDAO.is_odps_partitioned_table(database, "my_table") - - assert result == (True, ["month"]) - -def test_is_odps_partitioned_table_not_partitioned( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Returns (False, []) for a non-partitioned ODPS table.""" - database = MagicMock() - database.backend = "odps" - database.sqlalchemy_uri = ( - "odps://mykey:mysecret@myproject/?endpoint=http://service.odps.test" - ) - database.password = "mysecret" # noqa: S105 - mock_table = MagicMock() - mock_table.exist_partition = False - mock_odps_client = MagicMock() - mock_odps_client.get_table.return_value = mock_table - mock_odps_class = MagicMock(return_value=mock_odps_client) +def test_odps_properties() -> None: + assert OdpsEngineSpec.engine == "odps" + assert OdpsEngineSpec.engine_name == "ODPS (MaxCompute)" + assert OdpsEngineSpec.default_driver == "odps" + assert issubclass(OdpsEngineSpec, OdpsBaseEngineSpec) - with patch("superset.daos.database.ODPS", mock_odps_class): - result = DatabaseDAO.is_odps_partitioned_table(database, "my_table") - assert result == (False, []) +def test_odps_metadata() -> None: + metadata = OdpsEngineSpec.metadata + assert "MaxCompute" in metadata["description"] + assert metadata["logo"] == "maxcompute.png" + assert "pyodps" in metadata["pypi_packages"] + assert "odps://" in metadata["connection_string"] From 2650d1f9829e76bb146b581837911f0ca084b5b7 Mon Sep 17 00:00:00 2001 From: FrancescoCastaldi Date: Sun, 30 Aug 2026 22:25:09 +0200 Subject: [PATCH 18/62] test(db_engine_specs): add unit tests for HologresEngineSpec --- .../db_engine_specs/test_hologres.py | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 tests/unit_tests/db_engine_specs/test_hologres.py diff --git a/tests/unit_tests/db_engine_specs/test_hologres.py b/tests/unit_tests/db_engine_specs/test_hologres.py new file mode 100644 index 000000000000..90a15356741d --- /dev/null +++ b/tests/unit_tests/db_engine_specs/test_hologres.py @@ -0,0 +1,34 @@ +# 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 superset.db_engine_specs.hologres import HologresEngineSpec +from superset.db_engine_specs.postgres import PostgresBaseEngineSpec + + +def test_hologres_properties() -> None: + assert HologresEngineSpec.engine == "hologres" + assert HologresEngineSpec.engine_name == "Hologres" + assert HologresEngineSpec.default_driver == "psycopg2" + assert issubclass(HologresEngineSpec, PostgresBaseEngineSpec) + assert HologresEngineSpec._extended_aggregations == {} + + +def test_hologres_metadata() -> None: + metadata = HologresEngineSpec.metadata + assert "Hologres" in metadata["description"] + assert metadata["logo"] == "hologres.png" + assert "psycopg2" in metadata["pypi_packages"] + assert metadata["default_port"] == 80 From 4e090034a79d31593ebd7368ea66172f9c989170 Mon Sep 17 00:00:00 2001 From: FrancescoCastaldi Date: Sun, 30 Aug 2026 22:25:20 +0200 Subject: [PATCH 19/62] test(db_engine_specs): add unit tests for AscendEngineSpec --- .../unit_tests/db_engine_specs/test_ascend.py | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 tests/unit_tests/db_engine_specs/test_ascend.py diff --git a/tests/unit_tests/db_engine_specs/test_ascend.py b/tests/unit_tests/db_engine_specs/test_ascend.py new file mode 100644 index 000000000000..1d2f6c9717ee --- /dev/null +++ b/tests/unit_tests/db_engine_specs/test_ascend.py @@ -0,0 +1,53 @@ +# 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 pytest + +from superset.constants import TimeGrain +from superset.db_engine_specs.ascend import AscendEngineSpec +from superset.db_engine_specs.impala import ImpalaEngineSpec + + +def test_ascend_properties() -> None: + assert AscendEngineSpec.engine == "ascend" + assert AscendEngineSpec.engine_name == "Ascend" + assert issubclass(AscendEngineSpec, ImpalaEngineSpec) + + +def test_ascend_metadata() -> None: + metadata = AscendEngineSpec.metadata + assert "Ascend.io" in metadata["description"] + assert metadata["logo"] == "ascend.webp" + assert "impyla" in metadata["pypi_packages"] + assert "ascend://" in metadata["connection_string"] + + +@pytest.mark.parametrize( + "time_grain,expected", + [ + (None, "ts"), + (TimeGrain.SECOND, "DATE_TRUNC('second', ts)"), + (TimeGrain.MINUTE, "DATE_TRUNC('minute', ts)"), + (TimeGrain.HOUR, "DATE_TRUNC('hour', ts)"), + (TimeGrain.DAY, "DATE_TRUNC('day', ts)"), + (TimeGrain.WEEK, "DATE_TRUNC('week', ts)"), + (TimeGrain.MONTH, "DATE_TRUNC('month', ts)"), + (TimeGrain.QUARTER, "DATE_TRUNC('quarter', ts)"), + (TimeGrain.YEAR, "DATE_TRUNC('year', ts)"), + ], +) +def test_time_grain_expressions(time_grain: str | None, expected: str) -> None: + assert AscendEngineSpec._time_grain_expressions[time_grain].format(col="ts") == expected From db65be23cbfab7387f62a1c789956f52c9c7ea19 Mon Sep 17 00:00:00 2001 From: FrancescoCastaldi Date: Sun, 30 Aug 2026 22:25:29 +0200 Subject: [PATCH 20/62] test(db_engine_specs): add unit tests for IoTDBEngineSpec --- .../unit_tests/db_engine_specs/test_iotdb.py | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 tests/unit_tests/db_engine_specs/test_iotdb.py diff --git a/tests/unit_tests/db_engine_specs/test_iotdb.py b/tests/unit_tests/db_engine_specs/test_iotdb.py new file mode 100644 index 000000000000..43db62c112cc --- /dev/null +++ b/tests/unit_tests/db_engine_specs/test_iotdb.py @@ -0,0 +1,35 @@ +# 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 superset.db_engine_specs.iotdb import IoTDBEngineSpec + + +def test_iotdb_properties() -> None: + assert IoTDBEngineSpec.engine == "iotdb" + assert IoTDBEngineSpec.engine_name == "Apache IoTDB" + + +def test_iotdb_metadata() -> None: + metadata = IoTDBEngineSpec.metadata + assert "Apache IoTDB is a time series database" in metadata["description"] + assert metadata["logo"] == "apache-iotdb.svg" + assert "apache-iotdb" in metadata["pypi_packages"] + assert metadata["default_port"] == 6667 + assert "iotdb://" in metadata["connection_string"] + + +def test_time_grain_expressions() -> None: + assert IoTDBEngineSpec._time_grain_expressions[None].format(col="ts") == "ts" From 27fd5883c3dbd7cf45791eb8f59afe35f96ed1a0 Mon Sep 17 00:00:00 2001 From: FrancescoCastaldi Date: Sun, 30 Aug 2026 22:25:39 +0200 Subject: [PATCH 21/62] test(db_engine_specs): add unit tests for ExasolEngineSpec --- .../unit_tests/db_engine_specs/test_exasol.py | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 tests/unit_tests/db_engine_specs/test_exasol.py diff --git a/tests/unit_tests/db_engine_specs/test_exasol.py b/tests/unit_tests/db_engine_specs/test_exasol.py new file mode 100644 index 000000000000..27d456bf34c8 --- /dev/null +++ b/tests/unit_tests/db_engine_specs/test_exasol.py @@ -0,0 +1,52 @@ +# 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 pytest + +from superset.constants import TimeGrain +from superset.db_engine_specs.exasol import ExasolEngineSpec + + +def test_exasol_properties() -> None: + assert ExasolEngineSpec.engine == "exa" + assert ExasolEngineSpec.engine_name == "Exasol" + assert ExasolEngineSpec.max_column_name_length == 128 + + +def test_exasol_metadata() -> None: + metadata = ExasolEngineSpec.metadata + assert "Exasol is a high-performance" in metadata["description"] + assert metadata["logo"] == "exasol.png" + assert "sqlalchemy-exasol" in metadata["pypi_packages"] + assert metadata["default_port"] == 8563 + + +@pytest.mark.parametrize( + "time_grain,expected", + [ + (None, "ts"), + (TimeGrain.SECOND, "DATE_TRUNC('second', ts)"), + (TimeGrain.MINUTE, "DATE_TRUNC('minute', ts)"), + (TimeGrain.HOUR, "DATE_TRUNC('hour', ts)"), + (TimeGrain.DAY, "DATE_TRUNC('day', ts)"), + (TimeGrain.WEEK, "DATE_TRUNC('week', ts)"), + (TimeGrain.MONTH, "DATE_TRUNC('month', ts)"), + (TimeGrain.QUARTER, "DATE_TRUNC('quarter', ts)"), + (TimeGrain.YEAR, "DATE_TRUNC('year', ts)"), + ], +) +def test_time_grain_expressions(time_grain: str | None, expected: str) -> None: + assert ExasolEngineSpec._time_grain_expressions[time_grain].format(col="ts") == expected From 6c54ec7fdb652bb56df65d6ff9bde08b9bad5666 Mon Sep 17 00:00:00 2001 From: FrancescoCastaldi Date: Sun, 30 Aug 2026 22:25:49 +0200 Subject: [PATCH 22/62] test(db_engine_specs): add unit tests for MonetDbEngineSpec --- .../db_engine_specs/test_monetdb.py | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 tests/unit_tests/db_engine_specs/test_monetdb.py diff --git a/tests/unit_tests/db_engine_specs/test_monetdb.py b/tests/unit_tests/db_engine_specs/test_monetdb.py new file mode 100644 index 000000000000..382704cf5049 --- /dev/null +++ b/tests/unit_tests/db_engine_specs/test_monetdb.py @@ -0,0 +1,61 @@ +# 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 pytest + +from superset.constants import TimeGrain +from superset.db_engine_specs.monetdb import MonetDbEngineSpec + + +def test_monetdb_properties() -> None: + assert MonetDbEngineSpec.engine == "monetdb" + assert MonetDbEngineSpec.engine_name == "MonetDB" + assert MonetDbEngineSpec.default_driver == "pymonetdb" + + +def test_monetdb_metadata() -> None: + metadata = MonetDbEngineSpec.metadata + assert "MonetDB is an open-source column-oriented" in metadata["description"] + assert metadata["logo"] == "monet-db.png" + assert "sqlalchemy-monetdb" in metadata["pypi_packages"] + assert metadata["default_port"] == 50000 + + +@pytest.mark.parametrize( + "time_grain,expected", + [ + (None, "ts"), + (TimeGrain.SECOND, "CAST(FLOOR(EXTRACT(EPOCH FROM ts)) AS TIMESTAMP)"), + ( + TimeGrain.MINUTE, + "CAST(ts AS TIMESTAMP) - CAST(EXTRACT(SECOND FROM ts) AS INTERVAL SECOND)", + ), + ( + TimeGrain.HOUR, + "CAST(ts AS TIMESTAMP) - CAST(EXTRACT(MINUTE FROM ts) AS INTERVAL MINUTE) - " + "CAST(EXTRACT(SECOND FROM ts) AS INTERVAL SECOND)", + ), + (TimeGrain.DAY, "CAST(ts AS DATE)"), + ( + TimeGrain.MONTH, + "CAST(EXTRACT(YEAR FROM ts) || '-' || " + "LPAD(CAST(EXTRACT(MONTH FROM ts) AS VARCHAR), 2, '0') || '-01' AS DATE)", + ), + (TimeGrain.YEAR, "CAST(EXTRACT(YEAR FROM ts) || '-01-01' AS DATE)"), + ], +) +def test_time_grain_expressions(time_grain: str | None, expected: str) -> None: + assert MonetDbEngineSpec._time_grain_expressions[time_grain].format(col="ts") == expected From d8e60022a9d882de811dae5141df4e834a4a202e Mon Sep 17 00:00:00 2001 From: FrancescoCastaldi Date: Sun, 30 Aug 2026 22:25:59 +0200 Subject: [PATCH 23/62] test(db_engine_specs): add unit tests for SolrEngineSpec --- tests/unit_tests/db_engine_specs/test_solr.py | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 tests/unit_tests/db_engine_specs/test_solr.py diff --git a/tests/unit_tests/db_engine_specs/test_solr.py b/tests/unit_tests/db_engine_specs/test_solr.py new file mode 100644 index 000000000000..d42b61f72ab9 --- /dev/null +++ b/tests/unit_tests/db_engine_specs/test_solr.py @@ -0,0 +1,37 @@ +# 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 superset.db_engine_specs.solr import SolrEngineSpec + + +def test_solr_properties() -> None: + assert SolrEngineSpec.engine == "solr" + assert SolrEngineSpec.engine_name == "Apache Solr" + assert SolrEngineSpec.allows_joins is False + assert SolrEngineSpec.allows_subqueries is False + + +def test_solr_metadata() -> None: + metadata = SolrEngineSpec.metadata + assert "Apache Solr" in metadata["description"] + assert metadata["logo"] == "apache-solr.png" + assert "sqlalchemy-solr" in metadata["pypi_packages"] + assert metadata["default_port"] == 8983 + assert "solr://" in metadata["connection_string"] + + +def test_time_grain_expressions() -> None: + assert SolrEngineSpec._time_grain_expressions[None].format(col="ts") == "ts" From 2c4c901af621f618478c4849882134b078ab1ae4 Mon Sep 17 00:00:00 2001 From: FrancescoCastaldi Date: Sun, 30 Aug 2026 22:26:08 +0200 Subject: [PATCH 24/62] test(db_engine_specs): add unit tests for RisingWaveDbEngineSpec --- .../db_engine_specs/test_risingwave.py | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 tests/unit_tests/db_engine_specs/test_risingwave.py diff --git a/tests/unit_tests/db_engine_specs/test_risingwave.py b/tests/unit_tests/db_engine_specs/test_risingwave.py new file mode 100644 index 000000000000..2f6636b10089 --- /dev/null +++ b/tests/unit_tests/db_engine_specs/test_risingwave.py @@ -0,0 +1,34 @@ +# 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 superset.db_engine_specs.postgres import PostgresEngineSpec +from superset.db_engine_specs.risingwave import RisingWaveDbEngineSpec + + +def test_risingwave_properties() -> None: + assert RisingWaveDbEngineSpec.engine == "risingwave" + assert RisingWaveDbEngineSpec.engine_name == "RisingWave" + assert issubclass(RisingWaveDbEngineSpec, PostgresEngineSpec) + assert RisingWaveDbEngineSpec._extended_aggregations == {} + + +def test_risingwave_metadata() -> None: + metadata = RisingWaveDbEngineSpec.metadata + assert "RisingWave is a distributed streaming database" in metadata["description"] + assert metadata["logo"] == "risingwave.svg" + assert "sqlalchemy-risingwave" in metadata["pypi_packages"] + assert metadata["default_port"] == 4566 + assert "risingwave://" in metadata["connection_string"] From ae9260dbb6a6e526648a93ccaddf6a189ee195af Mon Sep 17 00:00:00 2001 From: FrancescoCastaldi Date: Sun, 30 Aug 2026 22:26:18 +0200 Subject: [PATCH 25/62] test(db_engine_specs): add unit tests for PhoenixEngineSpec --- .../db_engine_specs/test_phoenix.py | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 tests/unit_tests/db_engine_specs/test_phoenix.py diff --git a/tests/unit_tests/db_engine_specs/test_phoenix.py b/tests/unit_tests/db_engine_specs/test_phoenix.py new file mode 100644 index 000000000000..d6b0ff473ccd --- /dev/null +++ b/tests/unit_tests/db_engine_specs/test_phoenix.py @@ -0,0 +1,72 @@ +# 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 datetime import datetime + +import pytest + +from superset.constants import TimeGrain +from superset.db_engine_specs.phoenix import PhoenixEngineSpec +from tests.unit_tests.db_engine_specs.utils import assert_convert_dttm +from tests.unit_tests.fixtures.common import dttm # noqa: F401 + + +def test_phoenix_properties() -> None: + assert PhoenixEngineSpec.engine == "phoenix" + assert PhoenixEngineSpec.engine_name == "Apache Phoenix" + + +def test_phoenix_metadata() -> None: + metadata = PhoenixEngineSpec.metadata + assert "Apache Phoenix is a relational database layer" in metadata["description"] + assert metadata["logo"] == "apache-phoenix.png" + assert "phoenixdb" in metadata["pypi_packages"] + assert metadata["default_port"] == 8765 + + +@pytest.mark.parametrize( + "target_type,expected_result", + [ + ("Date", "TO_DATE('2019-01-02', 'yyyy-MM-dd')"), + ("DateTime", "TO_TIMESTAMP('2019-01-02 03:04:05', 'yyyy-MM-dd HH:mm:ss')"), + ("TIMESTAMP", "TO_TIMESTAMP('2019-01-02 03:04:05', 'yyyy-MM-dd HH:mm:ss')"), + ("UnknownType", None), + ], +) +def test_convert_dttm( + target_type: str, + expected_result: str | None, + dttm: datetime, # noqa: F811 +) -> None: + assert_convert_dttm(PhoenixEngineSpec, target_type, expected_result, dttm) + + +@pytest.mark.parametrize( + "time_grain,expected", + [ + (None, "ts"), + (TimeGrain.SECOND, "CAST(TRUNC(CAST(ts AS TIMESTAMP), 'SECOND') AS TIMESTAMP)"), + (TimeGrain.MINUTE, "CAST(TRUNC(CAST(ts AS TIMESTAMP), 'MINUTE') AS TIMESTAMP)"), + (TimeGrain.HOUR, "CAST(TRUNC(CAST(ts AS TIMESTAMP), 'HOUR') AS TIMESTAMP)"), + (TimeGrain.DAY, "CAST(TRUNC(CAST(ts AS TIMESTAMP), 'DAY') AS DATE)"), + (TimeGrain.WEEK, "CAST(TRUNC(CAST(ts AS TIMESTAMP), 'WEEK') AS DATE)"), + (TimeGrain.MONTH, "CAST(TRUNC(CAST(ts AS TIMESTAMP), 'MONTH') AS DATE)"), + (TimeGrain.QUARTER, "CAST(TRUNC(CAST(ts AS TIMESTAMP), 'QUARTER') AS DATE)"), + (TimeGrain.YEAR, "CAST(TRUNC(CAST(ts AS TIMESTAMP), 'YEAR') AS DATE)"), + ], +) +def test_time_grain_expressions(time_grain: str | None, expected: str) -> None: + assert PhoenixEngineSpec._time_grain_expressions[time_grain].format(col="ts") == expected From 3b7e0b2e247a332aa80ffb04adeb7b2fe0f9b77e Mon Sep 17 00:00:00 2001 From: FrancescoCastaldi Date: Sun, 30 Aug 2026 22:26:27 +0200 Subject: [PATCH 26/62] test(db_engine_specs): add unit tests for NetezzaEngineSpec --- .../db_engine_specs/test_netezza.py | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 tests/unit_tests/db_engine_specs/test_netezza.py diff --git a/tests/unit_tests/db_engine_specs/test_netezza.py b/tests/unit_tests/db_engine_specs/test_netezza.py new file mode 100644 index 000000000000..c58fe58ff176 --- /dev/null +++ b/tests/unit_tests/db_engine_specs/test_netezza.py @@ -0,0 +1,62 @@ +# 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 pytest + +from superset.constants import TimeGrain +from superset.db_engine_specs.netezza import NetezzaEngineSpec +from superset.db_engine_specs.postgres import PostgresBaseEngineSpec + + +def test_netezza_properties() -> None: + assert NetezzaEngineSpec.engine == "netezza" + assert NetezzaEngineSpec.engine_name == "IBM Netezza Performance Server" + assert NetezzaEngineSpec.default_driver == "nzpy" + assert issubclass(NetezzaEngineSpec, PostgresBaseEngineSpec) + assert NetezzaEngineSpec._extended_aggregations == {} + + +def test_netezza_metadata() -> None: + metadata = NetezzaEngineSpec.metadata + assert "IBM Netezza Performance Server" in metadata["description"] + assert metadata["logo"] == "netezza.png" + assert "nzalchemy" in metadata["pypi_packages"] + assert metadata["default_port"] == 5480 + + +def test_netezza_epoch_to_dttm() -> None: + assert ( + NetezzaEngineSpec.epoch_to_dttm().format(col="ts") + == "(timestamp 'epoch' + ts * interval '1 second')" + ) + + +@pytest.mark.parametrize( + "time_grain,expected", + [ + (None, "ts"), + (TimeGrain.SECOND, "DATE_TRUNC('second', ts)"), + (TimeGrain.MINUTE, "DATE_TRUNC('minute', ts)"), + (TimeGrain.HOUR, "DATE_TRUNC('hour', ts)"), + (TimeGrain.DAY, "DATE_TRUNC('day', ts)"), + (TimeGrain.WEEK, "DATE_TRUNC('week', ts)"), + (TimeGrain.MONTH, "DATE_TRUNC('month', ts)"), + (TimeGrain.QUARTER, "DATE_TRUNC('quarter', ts)"), + (TimeGrain.YEAR, "DATE_TRUNC('year', ts)"), + ], +) +def test_time_grain_expressions(time_grain: str | None, expected: str) -> None: + assert NetezzaEngineSpec._time_grain_expressions[time_grain].format(col="ts") == expected From 2965c8b4235d6e0e6acf9f6a6e9245633d5f110c Mon Sep 17 00:00:00 2001 From: FrancescoCastaldi Date: Sun, 30 Aug 2026 22:26:39 +0200 Subject: [PATCH 27/62] test(db_engine_specs): add unit tests for GreenplumEngineSpec --- .../db_engine_specs/test_greenplum.py | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 tests/unit_tests/db_engine_specs/test_greenplum.py diff --git a/tests/unit_tests/db_engine_specs/test_greenplum.py b/tests/unit_tests/db_engine_specs/test_greenplum.py new file mode 100644 index 000000000000..3aa3de89182c --- /dev/null +++ b/tests/unit_tests/db_engine_specs/test_greenplum.py @@ -0,0 +1,34 @@ +# 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 superset.db_engine_specs.greenplum import GreenplumEngineSpec +from superset.db_engine_specs.postgres import PostgresEngineSpec + + +def test_greenplum_properties() -> None: + assert GreenplumEngineSpec.engine == "greenplum" + assert GreenplumEngineSpec.engine_name == "Greenplum" + assert GreenplumEngineSpec.default_driver == "psycopg2" + assert issubclass(GreenplumEngineSpec, PostgresEngineSpec) + assert GreenplumEngineSpec._extended_aggregations == {} + + +def test_greenplum_metadata() -> None: + metadata = GreenplumEngineSpec.metadata + assert "VMware Greenplum" in metadata["description"] + assert metadata["logo"] == "greenplum.png" + assert "sqlalchemy-greenplum" in metadata["pypi_packages"] + assert metadata["default_port"] == 5432 From 2be71ba9af9103d719a778fa448bfc914fdb17d1 Mon Sep 17 00:00:00 2001 From: FrancescoCastaldi Date: Sun, 30 Aug 2026 22:26:48 +0200 Subject: [PATCH 28/62] test(db_engine_specs): add unit tests for YugabyteDBEngineSpec --- .../db_engine_specs/test_yugabytedb.py | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 tests/unit_tests/db_engine_specs/test_yugabytedb.py diff --git a/tests/unit_tests/db_engine_specs/test_yugabytedb.py b/tests/unit_tests/db_engine_specs/test_yugabytedb.py new file mode 100644 index 000000000000..cfb79db228e6 --- /dev/null +++ b/tests/unit_tests/db_engine_specs/test_yugabytedb.py @@ -0,0 +1,34 @@ +# 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 superset.db_engine_specs.postgres import PostgresBaseEngineSpec +from superset.db_engine_specs.yugabytedb import YugabyteDBEngineSpec + + +def test_yugabytedb_properties() -> None: + assert YugabyteDBEngineSpec.engine == "yugabytedb" + assert YugabyteDBEngineSpec.engine_name == "YugabyteDB" + assert YugabyteDBEngineSpec.default_driver == "psycopg2" + assert issubclass(YugabyteDBEngineSpec, PostgresBaseEngineSpec) + assert YugabyteDBEngineSpec._extended_aggregations == {} + + +def test_yugabytedb_metadata() -> None: + metadata = YugabyteDBEngineSpec.metadata + assert "YugabyteDB is a distributed SQL database" in metadata["description"] + assert metadata["logo"] == "yugabyte.png" + assert "psycopg2" in metadata["pypi_packages"] + assert metadata["default_port"] == 5433 From 8b9e2cd3c9d3966b070bb61d308da16d616817f6 Mon Sep 17 00:00:00 2001 From: FrancescoCastaldi Date: Sun, 30 Aug 2026 22:27:00 +0200 Subject: [PATCH 29/62] test(db_engine_specs): add unit tests for ShillelaghEngineSpec --- .../db_engine_specs/test_shillelagh.py | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 tests/unit_tests/db_engine_specs/test_shillelagh.py diff --git a/tests/unit_tests/db_engine_specs/test_shillelagh.py b/tests/unit_tests/db_engine_specs/test_shillelagh.py new file mode 100644 index 000000000000..9e03c65ac616 --- /dev/null +++ b/tests/unit_tests/db_engine_specs/test_shillelagh.py @@ -0,0 +1,35 @@ +# 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 superset.db_engine_specs.shillelagh import ShillelaghEngineSpec +from superset.db_engine_specs.sqlite import SqliteEngineSpec + + +def test_shillelagh_properties() -> None: + assert ShillelaghEngineSpec.engine == "shillelagh" + assert ShillelaghEngineSpec.engine_name == "Shillelagh" + assert ShillelaghEngineSpec.default_driver == "apsw" + assert issubclass(ShillelaghEngineSpec, SqliteEngineSpec) + assert ShillelaghEngineSpec.allows_joins is True + assert ShillelaghEngineSpec.allows_subqueries is True + + +def test_shillelagh_metadata() -> None: + metadata = ShillelaghEngineSpec.metadata + assert "Shillelagh is a Python library" in metadata["description"] + assert metadata["logo"] == "shillelagh.png" + assert "shillelagh[gsheetsapi]" in metadata["pypi_packages"] + assert metadata["connection_string"] == "shillelagh://" From bf8b5cb407c2477328ce40a2cbbe2abbe02bfcf2 Mon Sep 17 00:00:00 2001 From: FrancescoCastaldi Date: Sun, 30 Aug 2026 22:27:09 +0200 Subject: [PATCH 30/62] test(db_engine_specs): add unit tests for TeradataEngineSpec --- .../db_engine_specs/test_teradata.py | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 tests/unit_tests/db_engine_specs/test_teradata.py diff --git a/tests/unit_tests/db_engine_specs/test_teradata.py b/tests/unit_tests/db_engine_specs/test_teradata.py new file mode 100644 index 000000000000..fa319833aa0a --- /dev/null +++ b/tests/unit_tests/db_engine_specs/test_teradata.py @@ -0,0 +1,54 @@ +# 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 pytest + +from superset.db_engine_specs.teradata import TeradataEngineSpec + + +def test_teradata_properties() -> None: + assert TeradataEngineSpec.engine == "teradatasql" + assert TeradataEngineSpec.engine_name == "Teradata" + assert TeradataEngineSpec.max_column_name_length == 30 + + +def test_teradata_metadata() -> None: + metadata = TeradataEngineSpec.metadata + assert "Teradata is an enterprise data warehouse platform" in metadata["description"] + assert metadata["logo"] == "teradata.png" + assert "teradatasqlalchemy" in metadata["pypi_packages"] + assert metadata["default_port"] == 1025 + + +def test_teradata_epoch_to_dttm() -> None: + assert "DATE '1970-01-01'" in TeradataEngineSpec.epoch_to_dttm().format(col="ts") + + +@pytest.mark.parametrize( + "time_grain,expected", + [ + (None, "ts"), + ("PT1M", "TRUNC(CAST(ts as DATE), 'MI')"), + ("PT1H", "TRUNC(CAST(ts as DATE), 'HH')"), + ("P1D", "TRUNC(CAST(ts as DATE), 'DDD')"), + ("P1W", "TRUNC(CAST(ts as DATE), 'WW')"), + ("P1M", "TRUNC(CAST(ts as DATE), 'MONTH')"), + ("P3M", "TRUNC(CAST(ts as DATE), 'Q')"), + ("P1Y", "TRUNC(CAST(ts as DATE), 'YEAR')"), + ], +) +def test_time_grain_expressions(time_grain: str | None, expected: str) -> None: + assert TeradataEngineSpec._time_grain_expressions[time_grain].format(col="ts") == expected From 8a894090f3de1e9c2169b6904560fbcba38b7d31 Mon Sep 17 00:00:00 2001 From: FrancescoCastaldi Date: Sun, 30 Aug 2026 22:27:19 +0200 Subject: [PATCH 31/62] test(db_engine_specs): add unit tests for VerticaEngineSpec --- .../db_engine_specs/test_vertica.py | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 tests/unit_tests/db_engine_specs/test_vertica.py diff --git a/tests/unit_tests/db_engine_specs/test_vertica.py b/tests/unit_tests/db_engine_specs/test_vertica.py new file mode 100644 index 000000000000..00222a00af38 --- /dev/null +++ b/tests/unit_tests/db_engine_specs/test_vertica.py @@ -0,0 +1,33 @@ +# 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 superset.db_engine_specs.postgres import PostgresBaseEngineSpec +from superset.db_engine_specs.vertica import VerticaEngineSpec + + +def test_vertica_properties() -> None: + assert VerticaEngineSpec.engine == "vertica" + assert VerticaEngineSpec.engine_name == "Vertica" + assert issubclass(VerticaEngineSpec, PostgresBaseEngineSpec) + assert VerticaEngineSpec._extended_aggregations == {} + + +def test_vertica_metadata() -> None: + metadata = VerticaEngineSpec.metadata + assert "Vertica is a column-oriented analytics database" in metadata["description"] + assert metadata["logo"] == "vertica.png" + assert "sqlalchemy-vertica-python" in metadata["pypi_packages"] + assert metadata["default_port"] == 5433 From a989b99592bbc55aa1574778c6c1ccc1a5d29938 Mon Sep 17 00:00:00 2001 From: FrancescoCastaldi Date: Sun, 30 Aug 2026 22:27:30 +0200 Subject: [PATCH 32/62] test(db_engine_specs): add unit tests for SparkEngineSpec --- .../unit_tests/db_engine_specs/test_spark.py | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 tests/unit_tests/db_engine_specs/test_spark.py diff --git a/tests/unit_tests/db_engine_specs/test_spark.py b/tests/unit_tests/db_engine_specs/test_spark.py new file mode 100644 index 000000000000..39f01a13d43b --- /dev/null +++ b/tests/unit_tests/db_engine_specs/test_spark.py @@ -0,0 +1,61 @@ +# 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 pytest + +from superset.constants import TimeGrain +from superset.db_engine_specs.hive import HiveEngineSpec +from superset.db_engine_specs.spark import SparkEngineSpec + + +def test_spark_properties() -> None: + assert SparkEngineSpec.engine == "spark" + assert SparkEngineSpec.engine_name == "Apache Spark SQL" + assert issubclass(SparkEngineSpec, HiveEngineSpec) + + +def test_spark_metadata() -> None: + metadata = SparkEngineSpec.metadata + assert "Apache Spark SQL" in metadata["description"] + assert metadata["logo"] == "apache-spark.png" + assert "pyhive" in metadata["pypi_packages"] + assert metadata["default_port"] == 10000 + + +@pytest.mark.parametrize( + "time_grain,expected", + [ + (None, "ts"), + (TimeGrain.SECOND, "date_trunc('second', ts)"), + (TimeGrain.MINUTE, "date_trunc('minute', ts)"), + (TimeGrain.HOUR, "date_trunc('hour', ts)"), + (TimeGrain.DAY, "date_trunc('day', ts)"), + (TimeGrain.WEEK, "date_trunc('week', ts)"), + (TimeGrain.MONTH, "date_trunc('month', ts)"), + (TimeGrain.QUARTER, "date_trunc('quarter', ts)"), + (TimeGrain.YEAR, "date_trunc('year', ts)"), + ( + TimeGrain.WEEK_ENDING_SATURDAY, + "date_trunc('week', ts + interval '1 day') + interval '5 days'", + ), + ( + TimeGrain.WEEK_STARTING_SUNDAY, + "date_trunc('week', ts + interval '1 day') - interval '1 day'", + ), + ], +) +def test_time_grain_expressions(time_grain: str | None, expected: str) -> None: + assert SparkEngineSpec._time_grain_expressions[time_grain].format(col="ts") == expected From 9c4322fe1e9c13b00e4dfd0f68fbce47f636a1a3 Mon Sep 17 00:00:00 2001 From: FrancescoCastaldi Date: Sun, 30 Aug 2026 22:27:44 +0200 Subject: [PATCH 33/62] test(db_engine_specs): add unit tests for SupersetEngineSpec --- .../db_engine_specs/test_superset.py | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 tests/unit_tests/db_engine_specs/test_superset.py diff --git a/tests/unit_tests/db_engine_specs/test_superset.py b/tests/unit_tests/db_engine_specs/test_superset.py new file mode 100644 index 000000000000..fc3a580d60f3 --- /dev/null +++ b/tests/unit_tests/db_engine_specs/test_superset.py @@ -0,0 +1,33 @@ +# 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 superset.db_engine_specs.shillelagh import ShillelaghEngineSpec +from superset.db_engine_specs.superset import SupersetEngineSpec + + +def test_superset_properties() -> None: + assert SupersetEngineSpec.engine == "superset" + assert SupersetEngineSpec.engine_name == "Superset meta database" + assert SupersetEngineSpec.default_driver == "" + assert issubclass(SupersetEngineSpec, ShillelaghEngineSpec) + assert SupersetEngineSpec.supports_file_upload is False + + +def test_superset_metadata() -> None: + metadata = SupersetEngineSpec.metadata + assert "Superset meta database" in metadata["description"] + assert metadata["logo"] == "superset.svg" + assert metadata["connection_string"] == "superset://" From 408c1901f22d1772896727961210746482eeb197 Mon Sep 17 00:00:00 2001 From: FrancescoCastaldi Date: Wed, 2 Sep 2026 12:40:28 +0200 Subject: [PATCH 34/62] fix: resolve pre-commit and CodeQL alerts --- superset/db_engine_specs/aurora.py | 6 +++-- superset/db_engine_specs/lint_metadata.py | 22 ++++++++++++++----- .../unit_tests/db_engine_specs/test_spark.py | 3 ++- .../db_engine_specs/test_teradata.py | 6 +++-- 4 files changed, 27 insertions(+), 10 deletions(-) diff --git a/superset/db_engine_specs/aurora.py b/superset/db_engine_specs/aurora.py index 4b46676dd5da..bececc8d48b9 100644 --- a/superset/db_engine_specs/aurora.py +++ b/superset/db_engine_specs/aurora.py @@ -122,7 +122,8 @@ class AuroraMySQLEngineSpec(MySQLEngineSpec): metadata = { "description": ( - "Amazon Aurora MySQL is a fully managed, MySQL-compatible relational database." + "Amazon Aurora MySQL is a fully managed, " + "MySQL-compatible relational database." ), "logo": "aws-aurora.jpg", "homepage_url": "https://aws.amazon.com/rds/aurora/", @@ -158,7 +159,8 @@ class AuroraPostgresEngineSpec(PostgresEngineSpec): metadata = { "description": ( - "Amazon Aurora PostgreSQL is a fully managed, PostgreSQL-compatible relational database." + "Amazon Aurora PostgreSQL is a fully managed, " + "PostgreSQL-compatible relational database." ), "logo": "aws-aurora.jpg", "homepage_url": "https://aws.amazon.com/rds/aurora/", diff --git a/superset/db_engine_specs/lint_metadata.py b/superset/db_engine_specs/lint_metadata.py index ea8da331be1e..6457130d5de1 100644 --- a/superset/db_engine_specs/lint_metadata.py +++ b/superset/db_engine_specs/lint_metadata.py @@ -103,7 +103,10 @@ def check_pypi_package(package_name: str, timeout: float = 5.0) -> bool: if base_name in _pypi_cache: return _pypi_cache[base_name] - url = f"https://pypi.org/pypi/{base_name}/json" + import urllib.parse + + safe_base_name = urllib.parse.quote(base_name, safe="") + url = f"https://pypi.org/pypi/{safe_base_name}/json" try: req = urllib.request.Request( # noqa: S310 url, headers={"User-Agent": "superset-lint/1.0"} @@ -276,7 +279,10 @@ def get_all_engine_specs_ast() -> list[dict[str, Any]]: # noqa: C901 has_non_empty_engine = bool(item.value.value) break elif isinstance(item, ast.AnnAssign): - if isinstance(item.target, ast.Name) and item.target.id == "engine": + if ( + isinstance(item.target, ast.Name) + and item.target.id == "engine" + ): if isinstance(item.value, ast.Constant): has_non_empty_engine = bool(item.value.value) break @@ -307,7 +313,9 @@ def get_all_engine_specs_ast() -> list[dict[str, Any]]: # noqa: C901 if item.target.id == "engine_name": if isinstance(item.value, ast.Constant): engine_name = item.value.value - elif item.target.id == "metadata" and item.value is not None: + elif ( + item.target.id == "metadata" and item.value is not None + ): try: metadata = _eval_ast_dict(item.value) except Exception: @@ -352,9 +360,13 @@ def _eval_ast_value(node: Any) -> Any: # noqa: C901 if isinstance(node, ast.Constant): return node.value - elif hasattr(ast, "Str") and isinstance(node, getattr(ast, "Str")): # Python <3.8 compat + elif hasattr(ast, "Str") and isinstance( + node, getattr(ast, "Str") + ): # Python <3.8 compat return getattr(node, "s") - elif hasattr(ast, "Num") and isinstance(node, getattr(ast, "Num")): # Python <3.8 compat + elif hasattr(ast, "Num") and isinstance( + node, getattr(ast, "Num") + ): # Python <3.8 compat return getattr(node, "n") elif isinstance(node, ast.List): return [_eval_ast_value(e) for e in node.elts] diff --git a/tests/unit_tests/db_engine_specs/test_spark.py b/tests/unit_tests/db_engine_specs/test_spark.py index 39f01a13d43b..38bc733c4d47 100644 --- a/tests/unit_tests/db_engine_specs/test_spark.py +++ b/tests/unit_tests/db_engine_specs/test_spark.py @@ -58,4 +58,5 @@ def test_spark_metadata() -> None: ], ) def test_time_grain_expressions(time_grain: str | None, expected: str) -> None: - assert SparkEngineSpec._time_grain_expressions[time_grain].format(col="ts") == expected + actual = SparkEngineSpec._time_grain_expressions[time_grain].format(col="ts") + assert actual == expected diff --git a/tests/unit_tests/db_engine_specs/test_teradata.py b/tests/unit_tests/db_engine_specs/test_teradata.py index fa319833aa0a..0df6f36184d7 100644 --- a/tests/unit_tests/db_engine_specs/test_teradata.py +++ b/tests/unit_tests/db_engine_specs/test_teradata.py @@ -27,7 +27,8 @@ def test_teradata_properties() -> None: def test_teradata_metadata() -> None: metadata = TeradataEngineSpec.metadata - assert "Teradata is an enterprise data warehouse platform" in metadata["description"] + desc = metadata["description"] + assert "Teradata is an enterprise data warehouse platform" in desc assert metadata["logo"] == "teradata.png" assert "teradatasqlalchemy" in metadata["pypi_packages"] assert metadata["default_port"] == 1025 @@ -51,4 +52,5 @@ def test_teradata_epoch_to_dttm() -> None: ], ) def test_time_grain_expressions(time_grain: str | None, expected: str) -> None: - assert TeradataEngineSpec._time_grain_expressions[time_grain].format(col="ts") == expected + actual = TeradataEngineSpec._time_grain_expressions[time_grain].format(col="ts") + assert actual == expected From b055c221466a2e73aec58cf25d569034cbffeed4 Mon Sep 17 00:00:00 2001 From: FrancescoCastaldi Date: Wed, 2 Sep 2026 12:41:05 +0200 Subject: [PATCH 35/62] chore: fix ruff auto-fixable warnings --- superset/db_engine_specs/lint_metadata.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/superset/db_engine_specs/lint_metadata.py b/superset/db_engine_specs/lint_metadata.py index 6457130d5de1..51b62a3212f2 100644 --- a/superset/db_engine_specs/lint_metadata.py +++ b/superset/db_engine_specs/lint_metadata.py @@ -361,13 +361,13 @@ def _eval_ast_value(node: Any) -> Any: # noqa: C901 if isinstance(node, ast.Constant): return node.value elif hasattr(ast, "Str") and isinstance( - node, getattr(ast, "Str") + node, ast.Str ): # Python <3.8 compat - return getattr(node, "s") + return node.s elif hasattr(ast, "Num") and isinstance( - node, getattr(ast, "Num") + node, ast.Num ): # Python <3.8 compat - return getattr(node, "n") + return node.n elif isinstance(node, ast.List): return [_eval_ast_value(e) for e in node.elts] elif isinstance(node, ast.Tuple): From 29d57c95827e670cd823a9631631b4d6f22afea1 Mon Sep 17 00:00:00 2001 From: FrancescoCastaldi Date: Thu, 3 Sep 2026 09:49:02 +0200 Subject: [PATCH 36/62] feat(db_engine_specs): restore odps behavior tests, add aurora default_port, and fix CodeQL and lint errors --- superset/db_engine_specs/aurora.py | 1 + tests/unit_tests/db_engine_specs/test_arc.py | 9 +- .../unit_tests/db_engine_specs/test_ascend.py | 7 +- .../unit_tests/db_engine_specs/test_exasol.py | 5 +- .../db_engine_specs/test_monetdb.py | 8 +- .../db_engine_specs/test_netezza.py | 5 +- .../unit_tests/db_engine_specs/test_ocient.py | 15 +- tests/unit_tests/db_engine_specs/test_odps.py | 157 ++++++++++++++++++ .../db_engine_specs/test_parseable.py | 14 +- .../db_engine_specs/test_phoenix.py | 5 +- 10 files changed, 211 insertions(+), 15 deletions(-) diff --git a/superset/db_engine_specs/aurora.py b/superset/db_engine_specs/aurora.py index bececc8d48b9..77a8e5f5d571 100644 --- a/superset/db_engine_specs/aurora.py +++ b/superset/db_engine_specs/aurora.py @@ -95,6 +95,7 @@ class AuroraPostgresDataAPI(PostgresEngineSpec): "{database_name}?aurora_cluster_arn={aurora_cluster_arn}&" "secret_arn={secret_arn}®ion_name={region_name}" ), + "default_port": 5432, "parameters": { "aws_access_id": "AWS Access Key ID", "aws_secret_access_key": "AWS Secret Access Key", diff --git a/tests/unit_tests/db_engine_specs/test_arc.py b/tests/unit_tests/db_engine_specs/test_arc.py index 2e500167beb2..2ef32452d23e 100644 --- a/tests/unit_tests/db_engine_specs/test_arc.py +++ b/tests/unit_tests/db_engine_specs/test_arc.py @@ -28,7 +28,10 @@ def test_arc_properties() -> None: def test_arc_metadata() -> None: metadata = ArcEngineSpec.metadata - assert metadata["description"] == "Arc is a data platform with multiple connection options." + assert ( + metadata["description"] + == "Arc is a data platform with multiple connection options." + ) assert metadata["logo"] == "arc.png" assert "arc-superset-arrow" in metadata["pypi_packages"] assert metadata["default_port"] == 443 @@ -49,4 +52,6 @@ def test_arc_metadata() -> None: ], ) def test_time_grain_expressions(time_grain: str | None, expected: str) -> None: - assert ArcEngineSpec._time_grain_expressions[time_grain].format(col="ts") == expected + assert ( + ArcEngineSpec._time_grain_expressions[time_grain].format(col="ts") == expected + ) diff --git a/tests/unit_tests/db_engine_specs/test_ascend.py b/tests/unit_tests/db_engine_specs/test_ascend.py index 1d2f6c9717ee..8f7af5c8fca8 100644 --- a/tests/unit_tests/db_engine_specs/test_ascend.py +++ b/tests/unit_tests/db_engine_specs/test_ascend.py @@ -29,7 +29,7 @@ def test_ascend_properties() -> None: def test_ascend_metadata() -> None: metadata = AscendEngineSpec.metadata - assert "Ascend.io" in metadata["description"] + assert "Ascend" in metadata["description"] assert metadata["logo"] == "ascend.webp" assert "impyla" in metadata["pypi_packages"] assert "ascend://" in metadata["connection_string"] @@ -50,4 +50,7 @@ def test_ascend_metadata() -> None: ], ) def test_time_grain_expressions(time_grain: str | None, expected: str) -> None: - assert AscendEngineSpec._time_grain_expressions[time_grain].format(col="ts") == expected + assert ( + AscendEngineSpec._time_grain_expressions[time_grain].format(col="ts") + == expected + ) diff --git a/tests/unit_tests/db_engine_specs/test_exasol.py b/tests/unit_tests/db_engine_specs/test_exasol.py index 27d456bf34c8..884831d99222 100644 --- a/tests/unit_tests/db_engine_specs/test_exasol.py +++ b/tests/unit_tests/db_engine_specs/test_exasol.py @@ -49,4 +49,7 @@ def test_exasol_metadata() -> None: ], ) def test_time_grain_expressions(time_grain: str | None, expected: str) -> None: - assert ExasolEngineSpec._time_grain_expressions[time_grain].format(col="ts") == expected + assert ( + ExasolEngineSpec._time_grain_expressions[time_grain].format(col="ts") + == expected + ) diff --git a/tests/unit_tests/db_engine_specs/test_monetdb.py b/tests/unit_tests/db_engine_specs/test_monetdb.py index 382704cf5049..4eaa4dab6079 100644 --- a/tests/unit_tests/db_engine_specs/test_monetdb.py +++ b/tests/unit_tests/db_engine_specs/test_monetdb.py @@ -45,7 +45,8 @@ def test_monetdb_metadata() -> None: ), ( TimeGrain.HOUR, - "CAST(ts AS TIMESTAMP) - CAST(EXTRACT(MINUTE FROM ts) AS INTERVAL MINUTE) - " + "CAST(ts AS TIMESTAMP) - " + "CAST(EXTRACT(MINUTE FROM ts) AS INTERVAL MINUTE) - " "CAST(EXTRACT(SECOND FROM ts) AS INTERVAL SECOND)", ), (TimeGrain.DAY, "CAST(ts AS DATE)"), @@ -58,4 +59,7 @@ def test_monetdb_metadata() -> None: ], ) def test_time_grain_expressions(time_grain: str | None, expected: str) -> None: - assert MonetDbEngineSpec._time_grain_expressions[time_grain].format(col="ts") == expected + assert ( + MonetDbEngineSpec._time_grain_expressions[time_grain].format(col="ts") + == expected + ) diff --git a/tests/unit_tests/db_engine_specs/test_netezza.py b/tests/unit_tests/db_engine_specs/test_netezza.py index c58fe58ff176..f6d53448aa5f 100644 --- a/tests/unit_tests/db_engine_specs/test_netezza.py +++ b/tests/unit_tests/db_engine_specs/test_netezza.py @@ -59,4 +59,7 @@ def test_netezza_epoch_to_dttm() -> None: ], ) def test_time_grain_expressions(time_grain: str | None, expected: str) -> None: - assert NetezzaEngineSpec._time_grain_expressions[time_grain].format(col="ts") == expected + assert ( + NetezzaEngineSpec._time_grain_expressions[time_grain].format(col="ts") + == expected + ) diff --git a/tests/unit_tests/db_engine_specs/test_ocient.py b/tests/unit_tests/db_engine_specs/test_ocient.py index fb46d66cc938..3448575443cc 100644 --- a/tests/unit_tests/db_engine_specs/test_ocient.py +++ b/tests/unit_tests/db_engine_specs/test_ocient.py @@ -37,8 +37,14 @@ def test_ocient_metadata() -> None: def test_ocient_epoch_to_dttm() -> None: - assert OcientEngineSpec.epoch_to_dttm().format(col="ts") == "DATEADD(S, ts, '1970-01-01')" - assert OcientEngineSpec.epoch_ms_to_dttm().format(col="ts") == "DATEADD(MS, ts, '1970-01-01')" + assert ( + OcientEngineSpec.epoch_to_dttm().format(col="ts") + == "DATEADD(S, ts, '1970-01-01')" + ) + assert ( + OcientEngineSpec.epoch_ms_to_dttm().format(col="ts") + == "DATEADD(MS, ts, '1970-01-01')" + ) @pytest.mark.parametrize( @@ -56,4 +62,7 @@ def test_ocient_epoch_to_dttm() -> None: ], ) def test_time_grain_expressions(time_grain: str | None, expected: str) -> None: - assert OcientEngineSpec._time_grain_expressions[time_grain].format(col="ts") == expected + assert ( + OcientEngineSpec._time_grain_expressions[time_grain].format(col="ts") + == expected + ) diff --git a/tests/unit_tests/db_engine_specs/test_odps.py b/tests/unit_tests/db_engine_specs/test_odps.py index 1982a1256580..19e38e36703b 100644 --- a/tests/unit_tests/db_engine_specs/test_odps.py +++ b/tests/unit_tests/db_engine_specs/test_odps.py @@ -14,7 +14,15 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. +import logging +from unittest.mock import MagicMock, patch + +import pytest +from sqlalchemy.dialects import sqlite + +from superset.daos.database import DatabaseDAO from superset.db_engine_specs.odps import OdpsBaseEngineSpec, OdpsEngineSpec +from superset.sql.parse import Partition, Table def test_odps_properties() -> None: @@ -30,3 +38,152 @@ def test_odps_metadata() -> None: assert metadata["logo"] == "maxcompute.png" assert "pyodps" in metadata["pypi_packages"] assert "odps://" in metadata["connection_string"] + + +def test_odps_base_engine_spec_get_table_metadata_raises() -> None: + """OdpsBaseEngineSpec.get_table_metadata must not be called directly.""" + with pytest.raises(NotImplementedError): + OdpsBaseEngineSpec.get_table_metadata( + database=MagicMock(), + table=Table("my_table", None, None), + ) + + +def test_odps_engine_spec_select_star_no_partition() -> None: + """select_star for a non-partitioned ODPS table produces a plain SELECT *.""" + database = MagicMock() + database.backend = "odps" + database.get_columns.return_value = [] + database.compile_sqla_query = lambda query, catalog, schema: str( + query.compile(dialect=sqlite.dialect()) + ) + dialect = sqlite.dialect() + + sql = OdpsEngineSpec.select_star( + database=database, + table=Table("my_table", None, None), + dialect=dialect, + limit=100, + show_cols=False, + indent=False, + latest_partition=False, + partition=None, + ) + + assert "SELECT" in sql + assert "my_table" in sql + + +def test_odps_engine_spec_select_star_with_partition() -> None: + """select_star for a partitioned ODPS table adds a WHERE clause.""" + database = MagicMock() + database.backend = "odps" + database.get_columns.return_value = [] + database.compile_sqla_query = lambda query, catalog, schema: str( + query.compile(dialect=sqlite.dialect()) + ) + dialect = sqlite.dialect() + partition = Partition(is_partitioned_table=True, partition_column=("month",)) + + sql = OdpsEngineSpec.select_star( + database=database, + table=Table("my_table", None, None), + dialect=dialect, + limit=100, + show_cols=False, + indent=False, + latest_partition=False, + partition=partition, + ) + + assert "WHERE" in sql + + +def test_is_odps_partitioned_table_non_odps_backend() -> None: + """Returns (False, []) immediately for non-ODPS databases; no network call made.""" + database = MagicMock() + database.backend = "postgresql" + + result = DatabaseDAO.is_odps_partitioned_table(database, "some_table") + + assert result == (False, []) + + +def test_is_odps_partitioned_table_missing_pyodps() -> None: + """Returns (False, []) with a warning when pyodps is not installed.""" + database = MagicMock() + database.backend = "odps" + database.sqlalchemy_uri = ( + "odps://mykey:mysecret@myproject/?endpoint=http://service.odps.test" + ) + database.password = "mysecret" # noqa: S105 + + with patch("superset.daos.database.ODPS", None): + result = DatabaseDAO.is_odps_partitioned_table(database, "some_table") + + assert result == (False, []) + + +def test_is_odps_partitioned_table_uri_no_match( + caplog: pytest.LogCaptureFixture, +) -> None: + """Logs a warning and returns (False, []) when the URI doesn't match the pattern.""" + database = MagicMock() + database.backend = "odps" + database.sqlalchemy_uri = "odps://invalid-uri-format" + database.password = "secret" # noqa: S105 + + with patch("superset.daos.database.ODPS", MagicMock()): + with caplog.at_level(logging.WARNING, logger="superset.daos.database"): + result = DatabaseDAO.is_odps_partitioned_table(database, "some_table") + + assert result == (False, []) + assert "did not match" in caplog.text + + +def test_is_odps_partitioned_table_partitioned(monkeypatch: pytest.MonkeyPatch) -> None: + """Returns (True, [field_names]) for a partitioned ODPS table.""" + database = MagicMock() + database.backend = "odps" + database.sqlalchemy_uri = ( + "odps://mykey:mysecret@myproject/?endpoint=http://service.odps.test" + ) + database.password = "mysecret" # noqa: S105 + + mock_partition = MagicMock() + mock_partition.name = "month" + mock_table = MagicMock() + mock_table.exist_partition = True + mock_table.table_schema.partitions = [mock_partition] + + mock_odps_client = MagicMock() + mock_odps_client.get_table.return_value = mock_table + mock_odps_class = MagicMock(return_value=mock_odps_client) + + with patch("superset.daos.database.ODPS", mock_odps_class): + result = DatabaseDAO.is_odps_partitioned_table(database, "my_table") + + assert result == (True, ["month"]) + + +def test_is_odps_partitioned_table_not_partitioned( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Returns (False, []) for a non-partitioned ODPS table.""" + database = MagicMock() + database.backend = "odps" + database.sqlalchemy_uri = ( + "odps://mykey:mysecret@myproject/?endpoint=http://service.odps.test" + ) + database.password = "mysecret" # noqa: S105 + + mock_table = MagicMock() + mock_table.exist_partition = False + mock_odps_client = MagicMock() + mock_odps_client.get_table.return_value = mock_table + mock_odps_class = MagicMock(return_value=mock_odps_client) + + with patch("superset.daos.database.ODPS", mock_odps_class): + result = DatabaseDAO.is_odps_partitioned_table(database, "my_table") + + assert result == (False, []) diff --git a/tests/unit_tests/db_engine_specs/test_parseable.py b/tests/unit_tests/db_engine_specs/test_parseable.py index 440f6680a215..6300dbfb2da4 100644 --- a/tests/unit_tests/db_engine_specs/test_parseable.py +++ b/tests/unit_tests/db_engine_specs/test_parseable.py @@ -31,7 +31,9 @@ def test_parseable_properties() -> None: def test_parseable_metadata() -> None: metadata = ParseableEngineSpec.metadata - assert "Parseable is a distributed log analytics database" in metadata["description"] + assert ( + "Parseable is a distributed log analytics database" in metadata["description"] + ) assert metadata["logo"] == "parseable.png" assert "sqlalchemy-parseable" in metadata["pypi_packages"] assert metadata["default_port"] == 8000 @@ -39,7 +41,10 @@ def test_parseable_metadata() -> None: def test_epoch_to_dttm() -> None: assert ParseableEngineSpec.epoch_to_dttm().format(col="ts") == "to_timestamp(ts)" - assert ParseableEngineSpec.epoch_ms_to_dttm().format(col="ts") == "to_timestamp(ts / 1000)" + assert ( + ParseableEngineSpec.epoch_ms_to_dttm().format(col="ts") + == "to_timestamp(ts / 1000)" + ) @pytest.mark.parametrize( @@ -72,4 +77,7 @@ def test_convert_dttm( ], ) def test_time_grain_expressions(time_grain: str | None, expected: str) -> None: - assert ParseableEngineSpec._time_grain_expressions[time_grain].format(col="ts") == expected + assert ( + ParseableEngineSpec._time_grain_expressions[time_grain].format(col="ts") + == expected + ) diff --git a/tests/unit_tests/db_engine_specs/test_phoenix.py b/tests/unit_tests/db_engine_specs/test_phoenix.py index d6b0ff473ccd..2a3e6cee94ce 100644 --- a/tests/unit_tests/db_engine_specs/test_phoenix.py +++ b/tests/unit_tests/db_engine_specs/test_phoenix.py @@ -69,4 +69,7 @@ def test_convert_dttm( ], ) def test_time_grain_expressions(time_grain: str | None, expected: str) -> None: - assert PhoenixEngineSpec._time_grain_expressions[time_grain].format(col="ts") == expected + assert ( + PhoenixEngineSpec._time_grain_expressions[time_grain].format(col="ts") + == expected + ) From 612e0c8733f49cdff87276eebb8e0fcd40db1fe0 Mon Sep 17 00:00:00 2001 From: FrancescoCastaldi Date: Thu, 3 Sep 2026 10:37:20 +0200 Subject: [PATCH 37/62] chore: fix ruff formatting, import sorting, and complexity warnings in engine specs --- superset/db_engine_specs/databricks.py | 8 ++------ superset/db_engine_specs/ibmi.py | 1 + superset/db_engine_specs/lint_metadata.py | 19 ++++++------------- 3 files changed, 9 insertions(+), 19 deletions(-) diff --git a/superset/db_engine_specs/databricks.py b/superset/db_engine_specs/databricks.py index 54b925bfd05a..06e3ebb91ec3 100644 --- a/superset/db_engine_specs/databricks.py +++ b/superset/db_engine_specs/databricks.py @@ -319,9 +319,7 @@ class DatabricksODBCEngineSpec(DatabricksBaseEngineSpec): default_driver = "pyodbc" metadata = { - "description": ( - "Databricks SQL Endpoint connection using the ODBC driver." - ), + "description": ("Databricks SQL Endpoint connection using the ODBC driver."), "logo": "databricks.png", "homepage_url": "https://www.databricks.com/", "categories": [ @@ -660,9 +658,7 @@ class DatabricksNativeEngineSpec(DatabricksDynamicBaseEngineSpec): ) metadata = { - "description": ( - "Databricks legacy connector using databricks-dbapi." - ), + "description": ("Databricks legacy connector using databricks-dbapi."), "logo": "databricks.png", "homepage_url": "https://www.databricks.com/", "categories": [ diff --git a/superset/db_engine_specs/ibmi.py b/superset/db_engine_specs/ibmi.py index ccdc389f4722..c45484dff6eb 100644 --- a/superset/db_engine_specs/ibmi.py +++ b/superset/db_engine_specs/ibmi.py @@ -15,6 +15,7 @@ # specific language governing permissions and limitations # under the License. from superset.db_engine_specs.base import DatabaseCategory + from .db2 import Db2EngineSpec diff --git a/superset/db_engine_specs/lint_metadata.py b/superset/db_engine_specs/lint_metadata.py index 51b62a3212f2..69deab57ea27 100644 --- a/superset/db_engine_specs/lint_metadata.py +++ b/superset/db_engine_specs/lint_metadata.py @@ -48,6 +48,7 @@ from __future__ import annotations import argparse +import contextlib import json # noqa: TID251 - standalone script, don't depend on superset.utils import sys from dataclasses import dataclass @@ -360,13 +361,9 @@ def _eval_ast_value(node: Any) -> Any: # noqa: C901 if isinstance(node, ast.Constant): return node.value - elif hasattr(ast, "Str") and isinstance( - node, ast.Str - ): # Python <3.8 compat + elif hasattr(ast, "Str") and isinstance(node, ast.Str): # Python <3.8 compat return node.s - elif hasattr(ast, "Num") and isinstance( - node, ast.Num - ): # Python <3.8 compat + elif hasattr(ast, "Num") and isinstance(node, ast.Num): # Python <3.8 compat return node.n elif isinstance(node, ast.List): return [_eval_ast_value(e) for e in node.elts] @@ -641,18 +638,14 @@ def generate_markdown_report(reports: list[MetadataReport]) -> str: return "\n".join(lines) -def main() -> int: +def main() -> int: # noqa: C901 """Main entry point.""" if hasattr(sys.stdout, "reconfigure"): - try: + with contextlib.suppress(Exception): sys.stdout.reconfigure(encoding="utf-8") - except Exception: - pass if hasattr(sys.stderr, "reconfigure"): - try: + with contextlib.suppress(Exception): sys.stderr.reconfigure(encoding="utf-8") - except Exception: - pass parser = argparse.ArgumentParser( description="Lint DB engine spec metadata for completeness" From 601005b2e34294acde0b4faf2a1aa0b4390769be Mon Sep 17 00:00:00 2001 From: FrancescoCastaldi Date: Sat, 5 Sep 2026 11:58:09 +0200 Subject: [PATCH 38/62] test(db_engine_specs): restore ocient error/gis tests and parseable orm column test --- .../unit_tests/db_engine_specs/test_ocient.py | 395 +++++++++++++++++- .../db_engine_specs/test_parseable.py | 16 + 2 files changed, 410 insertions(+), 1 deletion(-) diff --git a/tests/unit_tests/db_engine_specs/test_ocient.py b/tests/unit_tests/db_engine_specs/test_ocient.py index 3448575443cc..d56145e84de0 100644 --- a/tests/unit_tests/db_engine_specs/test_ocient.py +++ b/tests/unit_tests/db_engine_specs/test_ocient.py @@ -14,10 +14,19 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. +from collections.abc import Callable +from typing import Any + import pytest from superset.constants import TimeGrain -from superset.db_engine_specs.ocient import OcientEngineSpec +from superset.db_engine_specs.ocient import ( + _point_list_to_wkt, + _sanitized_ocient_type_codes, + ocient_is_installed, + OcientEngineSpec, +) +from superset.errors import ErrorLevel, SupersetError, SupersetErrorType def test_ocient_properties() -> None: @@ -66,3 +75,387 @@ def test_time_grain_expressions(time_grain: str | None, expected: str) -> None: OcientEngineSpec._time_grain_expressions[time_grain].format(col="ts") == expected ) + + +MARSHALED_OCIENT_ERRORS: list[tuple[str, SupersetError]] = [ + ( + "The referenced user does not exist (User 'mj' not found)", + SupersetError( + message='The username "mj" does not exist.', + error_type=SupersetErrorType.CONNECTION_INVALID_USERNAME_ERROR, + level=ErrorLevel.ERROR, + extra={ + "engine_name": "Ocient", + "issue_codes": [ + { + "code": 1012, + "message": "Issue 1012 - The username provided when connecting to a database is not valid.", # noqa: E501 + } + ], + }, + ), + ), + ( + "The userid/password combination was not valid (Incorrect password for user)", + SupersetError( + message="The user/password combination is not valid (Incorrect password for user).", # noqa: E501 + error_type=SupersetErrorType.CONNECTION_INVALID_PASSWORD_ERROR, + level=ErrorLevel.ERROR, + extra={ + "engine_name": "Ocient", + "issue_codes": [ + { + "code": 1013, + "message": "Issue 1013 - The password provided when connecting to a database is not valid.", # noqa: E501 + } + ], + }, + ), + ), + ( + "No database named 'bulls' exists", + SupersetError( + message='Could not connect to database: "bulls"', + error_type=SupersetErrorType.CONNECTION_UNKNOWN_DATABASE_ERROR, + level=ErrorLevel.ERROR, + extra={ + "engine_name": "Ocient", + "issue_codes": [ + { + "code": 1015, + "message": "Issue 1015 - Either the database is spelled incorrectly or does not exist.", # noqa: E501 + } + ], + }, + ), + ), + ( + "Unable to connect to unitedcenter.com:4050", + SupersetError( + message='Could not resolve hostname: "unitedcenter.com".', + error_type=SupersetErrorType.CONNECTION_INVALID_HOSTNAME_ERROR, + level=ErrorLevel.ERROR, + extra={ + "engine_name": "Ocient", + "issue_codes": [ + { + "code": 1007, + "message": "Issue 1007 - The hostname provided can't be resolved.", # noqa: E501 + } + ], + }, + ), + ), + ( + "Port out of range 0-65535", + SupersetError( + message="Port out of range 0-65535", + error_type=SupersetErrorType.CONNECTION_INVALID_PORT_ERROR, + level=ErrorLevel.ERROR, + extra={ + "engine_name": "Ocient", + "issue_codes": [ + { + "code": 1034, + "message": "Issue 1034 - The port number is invalid.", + } + ], + }, + ), + ), + ( + "An invalid connection string attribute was specified (failed to decrypt cipher text)", # noqa: E501 + SupersetError( + message="Invalid Connection String: Expecting String of the form 'ocient://user:pass@host:port/database'.", + error_type=SupersetErrorType.GENERIC_DB_ENGINE_ERROR, + level=ErrorLevel.ERROR, + extra={ + "engine_name": "Ocient", + "issue_codes": [ + { + "code": 1002, + "message": "Issue 1002 - The database returned an unexpected error.", # noqa: E501 + } + ], + }, + ), + ), + ( + "There is a syntax error in your statement (extraneous input 'foo bar baz' expecting {, 'trace', 'using'})", # noqa: E501 + SupersetError( + message="Syntax Error: extraneous input \"foo bar baz\" expecting \"{, 'trace', 'using'}", # noqa: E501 + error_type=SupersetErrorType.SYNTAX_ERROR, + level=ErrorLevel.ERROR, + extra={ + "engine_name": "Ocient", + "issue_codes": [ + { + "code": 1030, + "message": "Issue 1030 - The query has a syntax error.", + } + ], + }, + ), + ), + ( + "There is a syntax error in your statement (mismatched input 'to' expecting {, 'trace', 'using'})", # noqa: E501 + SupersetError( + message="Syntax Error: mismatched input \"to\" expecting \"{, 'trace', 'using'}", # noqa: E501 + error_type=SupersetErrorType.SYNTAX_ERROR, + level=ErrorLevel.ERROR, + extra={ + "engine_name": "Ocient", + "issue_codes": [ + { + "code": 1030, + "message": "Issue 1030 - The query has a syntax error.", + } + ], + }, + ), + ), + ( + "The referenced table or view 'goats' does not exist", + SupersetError( + message='Table or View "goats" does not exist.', + error_type=SupersetErrorType.TABLE_DOES_NOT_EXIST_ERROR, + level=ErrorLevel.ERROR, + extra={ + "engine_name": "Ocient", + "issue_codes": [ + { + "code": 1003, + "message": "Issue 1003 - There is a syntax error in the SQL query. Perhaps there was a misspelling or a typo.", # noqa: E501 + }, + { + "code": 1005, + "message": "Issue 1005 - The table was deleted or renamed in the database.", # noqa: E501 + }, + ], + }, + ), + ), + ( + "The reference to column 'goats' is not valid", + SupersetError( + message='Invalid reference to column: "goats"', + error_type=SupersetErrorType.COLUMN_DOES_NOT_EXIST_ERROR, + level=ErrorLevel.ERROR, + extra={ + "engine_name": "Ocient", + "issue_codes": [ + { + "code": 1003, + "message": "Issue 1003 - There is a syntax error in the SQL query. Perhaps there was a misspelling or a typo.", # noqa: E501 + }, + { + "code": 1004, + "message": "Issue 1004 - The column was deleted or renamed in the database.", # noqa: E501 + }, + ], + }, + ), + ), +] + + +@pytest.mark.parametrize("msg,expected", MARSHALED_OCIENT_ERRORS) +def test_connection_errors(msg: str, expected: SupersetError) -> None: + from superset.db_engine_specs.ocient import OcientEngineSpec + + result = OcientEngineSpec.extract_errors(Exception(msg)) + assert result == [expected] + + +def _generate_gis_type_sanitization_test_cases() -> list[ + tuple[str, int, Any, dict[str, Any]] +]: + if not ocient_is_installed(): + return [] + + from pyocient import TypeCodes + from pyocient.api import ( + STLinestring as _STLinestring, + STPoint as _STPoint, + STPolygon as _STPolygon, + ) + + return [ + ( + "empty_point", + TypeCodes.ST_POINT, + _STPoint(long=float("inf"), lat=float("inf")), + { + "geometry": None, + "properties": {}, + "type": "Feature", + }, + ), + ( + "valid_point", + TypeCodes.ST_POINT, + _STPoint(long=float(33), lat=float(45)), + { + "geometry": { + "coordinates": [33.0, 45.0], + "type": "Point", + }, + "properties": {}, + "type": "Feature", + }, + ), + ( + "empty_line", + TypeCodes.ST_LINESTRING, + _STLinestring([]), + { + "geometry": None, + "properties": {}, + "type": "Feature", + }, + ), + ( + "valid_line", + TypeCodes.ST_LINESTRING, + _STLinestring( + [_STPoint(long=t[0], lat=t[1]) for t in [(1, 0), (1, 1), (1, 2)]] + ), + { + "geometry": { + "coordinates": [[1, 0], [1, 1], [1, 2]], + "type": "LineString", + }, + "properties": {}, + "type": "Feature", + }, + ), + ( + "downcast_line_to_point", + TypeCodes.ST_LINESTRING, + _STLinestring([_STPoint(long=t[0], lat=t[1]) for t in [(1, 0)]]), + { + "geometry": { + "coordinates": [1, 0], + "type": "Point", + }, + "properties": {}, + "type": "Feature", + }, + ), + ( + "empty_polygon", + TypeCodes.ST_POLYGON, + _STPolygon(exterior=[], holes=[], fullFlag=False), + { + "geometry": None, + "properties": {}, + "type": "Feature", + }, + ), + ( + "valid_polygon_no_holes", + TypeCodes.ST_POLYGON, + _STPolygon( + exterior=[ + _STPoint(long=t[0], lat=t[1]) for t in [(1, 0), (1, 1), (1, 0)] + ], + holes=[], + fullFlag=False, + ), + { + "geometry": { + "coordinates": [[[1, 0], [1, 1], [1, 0]]], + "type": "Polygon", + }, + "properties": {}, + "type": "Feature", + }, + ), + ( + "valid_polygon_with_holes", + TypeCodes.ST_POLYGON, + _STPolygon( + exterior=[ + _STPoint(long=t[0], lat=t[1]) for t in [(1, 0), (1, 1), (1, 0)] + ], + holes=[ + [_STPoint(long=t[0], lat=t[1]) for t in [(2, 0), (2, 1), (2, 0)]], + [_STPoint(long=t[0], lat=t[1]) for t in [(3, 0), (3, 1), (3, 0)]], + ], + fullFlag=False, + ), + { + "geometry": { + "coordinates": [ + [[1, 0], [1, 1], [1, 0]], + [[2, 0], [2, 1], [2, 0]], + [[3, 0], [3, 1], [3, 0]], + ], + "type": "Polygon", + }, + "properties": {}, + "type": "Feature", + }, + ), + ( + "downcast_poly_to_point", + TypeCodes.ST_POLYGON, + _STPolygon( + exterior=[_STPoint(long=t[0], lat=t[1]) for t in [(1, 0)]], + holes=[], + fullFlag=False, + ), + { + "geometry": { + "coordinates": [1, 0], + "type": "Point", + }, + "properties": {}, + "type": "Feature", + }, + ), + ( + "downcast_poly_to_line", + TypeCodes.ST_POLYGON, + _STPolygon( + exterior=[_STPoint(long=t[0], lat=t[1]) for t in [(1, 0), (0, 1)]], + holes=[], + fullFlag=False, + ), + { + "geometry": { + "coordinates": [[1, 0], [0, 1]], + "type": "LineString", + }, + "properties": {}, + "type": "Feature", + }, + ), + ] + + +@pytest.mark.skipif(not ocient_is_installed(), reason="requires ocient dependencies") +@pytest.mark.parametrize( + "name,type_code,geo,expected", _generate_gis_type_sanitization_test_cases() +) +def test_gis_type_sanitization( + name: str, type_code: int, geo: Any, expected: Any +) -> None: + # Hack to silence erroneous mypy errors + def die(any: Any) -> Callable[[Any], Any]: + pytest.fail(f"no sanitizer for type code {type_code}") + raise AssertionError() + + type_sanitizer = _sanitized_ocient_type_codes.get(type_code, die) + actual = type_sanitizer(geo) + assert expected == actual + + +@pytest.mark.skipif(not ocient_is_installed(), reason="requires ocient dependencies") +def test_point_list_to_wkt() -> None: + from pyocient.api import STPoint as _STPoint + + wkt = _point_list_to_wkt( + [_STPoint(long=t[0], lat=t[1]) for t in [(2, 0), (2, 1), (2, 0)]] + ) + assert wkt == "LINESTRING(2 0, 2 1, 2 0)" diff --git a/tests/unit_tests/db_engine_specs/test_parseable.py b/tests/unit_tests/db_engine_specs/test_parseable.py index 6300dbfb2da4..9d1f2764ec62 100644 --- a/tests/unit_tests/db_engine_specs/test_parseable.py +++ b/tests/unit_tests/db_engine_specs/test_parseable.py @@ -81,3 +81,19 @@ def test_time_grain_expressions(time_grain: str | None, expected: str) -> None: ParseableEngineSpec._time_grain_expressions[time_grain].format(col="ts") == expected ) + + +def test_alter_new_orm_column() -> None: + """ + DB Eng Specs (parseable): Test alter orm column + """ + from superset.connectors.sqla.models import SqlaTable, TableColumn + from superset.models.core import Database + + database = Database(database_name="parseable", sqlalchemy_uri="parseable://db") + tbl = SqlaTable(table_name="tbl", database=database) + col = TableColumn(column_name="p_timestamp", type="TIMESTAMP", table=tbl) + ParseableEngineSpec.alter_new_orm_column(col) + assert col.python_date_format == "epoch_ms" + assert col.is_dttm is True + From 943419dc0d1502fb46ba66bfb0da3f0f189fe845 Mon Sep 17 00:00:00 2001 From: FrancescoCastaldi Date: Sat, 5 Sep 2026 12:06:24 +0200 Subject: [PATCH 39/62] fix(ocient): define ocient_is_installed locally in test instead of importing from engine spec --- tests/unit_tests/db_engine_specs/test_ocient.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/unit_tests/db_engine_specs/test_ocient.py b/tests/unit_tests/db_engine_specs/test_ocient.py index d56145e84de0..ea64f92e8996 100644 --- a/tests/unit_tests/db_engine_specs/test_ocient.py +++ b/tests/unit_tests/db_engine_specs/test_ocient.py @@ -23,12 +23,15 @@ from superset.db_engine_specs.ocient import ( _point_list_to_wkt, _sanitized_ocient_type_codes, - ocient_is_installed, OcientEngineSpec, ) from superset.errors import ErrorLevel, SupersetError, SupersetErrorType +def ocient_is_installed() -> bool: + return len(_sanitized_ocient_type_codes) > 0 + + def test_ocient_properties() -> None: assert OcientEngineSpec.engine == "ocient" assert OcientEngineSpec.engine_name == "Ocient" From 912dbd92c7722fd86bf0ea9aa05f1aecf62d1f0d Mon Sep 17 00:00:00 2001 From: FrancescoCastaldi Date: Sat, 5 Sep 2026 12:13:26 +0200 Subject: [PATCH 40/62] style(parseable): remove extraneous trailing newline in test_parseable.py to fix end-of-file-fixer --- tests/unit_tests/db_engine_specs/test_parseable.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/unit_tests/db_engine_specs/test_parseable.py b/tests/unit_tests/db_engine_specs/test_parseable.py index 9d1f2764ec62..7ab7020eefa5 100644 --- a/tests/unit_tests/db_engine_specs/test_parseable.py +++ b/tests/unit_tests/db_engine_specs/test_parseable.py @@ -96,4 +96,3 @@ def test_alter_new_orm_column() -> None: ParseableEngineSpec.alter_new_orm_column(col) assert col.python_date_format == "epoch_ms" assert col.is_dttm is True - From 43ab309cba219c07cf99e295262fa5cea1a8413f Mon Sep 17 00:00:00 2001 From: FrancescoCastaldi Date: Fri, 11 Sep 2026 23:07:55 +0200 Subject: [PATCH 41/62] fix(db_engine_specs): address review comments for IBM i, Arc, Databricks, Kusto, Aurora, and ODPS --- superset/db_engine_specs/arc.py | 2 +- superset/db_engine_specs/aurora.py | 1 - superset/db_engine_specs/databricks.py | 9 ++++++--- superset/db_engine_specs/ibmi.py | 4 +--- superset/db_engine_specs/kusto.py | 11 +++++++++++ tests/unit_tests/db_engine_specs/test_arc.py | 2 +- tests/unit_tests/db_engine_specs/test_ibmi.py | 6 +++++- tests/unit_tests/db_engine_specs/test_kusto.py | 10 ++++++++++ tests/unit_tests/db_engine_specs/test_odps.py | 6 ++---- 9 files changed, 37 insertions(+), 14 deletions(-) diff --git a/superset/db_engine_specs/arc.py b/superset/db_engine_specs/arc.py index 9bbcb4936ff7..448fa1197b04 100644 --- a/superset/db_engine_specs/arc.py +++ b/superset/db_engine_specs/arc.py @@ -47,7 +47,7 @@ class ArcEngineSpec(BaseEngineSpec): "categories": [DatabaseCategory.OTHER, DatabaseCategory.PROPRIETARY], "pypi_packages": ["arc-superset-arrow"], "connection_string": ("arc+arrow://{api_key}@{hostname}:{port}/{database}"), - "default_port": 443, + "default_port": 8000, "docs_url": "https://docs.arcdata.io", "parameters": { "api_key": "Arc API key", diff --git a/superset/db_engine_specs/aurora.py b/superset/db_engine_specs/aurora.py index 77a8e5f5d571..bececc8d48b9 100644 --- a/superset/db_engine_specs/aurora.py +++ b/superset/db_engine_specs/aurora.py @@ -95,7 +95,6 @@ class AuroraPostgresDataAPI(PostgresEngineSpec): "{database_name}?aurora_cluster_arn={aurora_cluster_arn}&" "secret_arn={secret_arn}®ion_name={region_name}" ), - "default_port": 5432, "parameters": { "aws_access_id": "AWS Access Key ID", "aws_secret_access_key": "AWS Secret Access Key", diff --git a/superset/db_engine_specs/databricks.py b/superset/db_engine_specs/databricks.py index 06e3ebb91ec3..ad0480912713 100644 --- a/superset/db_engine_specs/databricks.py +++ b/superset/db_engine_specs/databricks.py @@ -658,7 +658,10 @@ class DatabricksNativeEngineSpec(DatabricksDynamicBaseEngineSpec): ) metadata = { - "description": ("Databricks legacy connector using databricks-dbapi."), + "description": ( + "Databricks connector using databricks-sql-connector and " + "databricks-sqlalchemy." + ), "logo": "databricks.png", "homepage_url": "https://www.databricks.com/", "categories": [ @@ -666,9 +669,9 @@ class DatabricksNativeEngineSpec(DatabricksDynamicBaseEngineSpec): DatabaseCategory.ANALYTICAL_DATABASES, DatabaseCategory.HOSTED_OPEN_SOURCE, ], - "pypi_packages": ["databricks-dbapi[sqlalchemy]"], + "pypi_packages": ["databricks-sql-connector", "databricks-sqlalchemy"], "connection_string": ( - "databricks+connector://token:{access_token}@{host}:{port}/{database}" + "databricks://token:{access_token}@{host}:{port}/{database}" ), "default_port": 443, "docs_url": "https://docs.databricks.com/", diff --git a/superset/db_engine_specs/ibmi.py b/superset/db_engine_specs/ibmi.py index c45484dff6eb..8ccd30e1c39a 100644 --- a/superset/db_engine_specs/ibmi.py +++ b/superset/db_engine_specs/ibmi.py @@ -38,13 +38,11 @@ class IBMiEngineSpec(Db2EngineSpec): DatabaseCategory.PROPRIETARY, ], "pypi_packages": ["sqlalchemy-ibmi"], - "connection_string": "ibmi://{username}:{password}@{host}:{port}/{database}", - "default_port": 50000, + "connection_string": "ibmi://{username}:{password}@{host}/{database}", "parameters": { "username": "IBM i username", "password": "IBM i password", "host": "IBM i system host", - "port": "Default 50000", "database": "Library/schema name", }, "docs_url": "https://github.com/IBM/sqlalchemy-ibmi", diff --git a/superset/db_engine_specs/kusto.py b/superset/db_engine_specs/kusto.py index e49ba0ba49a3..0a7d1bfa11da 100644 --- a/superset/db_engine_specs/kusto.py +++ b/superset/db_engine_specs/kusto.py @@ -243,6 +243,17 @@ class KustoKqlEngineSpec(BaseEngineSpec): # pylint: disable=abstract-method }, "docs_url": "https://learn.microsoft.com/en-us/azure/data-explorer/", "sqlalchemy_docs_url": "https://github.com/dodopizza/sqlalchemy-kusto", + "known_incompatibilities": [ + { + "dependency": "SQLAlchemy 2.0", + "reason": ( + "setup.py on the sqlalchemy-kusto main branch hard-pins " + "sqlalchemy==1.4.*; no SQLAlchemy 2.0 work has started." + ), + "tracking_url": "https://github.com/dodopizza/sqlalchemy-kusto", + "since": "2026-07-28", + } + ], } allows_joins = True allows_subqueries = True diff --git a/tests/unit_tests/db_engine_specs/test_arc.py b/tests/unit_tests/db_engine_specs/test_arc.py index 2ef32452d23e..6e0ae94442ec 100644 --- a/tests/unit_tests/db_engine_specs/test_arc.py +++ b/tests/unit_tests/db_engine_specs/test_arc.py @@ -34,7 +34,7 @@ def test_arc_metadata() -> None: ) assert metadata["logo"] == "arc.png" assert "arc-superset-arrow" in metadata["pypi_packages"] - assert metadata["default_port"] == 443 + assert metadata["default_port"] == 8000 @pytest.mark.parametrize( diff --git a/tests/unit_tests/db_engine_specs/test_ibmi.py b/tests/unit_tests/db_engine_specs/test_ibmi.py index e2ebc53b1220..5b8718f7a1e6 100644 --- a/tests/unit_tests/db_engine_specs/test_ibmi.py +++ b/tests/unit_tests/db_engine_specs/test_ibmi.py @@ -30,7 +30,11 @@ def test_ibmi_metadata() -> None: assert "IBM Db2 for i" in metadata["description"] assert metadata["logo"] == "ibm-db2.svg" assert "sqlalchemy-ibmi" in metadata["pypi_packages"] - assert metadata["default_port"] == 50000 + assert ( + metadata["connection_string"] + == "ibmi://{username}:{password}@{host}/{database}" + ) + assert "default_port" not in metadata def test_ibmi_epoch_to_dttm() -> None: diff --git a/tests/unit_tests/db_engine_specs/test_kusto.py b/tests/unit_tests/db_engine_specs/test_kusto.py index 568e90153a8e..350bce413a40 100644 --- a/tests/unit_tests/db_engine_specs/test_kusto.py +++ b/tests/unit_tests/db_engine_specs/test_kusto.py @@ -224,3 +224,13 @@ def test_kql_execute_array_processing(raw_query: str, expected_query: str) -> No KustoKqlEngineSpec.execute(mock_cursor, raw_query, mock_db) mock_cursor.execute.assert_called_once_with(expected_query) + + +def test_kql_metadata() -> None: + from superset.db_engine_specs.kusto import KustoSqlEngineSpec + + assert "known_incompatibilities" in KustoKqlEngineSpec.metadata + assert ( + KustoKqlEngineSpec.metadata["known_incompatibilities"] + == KustoSqlEngineSpec.metadata["known_incompatibilities"] + ) diff --git a/tests/unit_tests/db_engine_specs/test_odps.py b/tests/unit_tests/db_engine_specs/test_odps.py index 19e38e36703b..04f393e44571 100644 --- a/tests/unit_tests/db_engine_specs/test_odps.py +++ b/tests/unit_tests/db_engine_specs/test_odps.py @@ -141,7 +141,7 @@ def test_is_odps_partitioned_table_uri_no_match( assert "did not match" in caplog.text -def test_is_odps_partitioned_table_partitioned(monkeypatch: pytest.MonkeyPatch) -> None: +def test_is_odps_partitioned_table_partitioned() -> None: """Returns (True, [field_names]) for a partitioned ODPS table.""" database = MagicMock() database.backend = "odps" @@ -166,9 +166,7 @@ def test_is_odps_partitioned_table_partitioned(monkeypatch: pytest.MonkeyPatch) assert result == (True, ["month"]) -def test_is_odps_partitioned_table_not_partitioned( - monkeypatch: pytest.MonkeyPatch, -) -> None: +def test_is_odps_partitioned_table_not_partitioned() -> None: """Returns (False, []) for a non-partitioned ODPS table.""" database = MagicMock() database.backend = "odps" From a720d2f4447af8c44c9d838e6dcea58d0532c9d5 Mon Sep 17 00:00:00 2001 From: Francesco Castaldi Date: Thu, 1 Oct 2026 21:24:46 +0200 Subject: [PATCH 42/62] test(db_engine_specs): add AST discovery regression test for annotated engine specs --- .../db_engine_specs/test_lint_metadata.py | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 tests/unit_tests/db_engine_specs/test_lint_metadata.py diff --git a/tests/unit_tests/db_engine_specs/test_lint_metadata.py b/tests/unit_tests/db_engine_specs/test_lint_metadata.py new file mode 100644 index 000000000000..9ee3fb96cf93 --- /dev/null +++ b/tests/unit_tests/db_engine_specs/test_lint_metadata.py @@ -0,0 +1,65 @@ +# 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 tempfile +from pathlib import Path +from textwrap import dedent + +from superset.db_engine_specs import lint_metadata +from superset.db_engine_specs.lint_metadata import get_all_engine_specs_ast + + +def test_get_all_engine_specs_ast_annotated_attributes() -> None: + """ + Ensure engine specs with type-annotated engine, engine_name, and metadata + are correctly discovered and parsed by get_all_engine_specs_ast. + """ + specs_dir = Path(lint_metadata.__file__).parent + code = dedent( + """ + from typing import Any + from superset.db_engine_specs.base import BaseEngineSpec + + class TypedBaseEngineSpec(BaseEngineSpec): + engine: str = "typed_engine" + engine_name: str = "Typed Engine" + metadata: dict[str, Any] = { + "description": "A typed engine spec", + } + """ + ) + + with tempfile.NamedTemporaryFile( + mode="w", + dir=specs_dir, + prefix="_tmp_typed_", + suffix=".py", + encoding="utf-8", + delete=False, + ) as tmp_file: + tmp_path = Path(tmp_file.name) + tmp_file.write(code) + + try: + specs = get_all_engine_specs_ast() + matching = [s for s in specs if s["class_name"] == "TypedBaseEngineSpec"] + assert len(matching) == 1 + spec = matching[0] + assert spec["engine_name"] == "Typed Engine" + assert spec["metadata"] == {"description": "A typed engine spec"} + finally: + tmp_path.unlink(missing_ok=True) From 8a301f9032918f0a6bf92649e984b0986799ae43 Mon Sep 17 00:00:00 2001 From: Francesco Castaldi Date: Sat, 3 Oct 2026 11:35:42 +0200 Subject: [PATCH 43/62] fix(db_engine_specs): handle bare type annotations in AST scanner and use parseable+http scheme --- superset/db_engine_specs/lint_metadata.py | 12 +- superset/db_engine_specs/parseable.py | 4 +- .../db_engine_specs/test_lint_metadata.py | 105 +++++++++++++----- 3 files changed, 88 insertions(+), 33 deletions(-) diff --git a/superset/db_engine_specs/lint_metadata.py b/superset/db_engine_specs/lint_metadata.py index 69deab57ea27..2f5804b98c79 100644 --- a/superset/db_engine_specs/lint_metadata.py +++ b/superset/db_engine_specs/lint_metadata.py @@ -50,10 +50,14 @@ import argparse import contextlib import json # noqa: TID251 - standalone script, don't depend on superset.utils +import os import sys from dataclasses import dataclass from typing import Any +# Default directory for engine spec modules (monkeypatchable in tests) +SPECS_DIR: str = os.path.dirname(__file__) + # Schema definition - fields grouped by importance REQUIRED_FIELDS = { "description": "Brief description of the database", @@ -231,10 +235,9 @@ def get_all_engine_specs_ast() -> list[dict[str, Any]]: # noqa: C901 Returns a list of dicts with engine_name, module, and metadata. """ import ast - import os specs = [] - db_engine_specs_dir = os.path.dirname(__file__) + db_engine_specs_dir = SPECS_DIR for filename in os.listdir(db_engine_specs_dir): if not filename.endswith(".py"): @@ -284,6 +287,11 @@ def get_all_engine_specs_ast() -> list[dict[str, Any]]: # noqa: C901 isinstance(item.target, ast.Name) and item.target.id == "engine" ): + if item.value is None: + # Bare annotation without initializer + # (e.g. `engine: str`); keep scanning for + # the actual assignment below. + continue if isinstance(item.value, ast.Constant): has_non_empty_engine = bool(item.value.value) break diff --git a/superset/db_engine_specs/parseable.py b/superset/db_engine_specs/parseable.py index c58f7db4c202..839050e9bf28 100644 --- a/superset/db_engine_specs/parseable.py +++ b/superset/db_engine_specs/parseable.py @@ -46,14 +46,14 @@ class ParseableEngineSpec(BaseEngineSpec): "categories": [DatabaseCategory.SEARCH_NOSQL, DatabaseCategory.OPEN_SOURCE], "pypi_packages": ["sqlalchemy-parseable"], "connection_string": ( - "parseable://{username}:{password}@{hostname}:{port}/{stream_name}" + "parseable+http://{username}:{password}@{hostname}:{port}/{stream_name}" ), "default_port": 8000, "connection_examples": [ { "description": "Example connection", "connection_string": ( - "parseable://admin:admin@demo.parseable.com:443/ingress-nginx" + "parseable+http://admin:admin@demo.parseable.com:443/ingress-nginx" ), }, ], diff --git a/tests/unit_tests/db_engine_specs/test_lint_metadata.py b/tests/unit_tests/db_engine_specs/test_lint_metadata.py index 9ee3fb96cf93..3ce4f398e2ab 100644 --- a/tests/unit_tests/db_engine_specs/test_lint_metadata.py +++ b/tests/unit_tests/db_engine_specs/test_lint_metadata.py @@ -15,51 +15,98 @@ # specific language governing permissions and limitations # under the License. -import tempfile from pathlib import Path from textwrap import dedent -from superset.db_engine_specs import lint_metadata +import pytest + from superset.db_engine_specs.lint_metadata import get_all_engine_specs_ast -def test_get_all_engine_specs_ast_annotated_attributes() -> None: +def _write_spec(directory: Path, filename: str, code: str) -> Path: + """Write a Python source file into *directory* and return the path.""" + spec_file = directory / filename + spec_file.write_text(dedent(code), encoding="utf-8") + return spec_file + + +def test_get_all_engine_specs_ast_annotated_attributes( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: """ - Ensure engine specs with type-annotated engine, engine_name, and metadata - are correctly discovered and parsed by get_all_engine_specs_ast. + Engine specs with type-annotated engine, engine_name, and metadata + (inline initializers) are correctly discovered. """ - specs_dir = Path(lint_metadata.__file__).parent - code = dedent( - """ + _write_spec( + tmp_path, + "_tmp_typed_inline.py", + """\ from typing import Any from superset.db_engine_specs.base import BaseEngineSpec - class TypedBaseEngineSpec(BaseEngineSpec): + class TypedInlineEngineSpec(BaseEngineSpec): engine: str = "typed_engine" engine_name: str = "Typed Engine" metadata: dict[str, Any] = { "description": "A typed engine spec", } - """ + """, + ) + + monkeypatch.setattr( + "superset.db_engine_specs.lint_metadata.SPECS_DIR", + str(tmp_path), ) - with tempfile.NamedTemporaryFile( - mode="w", - dir=specs_dir, - prefix="_tmp_typed_", - suffix=".py", - encoding="utf-8", - delete=False, - ) as tmp_file: - tmp_path = Path(tmp_file.name) - tmp_file.write(code) + specs = get_all_engine_specs_ast() + matching = [s for s in specs if s["class_name"] == "TypedInlineEngineSpec"] + assert len(matching) == 1 + spec = matching[0] + assert spec["engine_name"] == "Typed Engine" + assert spec["metadata"] == {"description": "A typed engine spec"} - try: - specs = get_all_engine_specs_ast() - matching = [s for s in specs if s["class_name"] == "TypedBaseEngineSpec"] - assert len(matching) == 1 - spec = matching[0] - assert spec["engine_name"] == "Typed Engine" - assert spec["metadata"] == {"description": "A typed engine spec"} - finally: - tmp_path.unlink(missing_ok=True) + +def test_get_all_engine_specs_ast_declaration_then_assignment( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """ + Regression: a bare annotation `engine: str` followed by a plain + assignment `engine = "value"` must still be discovered (the scanner + must not break on the bare annotation). + """ + _write_spec( + tmp_path, + "_tmp_typed_decl.py", + """\ + from typing import Any + from superset.db_engine_specs.base import BaseEngineSpec + + class DeclThenAssignEngineSpec(BaseEngineSpec): + engine: str + engine = "decl_then_assign" + engine_name: str + engine_name = "DeclThenAssign" + metadata: dict[str, Any] + metadata = { + "description": "Engine with declaration-then-assignment", + } + """, + ) + + monkeypatch.setattr( + "superset.db_engine_specs.lint_metadata.SPECS_DIR", + str(tmp_path), + ) + + specs = get_all_engine_specs_ast() + matching = [s for s in specs if s["class_name"] == "DeclThenAssignEngineSpec"] + assert len(matching) == 1, ( + f"DeclThenAssignEngineSpec not found; got: {[s['class_name'] for s in specs]}" + ) + spec = matching[0] + assert spec["engine_name"] == "DeclThenAssign" + assert spec["metadata"] == { + "description": "Engine with declaration-then-assignment" + } From 6288794ccc96f7c028242b76850b407e9515e264 Mon Sep 17 00:00:00 2001 From: Francesco Castaldi Date: Tue, 6 Oct 2026 00:31:42 +0200 Subject: [PATCH 44/62] fix(db_engine_specs): retain https in parseable demo example and use BaseEngineSpec suffix in AST tests --- superset/db_engine_specs/parseable.py | 2 +- .../db_engine_specs/test_lint_metadata.py | 19 +++++++++++-------- .../db_engine_specs/test_parseable.py | 8 ++++++++ 3 files changed, 20 insertions(+), 9 deletions(-) diff --git a/superset/db_engine_specs/parseable.py b/superset/db_engine_specs/parseable.py index 839050e9bf28..ac6761c805b3 100644 --- a/superset/db_engine_specs/parseable.py +++ b/superset/db_engine_specs/parseable.py @@ -53,7 +53,7 @@ class ParseableEngineSpec(BaseEngineSpec): { "description": "Example connection", "connection_string": ( - "parseable+http://admin:admin@demo.parseable.com:443/ingress-nginx" + "parseable+https://admin:admin@demo.parseable.com:443/ingress-nginx" ), }, ], diff --git a/tests/unit_tests/db_engine_specs/test_lint_metadata.py b/tests/unit_tests/db_engine_specs/test_lint_metadata.py index 3ce4f398e2ab..d5bca4ea499d 100644 --- a/tests/unit_tests/db_engine_specs/test_lint_metadata.py +++ b/tests/unit_tests/db_engine_specs/test_lint_metadata.py @@ -36,7 +36,8 @@ def test_get_all_engine_specs_ast_annotated_attributes( ) -> None: """ Engine specs with type-annotated engine, engine_name, and metadata - (inline initializers) are correctly discovered. + (inline initializers) are correctly discovered, including when named + with the *BaseEngineSpec suffix. """ _write_spec( tmp_path, @@ -45,7 +46,7 @@ def test_get_all_engine_specs_ast_annotated_attributes( from typing import Any from superset.db_engine_specs.base import BaseEngineSpec - class TypedInlineEngineSpec(BaseEngineSpec): + class TypedInlineBaseEngineSpec(BaseEngineSpec): engine: str = "typed_engine" engine_name: str = "Typed Engine" metadata: dict[str, Any] = { @@ -60,7 +61,7 @@ class TypedInlineEngineSpec(BaseEngineSpec): ) specs = get_all_engine_specs_ast() - matching = [s for s in specs if s["class_name"] == "TypedInlineEngineSpec"] + matching = [s for s in specs if s["class_name"] == "TypedInlineBaseEngineSpec"] assert len(matching) == 1 spec = matching[0] assert spec["engine_name"] == "Typed Engine" @@ -73,8 +74,9 @@ def test_get_all_engine_specs_ast_declaration_then_assignment( ) -> None: """ Regression: a bare annotation `engine: str` followed by a plain - assignment `engine = "value"` must still be discovered (the scanner - must not break on the bare annotation). + assignment `engine = "value"` must still be discovered, including + when named with the *BaseEngineSpec suffix (the scanner must not break + on the bare annotation or skip the spec as a true base class). """ _write_spec( tmp_path, @@ -83,7 +85,7 @@ def test_get_all_engine_specs_ast_declaration_then_assignment( from typing import Any from superset.db_engine_specs.base import BaseEngineSpec - class DeclThenAssignEngineSpec(BaseEngineSpec): + class DeclThenAssignBaseEngineSpec(BaseEngineSpec): engine: str engine = "decl_then_assign" engine_name: str @@ -101,9 +103,10 @@ class DeclThenAssignEngineSpec(BaseEngineSpec): ) specs = get_all_engine_specs_ast() - matching = [s for s in specs if s["class_name"] == "DeclThenAssignEngineSpec"] + matching = [s for s in specs if s["class_name"] == "DeclThenAssignBaseEngineSpec"] + found_names = [s["class_name"] for s in specs] assert len(matching) == 1, ( - f"DeclThenAssignEngineSpec not found; got: {[s['class_name'] for s in specs]}" + f"DeclThenAssignBaseEngineSpec not found; got: {found_names}" ) spec = matching[0] assert spec["engine_name"] == "DeclThenAssign" diff --git a/tests/unit_tests/db_engine_specs/test_parseable.py b/tests/unit_tests/db_engine_specs/test_parseable.py index 7ab7020eefa5..981d1727ec08 100644 --- a/tests/unit_tests/db_engine_specs/test_parseable.py +++ b/tests/unit_tests/db_engine_specs/test_parseable.py @@ -37,6 +37,14 @@ def test_parseable_metadata() -> None: assert metadata["logo"] == "parseable.png" assert "sqlalchemy-parseable" in metadata["pypi_packages"] assert metadata["default_port"] == 8000 + assert ( + metadata["connection_string"] + == "parseable+http://{username}:{password}@{hostname}:{port}/{stream_name}" + ) + assert ( + metadata["connection_examples"][0]["connection_string"] + == "parseable+https://admin:admin@demo.parseable.com:443/ingress-nginx" + ) def test_epoch_to_dttm() -> None: From 7999bc6fb8e4b7a39e164a203f88091e4022a87f Mon Sep 17 00:00:00 2001 From: Francesco Castaldi Date: Tue, 6 Oct 2026 14:55:34 +0200 Subject: [PATCH 45/62] fix(test_exasol): remove stray merge conflict marker --- tests/unit_tests/db_engine_specs/test_exasol.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/unit_tests/db_engine_specs/test_exasol.py b/tests/unit_tests/db_engine_specs/test_exasol.py index 0ddc4ae1373f..e81766c3141e 100644 --- a/tests/unit_tests/db_engine_specs/test_exasol.py +++ b/tests/unit_tests/db_engine_specs/test_exasol.py @@ -225,4 +225,3 @@ def test_extract_verbose_empty_diagnostic(diagnostic: str, wrapped: bool) -> Non assert errors[0].message == prefix + envelope.strip() assert "code => 42000" in errors[0].message assert sql not in errors[0].message ->>>>>>> upstream/master From 1ad6d80546ce41e610562b1ddce89ce3b2e525e9 Mon Sep 17 00:00:00 2001 From: "Francesco.Castaldi" Date: Mon, 12 Oct 2026 00:38:34 +0200 Subject: [PATCH 46/62] docs(db_engine_specs): document IBM i database segment as RDB name --- superset/db_engine_specs/ibmi.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/superset/db_engine_specs/ibmi.py b/superset/db_engine_specs/ibmi.py index 0599abbbe909..ccd636dd0954 100644 --- a/superset/db_engine_specs/ibmi.py +++ b/superset/db_engine_specs/ibmi.py @@ -44,7 +44,7 @@ class IBMiEngineSpec(Db2EngineSpec): "username": "IBM i user profile", "password": "User password", "host": "Hostname or IP address", - "database": "Library/schema name", + "database": "RDB name", }, "docs_url": "https://github.com/IBM/sqlalchemy-ibmi", "sqlalchemy_docs_url": "https://github.com/IBM/sqlalchemy-ibmi", From 17b804d2e6b4186a09f748ab826db57f65a5dc02 Mon Sep 17 00:00:00 2001 From: "Francesco.Castaldi" Date: Mon, 12 Oct 2026 00:39:04 +0200 Subject: [PATCH 47/62] docs(db_engine_specs): specify ClickHouse Connect docs url to prevent legacy fallback --- superset/db_engine_specs/clickhouse.py | 1 + 1 file changed, 1 insertion(+) diff --git a/superset/db_engine_specs/clickhouse.py b/superset/db_engine_specs/clickhouse.py index 9b33fc952f56..ca875f68e752 100644 --- a/superset/db_engine_specs/clickhouse.py +++ b/superset/db_engine_specs/clickhouse.py @@ -424,6 +424,7 @@ class ClickHouseConnectEngineSpec(BasicParametersMixin, ClickHouseEngineSpec): ), "logo": "clickhouse.png", "homepage_url": "https://clickhouse.com/", + "sqlalchemy_docs_url": "https://clickhouse.com/docs/en/integrations/sql-clients/superset", "categories": [ DatabaseCategory.ANALYTICAL_DATABASES, DatabaseCategory.OPEN_SOURCE, From 3b708447d1957fbcdda6f579b03f755a1bfe6c73 Mon Sep 17 00:00:00 2001 From: "Francesco.Castaldi" Date: Mon, 12 Oct 2026 00:39:43 +0200 Subject: [PATCH 48/62] fix(db_engine_specs): specify databricks-dbapi[sqlalchemy] in Databricks ODBC and PyHive metadata --- superset/db_engine_specs/databricks.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/superset/db_engine_specs/databricks.py b/superset/db_engine_specs/databricks.py index ebf4a3badb0a..5fee957a914a 100644 --- a/superset/db_engine_specs/databricks.py +++ b/superset/db_engine_specs/databricks.py @@ -1120,7 +1120,7 @@ class DatabricksODBCEngineSpec(DatabricksBaseEngineSpec): DatabaseCategory.ANALYTICAL_DATABASES, DatabaseCategory.PROPRIETARY, ], - "pypi_packages": ["pyodbc"], + "pypi_packages": ["databricks-dbapi[sqlalchemy]"], "connection_string": ( "databricks+pyodbc://token:{access_token}@{host}:{port}/{database}?http_path={http_path}" ), @@ -1164,7 +1164,7 @@ class DatabricksHiveEngineSpec(HiveEngineSpec): DatabaseCategory.ANALYTICAL_DATABASES, DatabaseCategory.HOSTED_OPEN_SOURCE, ], - "pypi_packages": ["pyhive"], + "pypi_packages": ["databricks-dbapi[sqlalchemy]"], "connection_string": ( "databricks+pyhive://token:{access_token}@{host}:{port}/{database}?http_path={http_path}" ), From 5902edd9464eb59fc83f00e3287e95024d1f4909 Mon Sep 17 00:00:00 2001 From: "Francesco.Castaldi" Date: Sun, 11 Oct 2026 22:39:43 +0000 Subject: [PATCH 49/62] fix(db_engine_specs): alias databricks.connector dialect to DatabricksDialect and update package requirements --- superset/db_engine_specs/databricks.py | 12 ++++++++++-- .../db_engine_specs/test_databricks.py | 16 ++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/superset/db_engine_specs/databricks.py b/superset/db_engine_specs/databricks.py index 5fee957a914a..edb8d06d4720 100644 --- a/superset/db_engine_specs/databricks.py +++ b/superset/db_engine_specs/databricks.py @@ -29,6 +29,7 @@ from marshmallow import fields, Schema from marshmallow.validate import Range from sqlalchemy import text, types +from sqlalchemy.dialects import registry from sqlalchemy.engine.default import DefaultDialect from sqlalchemy.engine.reflection import Inspector from sqlalchemy.engine.url import URL @@ -62,6 +63,13 @@ r"\[INSUFFICIENT_PERMISSIONS\]|\bSQLSTATE:\s*42501\b" ) +# Alias databricks+connector dialect to DatabricksDialect for backwards compatibility +registry.register( + "databricks.connector", + "databricks.sqlalchemy", + "DatabricksDialect", +) + try: from databricks.sql.utils import ParamEscaper @@ -799,7 +807,7 @@ class DatabricksPythonConnectorEngineSpec(DatabricksDynamicBaseEngineSpec): }, { "name": "databricks-dbapi (Legacy)", - "pypi_package": "databricks-dbapi[sqlalchemy]", + "pypi_package": "apache-superset[databricks]", "connection_string": ( "databricks+connector://token:{access_token}@{host}:{port}/{database}" ), @@ -943,7 +951,7 @@ class DatabricksNativeEngineSpec(DatabricksDynamicBaseEngineSpec): DatabaseCategory.ANALYTICAL_DATABASES, DatabaseCategory.PROPRIETARY, ], - "pypi_packages": ["databricks-dbapi[sqlalchemy]"], + "pypi_packages": ["apache-superset[databricks]"], "connection_string": ( "databricks+connector://token:{access_token}@{host}:{port}/{database_name}?http_path={http_path}" ), diff --git a/tests/unit_tests/db_engine_specs/test_databricks.py b/tests/unit_tests/db_engine_specs/test_databricks.py index fb06011bf8f3..633b31c74c15 100644 --- a/tests/unit_tests/db_engine_specs/test_databricks.py +++ b/tests/unit_tests/db_engine_specs/test_databricks.py @@ -74,9 +74,25 @@ def test_dialect_supports_installed_sqlalchemy(mocker: MockerFixture) -> None: assert str(text("SELECT :value").compile(engine)) == "SELECT :value" finally: engine.dispose() + + connector_engine = create_engine( + "databricks+connector://token:test-token@localhost" + "?http_path=/sql/1.0/warehouses/test&catalog=main&schema=default" + ) + try: + assert isinstance(connector_engine.dialect, dialect.DatabricksDialect) + finally: + connector_engine.dispose() socket.assert_not_called() +def test_databricks_connector_dialect_registration() -> None: + """Test that databricks.connector dialect is registered in SQLAlchemy registry.""" + from sqlalchemy.dialects import registry + + assert "databricks.connector" in registry.impls + + def test_get_parameters_from_uri() -> None: """ Test that the result from ``get_parameters_from_uri`` is JSON serializable. From 853f303c3ef2ed82649ff0a3e26547ba1576b10d Mon Sep 17 00:00:00 2001 From: "Francesco.Castaldi" Date: Sun, 11 Oct 2026 22:39:43 +0000 Subject: [PATCH 50/62] fix(db_engine_specs): remove unadded logos/urls and expand metadata test coverage --- superset/db_engine_specs/arc.py | 3 -- superset/db_engine_specs/ocient.py | 1 - superset/db_engine_specs/odps.py | 1 - superset/db_engine_specs/parseable.py | 1 - .../db_engine_specs/test_lint_metadata.py | 44 +++++++++++++++++++ .../db_engine_specs/test_teradata.py | 5 ++- 6 files changed, 48 insertions(+), 7 deletions(-) diff --git a/superset/db_engine_specs/arc.py b/superset/db_engine_specs/arc.py index 448fa1197b04..f167d9ce9da7 100644 --- a/superset/db_engine_specs/arc.py +++ b/superset/db_engine_specs/arc.py @@ -42,13 +42,10 @@ class ArcEngineSpec(BaseEngineSpec): metadata = { "description": "Arc is a data platform with multiple connection options.", - "logo": "arc.png", - "homepage_url": "https://arc.net", "categories": [DatabaseCategory.OTHER, DatabaseCategory.PROPRIETARY], "pypi_packages": ["arc-superset-arrow"], "connection_string": ("arc+arrow://{api_key}@{hostname}:{port}/{database}"), "default_port": 8000, - "docs_url": "https://docs.arcdata.io", "parameters": { "api_key": "Arc API key", "hostname": "Arc hostname", diff --git a/superset/db_engine_specs/ocient.py b/superset/db_engine_specs/ocient.py index 4c225ae150d9..418f6557b83f 100644 --- a/superset/db_engine_specs/ocient.py +++ b/superset/db_engine_specs/ocient.py @@ -238,7 +238,6 @@ class OcientEngineSpec(BaseEngineSpec): metadata = { "description": "Ocient is a hyperscale data analytics database.", - "logo": "ocient.png", "homepage_url": "https://www.ocient.com/", "categories": [ DatabaseCategory.ANALYTICAL_DATABASES, diff --git a/superset/db_engine_specs/odps.py b/superset/db_engine_specs/odps.py index 26194e831a37..20e6bdc852bb 100644 --- a/superset/db_engine_specs/odps.py +++ b/superset/db_engine_specs/odps.py @@ -75,7 +75,6 @@ class OdpsEngineSpec(BasicParametersMixin, OdpsBaseEngineSpec): "multi-tenancy cloud data warehousing platform for large-scale " "batch and streaming analytics." ), - "logo": "maxcompute.png", "homepage_url": "https://www.alibabacloud.com/product/maxcompute", "categories": [ DatabaseCategory.CLOUD_DATA_WAREHOUSES, diff --git a/superset/db_engine_specs/parseable.py b/superset/db_engine_specs/parseable.py index ac6761c805b3..9c71ba17d60e 100644 --- a/superset/db_engine_specs/parseable.py +++ b/superset/db_engine_specs/parseable.py @@ -41,7 +41,6 @@ class ParseableEngineSpec(BaseEngineSpec): "Parseable is a distributed log analytics database " "with SQL-like query interface." ), - "logo": "parseable.png", "homepage_url": "https://www.parseable.com", "categories": [DatabaseCategory.SEARCH_NOSQL, DatabaseCategory.OPEN_SOURCE], "pypi_packages": ["sqlalchemy-parseable"], diff --git a/tests/unit_tests/db_engine_specs/test_lint_metadata.py b/tests/unit_tests/db_engine_specs/test_lint_metadata.py index d5bca4ea499d..07e62a0f85b2 100644 --- a/tests/unit_tests/db_engine_specs/test_lint_metadata.py +++ b/tests/unit_tests/db_engine_specs/test_lint_metadata.py @@ -113,3 +113,47 @@ class DeclThenAssignBaseEngineSpec(BaseEngineSpec): assert spec["metadata"] == { "description": "Engine with declaration-then-assignment" } + + +def test_get_all_engine_specs_ast_set_and_unary_minus( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """ + Regression: ast.Set and unary-minus in metadata values are correctly + evaluated and preserved in the AST scan. + """ + _write_spec( + tmp_path, + "_tmp_complex_values.py", + """\ + from typing import Any + from superset.db_engine_specs.base import BaseEngineSpec + + class ComplexValuesBaseEngineSpec(BaseEngineSpec): + engine: str = "complex_engine" + engine_name: str = "Complex Engine" + metadata: dict[str, Any] = { + "tags": {"analytics", "warehouse"}, + "default_port": -1, + "negative_float": -3.14, + } + """, + ) + + monkeypatch.setattr( + "superset.db_engine_specs.lint_metadata.SPECS_DIR", + str(tmp_path), + ) + + specs = get_all_engine_specs_ast() + matching = [s for s in specs if s["class_name"] == "ComplexValuesBaseEngineSpec"] + assert len(matching) == 1 + spec = matching[0] + assert spec["engine_name"] == "Complex Engine" + assert spec["metadata"] == { + "tags": {"analytics", "warehouse"}, + "default_port": -1, + "negative_float": -3.14, + } + diff --git a/tests/unit_tests/db_engine_specs/test_teradata.py b/tests/unit_tests/db_engine_specs/test_teradata.py index 0df6f36184d7..a1e360488ecb 100644 --- a/tests/unit_tests/db_engine_specs/test_teradata.py +++ b/tests/unit_tests/db_engine_specs/test_teradata.py @@ -35,7 +35,10 @@ def test_teradata_metadata() -> None: def test_teradata_epoch_to_dttm() -> None: - assert "DATE '1970-01-01'" in TeradataEngineSpec.epoch_to_dttm().format(col="ts") + assert ( + TeradataEngineSpec.epoch_to_dttm().format(col="ts") + == "CAST(((CAST(DATE '1970-01-01' + (ts / 86400) AS TIMESTAMP(0) AT 0)) AT 0) + ((ts MOD 86400) * INTERVAL '00:00:01' HOUR TO SECOND) AS TIMESTAMP(0))" + ) @pytest.mark.parametrize( From be776ec7ee46ee7d23de135069ac19e00e68dc36 Mon Sep 17 00:00:00 2001 From: Francesco Castaldi Date: Tue, 13 Oct 2026 14:53:12 +0200 Subject: [PATCH 51/62] fix(db_engine_specs): align databricks parameters and fix unit test metadata assertions --- superset/db_engine_specs/databricks.py | 57 ++++++++++++++++++- tests/unit_tests/db_engine_specs/test_arc.py | 1 - .../db_engine_specs/test_databricks.py | 55 +++++++++++++++++- .../unit_tests/db_engine_specs/test_ocient.py | 1 - tests/unit_tests/db_engine_specs/test_odps.py | 1 - .../db_engine_specs/test_parseable.py | 1 - .../db_engine_specs/test_teradata.py | 6 +- 7 files changed, 110 insertions(+), 12 deletions(-) diff --git a/superset/db_engine_specs/databricks.py b/superset/db_engine_specs/databricks.py index edb8d06d4720..d6958c00c3c2 100644 --- a/superset/db_engine_specs/databricks.py +++ b/superset/db_engine_specs/databricks.py @@ -1019,6 +1019,37 @@ def build_sqlalchemy_uri( # type: ignore query=query, ).render_as_string(hide_password=False) + @classmethod + def adjust_engine_params( + cls, + uri: URL, + connect_args: dict[str, Any], + catalog: str | None = None, + schema: str | None = None, + ) -> tuple[URL, dict[str, Any]]: + effective_catalog = ( + catalog or connect_args.get("catalog") or uri.query.get("catalog") + ) + effective_schema = ( + schema + or uri.query.get("schema") + or (uri.database if uri.database else None) + ) + + query_params: dict[str, str] = {} + if effective_catalog: + query_params["catalog"] = effective_catalog + if effective_schema: + query_params["schema"] = effective_schema + if http_path := connect_args.get("http_path"): + if "http_path" not in uri.query: + query_params["http_path"] = http_path + + if query_params: + uri = uri.update_query_dict(query_params) + + return uri, connect_args + @classmethod def get_parameters_from_uri( # type: ignore cls, uri: str, *_, **__ @@ -1128,7 +1159,7 @@ class DatabricksODBCEngineSpec(DatabricksBaseEngineSpec): DatabaseCategory.ANALYTICAL_DATABASES, DatabaseCategory.PROPRIETARY, ], - "pypi_packages": ["databricks-dbapi[sqlalchemy]"], + "pypi_packages": ["databricks-dbapi[odbc]", "pyodbc"], "connection_string": ( "databricks+pyodbc://token:{access_token}@{host}:{port}/{database}?http_path={http_path}" ), @@ -1140,6 +1171,17 @@ class DatabricksODBCEngineSpec(DatabricksBaseEngineSpec): "database": "Database name", "http_path": "HTTP path from SQL endpoint settings", }, + "known_incompatibilities": [ + { + "dependency": "SQLAlchemy 2.0", + "reason": ( + "databricks-dbapi pins sqlalchemy>=1.3,<2.0; no SQLAlchemy 2.0 " + "support is planned for the legacy driver. Use the Databricks " + "Python Connector (databricks-sql-connector) instead." + ), + "tracking_url": "https://github.com/databricks/databricks-dbapi", + } + ], } # Note: Primary metadata is in DatabricksPythonConnectorEngineSpec which @@ -1172,7 +1214,7 @@ class DatabricksHiveEngineSpec(HiveEngineSpec): DatabaseCategory.ANALYTICAL_DATABASES, DatabaseCategory.HOSTED_OPEN_SOURCE, ], - "pypi_packages": ["databricks-dbapi[sqlalchemy]"], + "pypi_packages": ["databricks-dbapi[hive]", "pyhive", "thrift"], "connection_string": ( "databricks+pyhive://token:{access_token}@{host}:{port}/{database}?http_path={http_path}" ), @@ -1184,6 +1226,17 @@ class DatabricksHiveEngineSpec(HiveEngineSpec): "database": "Database name", "http_path": "HTTP path from cluster settings", }, + "known_incompatibilities": [ + { + "dependency": "SQLAlchemy 2.0", + "reason": ( + "databricks-dbapi pins sqlalchemy>=1.3,<2.0; no SQLAlchemy 2.0 " + "support is planned for the legacy driver. Use the Databricks " + "Python Connector (databricks-sql-connector) instead." + ), + "tracking_url": "https://github.com/databricks/databricks-dbapi", + } + ], } # Note: Primary metadata is in DatabricksPythonConnectorEngineSpec which diff --git a/tests/unit_tests/db_engine_specs/test_arc.py b/tests/unit_tests/db_engine_specs/test_arc.py index 6e0ae94442ec..cf216bf585fd 100644 --- a/tests/unit_tests/db_engine_specs/test_arc.py +++ b/tests/unit_tests/db_engine_specs/test_arc.py @@ -32,7 +32,6 @@ def test_arc_metadata() -> None: metadata["description"] == "Arc is a data platform with multiple connection options." ) - assert metadata["logo"] == "arc.png" assert "arc-superset-arrow" in metadata["pypi_packages"] assert metadata["default_port"] == 8000 diff --git a/tests/unit_tests/db_engine_specs/test_databricks.py b/tests/unit_tests/db_engine_specs/test_databricks.py index 633b31c74c15..dc6a3a552a63 100644 --- a/tests/unit_tests/db_engine_specs/test_databricks.py +++ b/tests/unit_tests/db_engine_specs/test_databricks.py @@ -75,17 +75,66 @@ def test_dialect_supports_installed_sqlalchemy(mocker: MockerFixture) -> None: finally: engine.dispose() - connector_engine = create_engine( - "databricks+connector://token:test-token@localhost" - "?http_path=/sql/1.0/warehouses/test&catalog=main&schema=default" + native_uri_str = DatabricksNativeEngineSpec.build_sqlalchemy_uri( + { + "access_token": "test-token", + "host": "localhost", + "port": 443, + "database": "default", + } ) + native_url, _ = DatabricksNativeEngineSpec.adjust_engine_params( + make_url(native_uri_str), + connect_args={"http_path": "/sql/1.0/warehouses/test"}, + catalog="main", + schema="default", + ) + connector_engine = create_engine(native_url) try: assert isinstance(connector_engine.dialect, dialect.DatabricksDialect) + _, connector_connect_args = connector_engine.dialect.create_connect_args( + connector_engine.url + ) + assert connector_connect_args["http_path"] == "/sql/1.0/warehouses/test" + assert connector_connect_args["catalog"] == "main" + assert connector_connect_args["schema"] == "default" finally: connector_engine.dispose() socket.assert_not_called() +def test_databricks_native_adjust_engine_params() -> None: + """Test that DatabricksNativeEngineSpec translates catalog and schema to query params.""" + uri_str = DatabricksNativeEngineSpec.build_sqlalchemy_uri( + { + "access_token": "test-token", + "host": "localhost", + "port": 443, + "database": "default", + } + ) + assert uri_str == "databricks+connector://token:test-token@localhost:443/default" + + # Test explicit catalog and schema with http_path in connect_args + adjusted_url, _ = DatabricksNativeEngineSpec.adjust_engine_params( + make_url(uri_str), + connect_args={"http_path": "/sql/1.0/warehouses/test"}, + catalog="main", + schema="custom_schema", + ) + assert adjusted_url.query["catalog"] == "main" + assert adjusted_url.query["schema"] == "custom_schema" + assert adjusted_url.query["http_path"] == "/sql/1.0/warehouses/test" + + # Test catalog from connect_args and fallback schema from URI database + adjusted_url_fallback, _ = DatabricksNativeEngineSpec.adjust_engine_params( + make_url(uri_str), + connect_args={"catalog": "connect_arg_cat"}, + ) + assert adjusted_url_fallback.query["catalog"] == "connect_arg_cat" + assert adjusted_url_fallback.query["schema"] == "default" + + def test_databricks_connector_dialect_registration() -> None: """Test that databricks.connector dialect is registered in SQLAlchemy registry.""" from sqlalchemy.dialects import registry diff --git a/tests/unit_tests/db_engine_specs/test_ocient.py b/tests/unit_tests/db_engine_specs/test_ocient.py index ea64f92e8996..68c4a8b2b3be 100644 --- a/tests/unit_tests/db_engine_specs/test_ocient.py +++ b/tests/unit_tests/db_engine_specs/test_ocient.py @@ -43,7 +43,6 @@ def test_ocient_properties() -> None: def test_ocient_metadata() -> None: metadata = OcientEngineSpec.metadata assert metadata["description"] == "Ocient is a hyperscale data analytics database." - assert metadata["logo"] == "ocient.png" assert "sqlalchemy-ocient" in metadata["pypi_packages"] assert metadata["default_port"] == 4050 diff --git a/tests/unit_tests/db_engine_specs/test_odps.py b/tests/unit_tests/db_engine_specs/test_odps.py index cd67278c0953..2a2be48b587b 100644 --- a/tests/unit_tests/db_engine_specs/test_odps.py +++ b/tests/unit_tests/db_engine_specs/test_odps.py @@ -35,7 +35,6 @@ def test_odps_properties() -> None: def test_odps_metadata() -> None: metadata = OdpsEngineSpec.metadata assert "MaxCompute" in metadata["description"] - assert metadata["logo"] == "maxcompute.png" assert "pyodps" in metadata["pypi_packages"] assert "odps://" in metadata["connection_string"] diff --git a/tests/unit_tests/db_engine_specs/test_parseable.py b/tests/unit_tests/db_engine_specs/test_parseable.py index 981d1727ec08..73313587b550 100644 --- a/tests/unit_tests/db_engine_specs/test_parseable.py +++ b/tests/unit_tests/db_engine_specs/test_parseable.py @@ -34,7 +34,6 @@ def test_parseable_metadata() -> None: assert ( "Parseable is a distributed log analytics database" in metadata["description"] ) - assert metadata["logo"] == "parseable.png" assert "sqlalchemy-parseable" in metadata["pypi_packages"] assert metadata["default_port"] == 8000 assert ( diff --git a/tests/unit_tests/db_engine_specs/test_teradata.py b/tests/unit_tests/db_engine_specs/test_teradata.py index a1e360488ecb..ebe001daf7bf 100644 --- a/tests/unit_tests/db_engine_specs/test_teradata.py +++ b/tests/unit_tests/db_engine_specs/test_teradata.py @@ -35,9 +35,9 @@ def test_teradata_metadata() -> None: def test_teradata_epoch_to_dttm() -> None: - assert ( - TeradataEngineSpec.epoch_to_dttm().format(col="ts") - == "CAST(((CAST(DATE '1970-01-01' + (ts / 86400) AS TIMESTAMP(0) AT 0)) AT 0) + ((ts MOD 86400) * INTERVAL '00:00:01' HOUR TO SECOND) AS TIMESTAMP(0))" + assert TeradataEngineSpec.epoch_to_dttm().format(col="ts") == ( + "CAST(((CAST(DATE '1970-01-01' + (ts / 86400) AS TIMESTAMP(0) AT 0)) AT 0)" + " + ((ts MOD 86400) * INTERVAL '00:00:01' HOUR TO SECOND) AS TIMESTAMP(0))" ) From 8c6a57be8d21fe7533b81bef23ecf30d9dd30d94 Mon Sep 17 00:00:00 2001 From: Francesco Castaldi Date: Tue, 13 Oct 2026 15:50:35 +0200 Subject: [PATCH 52/62] fix(databricks): resolve default catalog and synchronize connect_args with URL in native spec --- superset/db_engine_specs/databricks.py | 23 +++++-- .../db_engine_specs/test_databricks.py | 65 +++++++++++++++---- 2 files changed, 70 insertions(+), 18 deletions(-) diff --git a/superset/db_engine_specs/databricks.py b/superset/db_engine_specs/databricks.py index d6958c00c3c2..c0c54eefe883 100644 --- a/superset/db_engine_specs/databricks.py +++ b/superset/db_engine_specs/databricks.py @@ -932,6 +932,7 @@ class DatabricksNativeEngineSpec(DatabricksDynamicBaseEngineSpec): engine_name = "Databricks (legacy)" drivers = {"connector": "Native all-purpose driver"} default_driver = "connector" + default_catalog = "hive_metastore" parameters_schema = DatabricksNativeSchema() properties_schema = DatabricksNativePropertiesSchema() @@ -1027,28 +1028,40 @@ def adjust_engine_params( catalog: str | None = None, schema: str | None = None, ) -> tuple[URL, dict[str, Any]]: + adjusted_connect_args = connect_args.copy() + effective_catalog = ( - catalog or connect_args.get("catalog") or uri.query.get("catalog") + catalog + or adjusted_connect_args.get("catalog") + or uri.query.get("catalog") + or cls.default_catalog ) effective_schema = ( schema + or adjusted_connect_args.get("schema") or uri.query.get("schema") or (uri.database if uri.database else None) ) + effective_http_path = ( + adjusted_connect_args.get("http_path") + or uri.query.get("http_path") + ) query_params: dict[str, str] = {} if effective_catalog: query_params["catalog"] = effective_catalog + adjusted_connect_args["catalog"] = effective_catalog if effective_schema: query_params["schema"] = effective_schema - if http_path := connect_args.get("http_path"): - if "http_path" not in uri.query: - query_params["http_path"] = http_path + adjusted_connect_args["schema"] = effective_schema + if effective_http_path: + query_params["http_path"] = effective_http_path + adjusted_connect_args["http_path"] = effective_http_path if query_params: uri = uri.update_query_dict(query_params) - return uri, connect_args + return uri, adjusted_connect_args @classmethod def get_parameters_from_uri( # type: ignore diff --git a/tests/unit_tests/db_engine_specs/test_databricks.py b/tests/unit_tests/db_engine_specs/test_databricks.py index dc6a3a552a63..ba85157b59b6 100644 --- a/tests/unit_tests/db_engine_specs/test_databricks.py +++ b/tests/unit_tests/db_engine_specs/test_databricks.py @@ -83,28 +83,31 @@ def test_dialect_supports_installed_sqlalchemy(mocker: MockerFixture) -> None: "database": "default", } ) - native_url, _ = DatabricksNativeEngineSpec.adjust_engine_params( + native_url, native_connect_args = DatabricksNativeEngineSpec.adjust_engine_params( make_url(native_uri_str), connect_args={"http_path": "/sql/1.0/warehouses/test"}, - catalog="main", - schema="default", ) - connector_engine = create_engine(native_url) + connector_engine = create_engine(native_url, connect_args=native_connect_args) try: assert isinstance(connector_engine.dialect, dialect.DatabricksDialect) - _, connector_connect_args = connector_engine.dialect.create_connect_args( + _, connector_dialect_args = connector_engine.dialect.create_connect_args( connector_engine.url ) - assert connector_connect_args["http_path"] == "/sql/1.0/warehouses/test" - assert connector_connect_args["catalog"] == "main" - assert connector_connect_args["schema"] == "default" + assert connector_dialect_args["http_path"] == "/sql/1.0/warehouses/test" + assert connector_dialect_args["catalog"] == "hive_metastore" + assert connector_dialect_args["schema"] == "default" + assert native_connect_args["http_path"] == "/sql/1.0/warehouses/test" + assert native_connect_args["catalog"] == "hive_metastore" + assert native_connect_args["schema"] == "default" + assert connector_engine.dialect.catalog == "hive_metastore" + assert connector_engine.dialect.schema == "default" finally: connector_engine.dispose() socket.assert_not_called() def test_databricks_native_adjust_engine_params() -> None: - """Test that DatabricksNativeEngineSpec translates catalog and schema to query params.""" + """Test that DatabricksNativeEngineSpec translates catalog/schema and syncs connect_args.""" uri_str = DatabricksNativeEngineSpec.build_sqlalchemy_uri( { "access_token": "test-token", @@ -115,8 +118,21 @@ def test_databricks_native_adjust_engine_params() -> None: ) assert uri_str == "databricks+connector://token:test-token@localhost:443/default" + # Test default resolution when no catalog is supplied: resolves hive_metastore + # and keeps URL and connect_args in full agreement + url_default, args_default = DatabricksNativeEngineSpec.adjust_engine_params( + make_url(uri_str), + connect_args={"http_path": "/sql/1.0/warehouses/test"}, + ) + assert url_default.query["catalog"] == "hive_metastore" + assert url_default.query["schema"] == "default" + assert url_default.query["http_path"] == "/sql/1.0/warehouses/test" + assert args_default["catalog"] == "hive_metastore" + assert args_default["schema"] == "default" + assert args_default["http_path"] == "/sql/1.0/warehouses/test" + # Test explicit catalog and schema with http_path in connect_args - adjusted_url, _ = DatabricksNativeEngineSpec.adjust_engine_params( + adjusted_url, adjusted_args = DatabricksNativeEngineSpec.adjust_engine_params( make_url(uri_str), connect_args={"http_path": "/sql/1.0/warehouses/test"}, catalog="main", @@ -125,14 +141,37 @@ def test_databricks_native_adjust_engine_params() -> None: assert adjusted_url.query["catalog"] == "main" assert adjusted_url.query["schema"] == "custom_schema" assert adjusted_url.query["http_path"] == "/sql/1.0/warehouses/test" + assert adjusted_args["catalog"] == "main" + assert adjusted_args["schema"] == "custom_schema" + assert adjusted_args["http_path"] == "/sql/1.0/warehouses/test" + + # Test reconciling conflicting schema in connect_args with passed schema + reconciled_url, reconciled_args = ( + DatabricksNativeEngineSpec.adjust_engine_params( + make_url(uri_str), + connect_args={ + "catalog": "connect_arg_cat", + "schema": "stale_schema", + }, + schema="reconciled_schema", + ) + ) + assert reconciled_url.query["catalog"] == "connect_arg_cat" + assert reconciled_url.query["schema"] == "reconciled_schema" + assert reconciled_args["catalog"] == "connect_arg_cat" + assert reconciled_args["schema"] == "reconciled_schema" # Test catalog from connect_args and fallback schema from URI database - adjusted_url_fallback, _ = DatabricksNativeEngineSpec.adjust_engine_params( - make_url(uri_str), - connect_args={"catalog": "connect_arg_cat"}, + adjusted_url_fallback, adjusted_args_fallback = ( + DatabricksNativeEngineSpec.adjust_engine_params( + make_url(uri_str), + connect_args={"catalog": "connect_arg_cat"}, + ) ) assert adjusted_url_fallback.query["catalog"] == "connect_arg_cat" assert adjusted_url_fallback.query["schema"] == "default" + assert adjusted_args_fallback["catalog"] == "connect_arg_cat" + assert adjusted_args_fallback["schema"] == "default" def test_databricks_connector_dialect_registration() -> None: From 6209589fc6629c0125d5bb26fc7c5e5b07c2c86c Mon Sep 17 00:00:00 2001 From: Francesco Castaldi Date: Wed, 14 Oct 2026 09:30:00 +0200 Subject: [PATCH 53/62] fix(db_engine_specs): omit default catalog fallback and skip URI-database schema on catalog override Address follow-up review comments from @sadpandajoe on PR #43695: 1. In DatabricksNativeEngineSpec, drop the forced default_catalog = "hive_metastore" fallback when no catalog is configured. Leaving catalog unset allows the server's default catalog (e.g. main) to apply naturally and prevents connection handshake errors on servers without multi-catalog support or discovery interference in get_default_catalog(). 2. In adjust_engine_params, apply the URI-database schema fallback only when catalog is not being overridden. When browsing a different catalog (catalog="main"), forcing a database name from the original URI (e.g. /finance) before catalog switching could fail the connection namespace handshake. 3. Update unit tests in test_databricks.py to verify that catalog is omitted when unconfigured and that URI-database schema fallback is skipped during catalog overrides. Refs: apache/superset#43695 --- superset/db_engine_specs/databricks.py | 15 +++++++---- .../db_engine_specs/test_databricks.py | 26 +++++++++++++++---- 2 files changed, 31 insertions(+), 10 deletions(-) diff --git a/superset/db_engine_specs/databricks.py b/superset/db_engine_specs/databricks.py index c0c54eefe883..0b1016b1de5f 100644 --- a/superset/db_engine_specs/databricks.py +++ b/superset/db_engine_specs/databricks.py @@ -932,7 +932,6 @@ class DatabricksNativeEngineSpec(DatabricksDynamicBaseEngineSpec): engine_name = "Databricks (legacy)" drivers = {"connector": "Native all-purpose driver"} default_driver = "connector" - default_catalog = "hive_metastore" parameters_schema = DatabricksNativeSchema() properties_schema = DatabricksNativePropertiesSchema() @@ -1030,17 +1029,17 @@ def adjust_engine_params( ) -> tuple[URL, dict[str, Any]]: adjusted_connect_args = connect_args.copy() + is_catalog_overridden = catalog is not None effective_catalog = ( catalog or adjusted_connect_args.get("catalog") or uri.query.get("catalog") - or cls.default_catalog ) effective_schema = ( schema - or adjusted_connect_args.get("schema") - or uri.query.get("schema") - or (uri.database if uri.database else None) + or (adjusted_connect_args.get("schema") if not is_catalog_overridden else None) + or (uri.query.get("schema") if not is_catalog_overridden else None) + or (uri.database if uri.database and not is_catalog_overridden else None) ) effective_http_path = ( adjusted_connect_args.get("http_path") @@ -1051,9 +1050,15 @@ def adjust_engine_params( if effective_catalog: query_params["catalog"] = effective_catalog adjusted_connect_args["catalog"] = effective_catalog + else: + adjusted_connect_args.pop("catalog", None) + if effective_schema: query_params["schema"] = effective_schema adjusted_connect_args["schema"] = effective_schema + else: + adjusted_connect_args.pop("schema", None) + if effective_http_path: query_params["http_path"] = effective_http_path adjusted_connect_args["http_path"] = effective_http_path diff --git a/tests/unit_tests/db_engine_specs/test_databricks.py b/tests/unit_tests/db_engine_specs/test_databricks.py index ba85157b59b6..8a4915b993f3 100644 --- a/tests/unit_tests/db_engine_specs/test_databricks.py +++ b/tests/unit_tests/db_engine_specs/test_databricks.py @@ -85,7 +85,10 @@ def test_dialect_supports_installed_sqlalchemy(mocker: MockerFixture) -> None: ) native_url, native_connect_args = DatabricksNativeEngineSpec.adjust_engine_params( make_url(native_uri_str), - connect_args={"http_path": "/sql/1.0/warehouses/test"}, + connect_args={ + "http_path": "/sql/1.0/warehouses/test", + "catalog": "hive_metastore", + }, ) connector_engine = create_engine(native_url, connect_args=native_connect_args) try: @@ -118,19 +121,32 @@ def test_databricks_native_adjust_engine_params() -> None: ) assert uri_str == "databricks+connector://token:test-token@localhost:443/default" - # Test default resolution when no catalog is supplied: resolves hive_metastore - # and keeps URL and connect_args in full agreement + # Test default resolution when no catalog is supplied: catalog is omitted + # so server default applies, while URI database is preserved as schema url_default, args_default = DatabricksNativeEngineSpec.adjust_engine_params( make_url(uri_str), connect_args={"http_path": "/sql/1.0/warehouses/test"}, ) - assert url_default.query["catalog"] == "hive_metastore" + assert "catalog" not in url_default.query assert url_default.query["schema"] == "default" assert url_default.query["http_path"] == "/sql/1.0/warehouses/test" - assert args_default["catalog"] == "hive_metastore" + assert "catalog" not in args_default assert args_default["schema"] == "default" assert args_default["http_path"] == "/sql/1.0/warehouses/test" + # Test catalog override without schema: URI-database schema fallback is omitted + url_cat_override, args_cat_override = ( + DatabricksNativeEngineSpec.adjust_engine_params( + make_url("databricks+connector://token:test-token@localhost:443/finance"), + connect_args={"http_path": "/sql/1.0/warehouses/test"}, + catalog="main", + ) + ) + assert url_cat_override.query["catalog"] == "main" + assert "schema" not in url_cat_override.query + assert args_cat_override["catalog"] == "main" + assert "schema" not in args_cat_override + # Test explicit catalog and schema with http_path in connect_args adjusted_url, adjusted_args = DatabricksNativeEngineSpec.adjust_engine_params( make_url(uri_str), From 1c2d9f83b5371e6a366dbe0df0051100385e4a2c Mon Sep 17 00:00:00 2001 From: Francesco Castaldi Date: Wed, 14 Oct 2026 12:41:58 +0200 Subject: [PATCH 54/62] fix(db_engine_specs): purge stale schema from URL query on catalog override Follow up on review comments from @sadpandajoe on PR #43695: - In DatabricksNativeEngineSpec.adjust_engine_params, mutate query parameters via dict(uri.query) and uri.set(query=query_dict) instead of uri.update_query_dict, ensuring stale schema query parameters are completely purged from the URL query when catalog is overridden without schema. - Shorten test docstring in test_databricks.py to fit within the 88-column Ruff limit. - Add unit test verifying that starting from a URI with ?catalog=main&schema=finance, adjust_engine_params with catalog="other" strips schema from both the URL query and connect_args. Refs: apache/superset#43695 --- superset/db_engine_specs/databricks.py | 13 ++++++------ .../db_engine_specs/test_databricks.py | 20 ++++++++++++++++++- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/superset/db_engine_specs/databricks.py b/superset/db_engine_specs/databricks.py index 0b1016b1de5f..e91ef6eda4b7 100644 --- a/superset/db_engine_specs/databricks.py +++ b/superset/db_engine_specs/databricks.py @@ -1046,25 +1046,26 @@ def adjust_engine_params( or uri.query.get("http_path") ) - query_params: dict[str, str] = {} + query_dict = dict(uri.query) if effective_catalog: - query_params["catalog"] = effective_catalog + query_dict["catalog"] = effective_catalog adjusted_connect_args["catalog"] = effective_catalog else: + query_dict.pop("catalog", None) adjusted_connect_args.pop("catalog", None) if effective_schema: - query_params["schema"] = effective_schema + query_dict["schema"] = effective_schema adjusted_connect_args["schema"] = effective_schema else: + query_dict.pop("schema", None) adjusted_connect_args.pop("schema", None) if effective_http_path: - query_params["http_path"] = effective_http_path + query_dict["http_path"] = effective_http_path adjusted_connect_args["http_path"] = effective_http_path - if query_params: - uri = uri.update_query_dict(query_params) + uri = uri.set(query=query_dict) return uri, adjusted_connect_args diff --git a/tests/unit_tests/db_engine_specs/test_databricks.py b/tests/unit_tests/db_engine_specs/test_databricks.py index 8a4915b993f3..9b1d3bba6572 100644 --- a/tests/unit_tests/db_engine_specs/test_databricks.py +++ b/tests/unit_tests/db_engine_specs/test_databricks.py @@ -110,7 +110,7 @@ def test_dialect_supports_installed_sqlalchemy(mocker: MockerFixture) -> None: def test_databricks_native_adjust_engine_params() -> None: - """Test that DatabricksNativeEngineSpec translates catalog/schema and syncs connect_args.""" + """Test that adjust_engine_params maps catalog/schema into the URL and args.""" uri_str = DatabricksNativeEngineSpec.build_sqlalchemy_uri( { "access_token": "test-token", @@ -147,6 +147,24 @@ def test_databricks_native_adjust_engine_params() -> None: assert args_cat_override["catalog"] == "main" assert "schema" not in args_cat_override + # Test catalog override when URI already contains ?catalog=main&schema=finance + url_query_cat_override, args_query_cat_override = ( + DatabricksNativeEngineSpec.adjust_engine_params( + make_url( + "databricks+connector://token:test-token@localhost:443/" + "?catalog=main&schema=finance&http_path=/sql/1.0/warehouses/test" + ), + connect_args={}, + catalog="other", + ) + ) + assert url_query_cat_override.query["catalog"] == "other" + assert "schema" not in url_query_cat_override.query + assert url_query_cat_override.query["http_path"] == "/sql/1.0/warehouses/test" + assert args_query_cat_override["catalog"] == "other" + assert "schema" not in args_query_cat_override + assert args_query_cat_override["http_path"] == "/sql/1.0/warehouses/test" + # Test explicit catalog and schema with http_path in connect_args adjusted_url, adjusted_args = DatabricksNativeEngineSpec.adjust_engine_params( make_url(uri_str), From 1a59b994060952b444157b1ccf4b45d6f1ab5b35 Mon Sep 17 00:00:00 2001 From: Francesco Castaldi Date: Wed, 14 Oct 2026 17:31:44 +0200 Subject: [PATCH 55/62] fix(db_engine_specs): databricks catalog schema resolution and lint_metadata stdout ascii test Address review feedback from @sadpandajoe on PR #43695: 1. In superset/db_engine_specs/databricks.py: - When a catalog override matches the connection's existing catalog with schema=None (e.g. creating a dataset with catalog="main", schema=None against a /default?catalog=main URI), preserve the existing schema from connect_args, uri.query, or uri.database instead of dropping it to None. - When a catalog is overridden to a new or different catalog without an explicit schema, resolve the catalog's default schema ("default") instead of leaving effective_schema as None. This ensures the dialect's self.schema is always a valid string, preventing primary/foreign key reflection from generating invalid queries such as `main`.`None`.`orders`. 2. In tests/unit_tests/db_engine_specs/test_databricks.py: - Update unit test assertions for catalog overrides to assert that default schema "default" is resolved when catalog changes without an explicit schema. - Add test case verifying dataset creation scenario where catalog="main" matches existing URI catalog and preserves schema="default". 3. In tests/unit_tests/db_engine_specs/test_lint_metadata.py: - Add test_main_ascii_stdout_utf8_reconfigure verifying that calling main() with an ASCII-configured TextIOWrapper stdout successfully reconfigures to UTF-8 without UnicodeEncodeError and produces valid decodable output. - Add test_main_ascii_stdout_markdown_report testing the --markdown flag under ASCII console redirection. Refs: apache/superset#43695 --- superset/db_engine_specs/databricks.py | 32 ++++--- .../db_engine_specs/test_databricks.py | 30 +++++-- .../db_engine_specs/test_lint_metadata.py | 87 ++++++++++++++++++- 3 files changed, 133 insertions(+), 16 deletions(-) diff --git a/superset/db_engine_specs/databricks.py b/superset/db_engine_specs/databricks.py index e91ef6eda4b7..9970bd905821 100644 --- a/superset/db_engine_specs/databricks.py +++ b/superset/db_engine_specs/databricks.py @@ -1029,18 +1029,30 @@ def adjust_engine_params( ) -> tuple[URL, dict[str, Any]]: adjusted_connect_args = connect_args.copy() - is_catalog_overridden = catalog is not None - effective_catalog = ( - catalog - or adjusted_connect_args.get("catalog") - or uri.query.get("catalog") + original_catalog = ( + adjusted_connect_args.get("catalog") or uri.query.get("catalog") ) - effective_schema = ( - schema - or (adjusted_connect_args.get("schema") if not is_catalog_overridden else None) - or (uri.query.get("schema") if not is_catalog_overridden else None) - or (uri.database if uri.database and not is_catalog_overridden else None) + is_catalog_changing = ( + catalog is not None + and (original_catalog is None or catalog != original_catalog) ) + + effective_catalog = catalog or original_catalog + if schema: + effective_schema = schema + elif not is_catalog_changing: + effective_schema = ( + adjusted_connect_args.get("schema") + or uri.query.get("schema") + or uri.database + or "default" + ) + else: + # Catalog is overridden to a new or different catalog without an + # explicit schema. Resolve the catalog's default schema ("default") + # to prevent reflection from generating invalid queries like + # `catalog`.`None`.`table`. + effective_schema = "default" effective_http_path = ( adjusted_connect_args.get("http_path") or uri.query.get("http_path") diff --git a/tests/unit_tests/db_engine_specs/test_databricks.py b/tests/unit_tests/db_engine_specs/test_databricks.py index 9b1d3bba6572..4e95b939153b 100644 --- a/tests/unit_tests/db_engine_specs/test_databricks.py +++ b/tests/unit_tests/db_engine_specs/test_databricks.py @@ -135,6 +135,7 @@ def test_databricks_native_adjust_engine_params() -> None: assert args_default["http_path"] == "/sql/1.0/warehouses/test" # Test catalog override without schema: URI-database schema fallback is omitted + # and the catalog's default schema ("default") is resolved instead url_cat_override, args_cat_override = ( DatabricksNativeEngineSpec.adjust_engine_params( make_url("databricks+connector://token:test-token@localhost:443/finance"), @@ -143,11 +144,12 @@ def test_databricks_native_adjust_engine_params() -> None: ) ) assert url_cat_override.query["catalog"] == "main" - assert "schema" not in url_cat_override.query + assert url_cat_override.query["schema"] == "default" assert args_cat_override["catalog"] == "main" - assert "schema" not in args_cat_override + assert args_cat_override["schema"] == "default" # Test catalog override when URI already contains ?catalog=main&schema=finance + # Switching to a different catalog resolves the catalog's default schema url_query_cat_override, args_query_cat_override = ( DatabricksNativeEngineSpec.adjust_engine_params( make_url( @@ -159,11 +161,29 @@ def test_databricks_native_adjust_engine_params() -> None: ) ) assert url_query_cat_override.query["catalog"] == "other" - assert "schema" not in url_query_cat_override.query + assert url_query_cat_override.query["schema"] == "default" assert url_query_cat_override.query["http_path"] == "/sql/1.0/warehouses/test" assert args_query_cat_override["catalog"] == "other" - assert "schema" not in args_query_cat_override - assert args_query_cat_override["http_path"] == "/sql/1.0/warehouses/test" + assert args_query_cat_override["schema"] == "default" + assert args_query_cat_override["http_path"] == "/sql/1.0/warehouses/test" + + # Test catalog override matching existing catalog in URI with schema=None + # (dataset creation scenario): preserves the URI-defined schema ("default") + url_cat_match, args_cat_match = ( + DatabricksNativeEngineSpec.adjust_engine_params( + make_url( + "databricks+connector://token:test-token@localhost:443/default" + "?catalog=main&http_path=/sql/1.0/warehouses/test" + ), + connect_args={"http_path": "/sql/1.0/warehouses/test"}, + catalog="main", + schema=None, + ) + ) + assert url_cat_match.query["catalog"] == "main" + assert url_cat_match.query["schema"] == "default" + assert args_cat_match["catalog"] == "main" + assert args_cat_match["schema"] == "default" # Test explicit catalog and schema with http_path in connect_args adjusted_url, adjusted_args = DatabricksNativeEngineSpec.adjust_engine_params( diff --git a/tests/unit_tests/db_engine_specs/test_lint_metadata.py b/tests/unit_tests/db_engine_specs/test_lint_metadata.py index 07e62a0f85b2..1361854bd4de 100644 --- a/tests/unit_tests/db_engine_specs/test_lint_metadata.py +++ b/tests/unit_tests/db_engine_specs/test_lint_metadata.py @@ -20,7 +20,11 @@ import pytest -from superset.db_engine_specs.lint_metadata import get_all_engine_specs_ast +import io +from superset.db_engine_specs.lint_metadata import ( + get_all_engine_specs_ast, + main, +) def _write_spec(directory: Path, filename: str, code: str) -> Path: @@ -157,3 +161,84 @@ class ComplexValuesBaseEngineSpec(BaseEngineSpec): "negative_float": -3.14, } +def test_main_ascii_stdout_utf8_reconfigure( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """ + Ensure main() reconfigures an ASCII stdout stream to UTF-8 without raising + UnicodeEncodeError when outputting Unicode symbols in reports. + """ + _write_spec( + tmp_path, + "_tmp_ascii_spec.py", + """\ + from typing import Any + from superset.db_engine_specs.base import BaseEngineSpec + + class AsciiConsoleBaseEngineSpec(BaseEngineSpec): + engine: str = "ascii_engine" + engine_name: str = "ASCII Engine" + metadata: dict[str, Any] = { + "description": "An engine spec for ascii console testing", + } + """, + ) + + monkeypatch.setattr( + "superset.db_engine_specs.lint_metadata.SPECS_DIR", + str(tmp_path), + ) + monkeypatch.setattr("sys.argv", ["lint_metadata"]) + + buffer = io.BytesIO() + ascii_stdout = io.TextIOWrapper(buffer, encoding="ascii", write_through=True) + monkeypatch.setattr("sys.stdout", ascii_stdout) + + ret = main() + assert ret == 0 + + decoded = buffer.getvalue().decode("utf-8") + assert "ASCII Engine" in decoded + + +def test_main_ascii_stdout_markdown_report( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """ + Ensure main() with --markdown output on an ASCII stream completes + without UnicodeEncodeError and produces valid UTF-8 output. + """ + _write_spec( + tmp_path, + "_tmp_ascii_md_spec.py", + """\ + from typing import Any + from superset.db_engine_specs.base import BaseEngineSpec + + class AsciiMarkdownBaseEngineSpec(BaseEngineSpec): + engine: str = "ascii_md_engine" + engine_name: str = "ASCII MD Engine" + metadata: dict[str, Any] = { + "description": "An engine spec for ascii markdown testing", + } + """, + ) + + monkeypatch.setattr( + "superset.db_engine_specs.lint_metadata.SPECS_DIR", + str(tmp_path), + ) + monkeypatch.setattr("sys.argv", ["lint_metadata", "--markdown"]) + + buffer = io.BytesIO() + ascii_stdout = io.TextIOWrapper(buffer, encoding="ascii", write_through=True) + monkeypatch.setattr("sys.stdout", ascii_stdout) + + ret = main() + assert ret == 0 + + decoded = buffer.getvalue().decode("utf-8") + assert "ASCII MD Engine" in decoded + From 58d75109c14e6b1f2f099cacfcc0db06bcc3da41 Mon Sep 17 00:00:00 2001 From: FrancescoCastaldi Date: Thu, 15 Oct 2026 01:23:13 +0200 Subject: [PATCH 56/62] fix(db_engine_specs): keep URI schema when catalog is not configured Address review feedback from @sadpandajoe on PR #43695: - databricks.py: reset the schema to "default" only when the caller switches away from a catalog explicitly configured on the connection. A connection without a catalog (e.g. /finance?http_path=...) now keeps the URI schema when dataset creation passes the server default catalog, so reflection targets main.finance.orders instead of main.default.orders. - test_databricks.py: pin the no-catalog /finance URI with catalog="main", the /finance?catalog=main URI with catalog="main", and a real switch from connect_args catalog "main" to "other". Apply ruff format. - test_lint_metadata.py: move `import io` into the stdlib group (I001). --- superset/db_engine_specs/databricks.py | 22 ++--- .../db_engine_specs/test_databricks.py | 81 +++++++++++-------- .../db_engine_specs/test_lint_metadata.py | 4 +- 3 files changed, 61 insertions(+), 46 deletions(-) diff --git a/superset/db_engine_specs/databricks.py b/superset/db_engine_specs/databricks.py index 9970bd905821..1d91ac22909a 100644 --- a/superset/db_engine_specs/databricks.py +++ b/superset/db_engine_specs/databricks.py @@ -1029,12 +1029,17 @@ def adjust_engine_params( ) -> tuple[URL, dict[str, Any]]: adjusted_connect_args = connect_args.copy() - original_catalog = ( - adjusted_connect_args.get("catalog") or uri.query.get("catalog") + original_catalog = adjusted_connect_args.get("catalog") or uri.query.get( + "catalog" ) + # Only a switch away from an explicitly configured catalog invalidates + # the saved schema. When the connection has no catalog configured, the + # caller's catalog is the server default (see `get_default_catalog`), so + # the schema from the URI still applies. is_catalog_changing = ( catalog is not None - and (original_catalog is None or catalog != original_catalog) + and original_catalog is not None + and catalog != original_catalog ) effective_catalog = catalog or original_catalog @@ -1048,14 +1053,13 @@ def adjust_engine_params( or "default" ) else: - # Catalog is overridden to a new or different catalog without an - # explicit schema. Resolve the catalog's default schema ("default") - # to prevent reflection from generating invalid queries like + # Catalog is overridden to a different catalog without an explicit + # schema. Resolve the catalog's default schema ("default") to prevent + # reflection from generating invalid queries like # `catalog`.`None`.`table`. effective_schema = "default" - effective_http_path = ( - adjusted_connect_args.get("http_path") - or uri.query.get("http_path") + effective_http_path = adjusted_connect_args.get("http_path") or uri.query.get( + "http_path" ) query_dict = dict(uri.query) diff --git a/tests/unit_tests/db_engine_specs/test_databricks.py b/tests/unit_tests/db_engine_specs/test_databricks.py index 4e95b939153b..d02490491cc1 100644 --- a/tests/unit_tests/db_engine_specs/test_databricks.py +++ b/tests/unit_tests/db_engine_specs/test_databricks.py @@ -134,19 +134,22 @@ def test_databricks_native_adjust_engine_params() -> None: assert args_default["schema"] == "default" assert args_default["http_path"] == "/sql/1.0/warehouses/test" - # Test catalog override without schema: URI-database schema fallback is omitted - # and the catalog's default schema ("default") is resolved instead - url_cat_override, args_cat_override = ( - DatabricksNativeEngineSpec.adjust_engine_params( - make_url("databricks+connector://token:test-token@localhost:443/finance"), - connect_args={"http_path": "/sql/1.0/warehouses/test"}, - catalog="main", - ) + # Test catalog on a connection with no configured catalog (dataset creation + # with the server default catalog from `get_default_catalog`): the URI + # database is kept as schema, so reflection targets `main.finance.` + url_no_cat, args_no_cat = DatabricksNativeEngineSpec.adjust_engine_params( + make_url( + "databricks+connector://token:test-token@localhost:443/finance" + "?http_path=/sql/1.0/warehouses/test" + ), + connect_args={}, + catalog="main", + schema=None, ) - assert url_cat_override.query["catalog"] == "main" - assert url_cat_override.query["schema"] == "default" - assert args_cat_override["catalog"] == "main" - assert args_cat_override["schema"] == "default" + assert url_no_cat.query["catalog"] == "main" + assert url_no_cat.query["schema"] == "finance" + assert args_no_cat["catalog"] == "main" + assert args_no_cat["schema"] == "finance" # Test catalog override when URI already contains ?catalog=main&schema=finance # Switching to a different catalog resolves the catalog's default schema @@ -167,23 +170,33 @@ def test_databricks_native_adjust_engine_params() -> None: assert args_query_cat_override["schema"] == "default" assert args_query_cat_override["http_path"] == "/sql/1.0/warehouses/test" - # Test catalog override matching existing catalog in URI with schema=None - # (dataset creation scenario): preserves the URI-defined schema ("default") - url_cat_match, args_cat_match = ( - DatabricksNativeEngineSpec.adjust_engine_params( - make_url( - "databricks+connector://token:test-token@localhost:443/default" - "?catalog=main&http_path=/sql/1.0/warehouses/test" - ), - connect_args={"http_path": "/sql/1.0/warehouses/test"}, - catalog="main", - schema=None, - ) + # Test catalog matching the catalog configured in the URI with schema=None + # (dataset creation scenario): preserves the URI-defined schema ("finance") + url_cat_match, args_cat_match = DatabricksNativeEngineSpec.adjust_engine_params( + make_url( + "databricks+connector://token:test-token@localhost:443/finance" + "?catalog=main&http_path=/sql/1.0/warehouses/test" + ), + connect_args={}, + catalog="main", + schema=None, ) assert url_cat_match.query["catalog"] == "main" - assert url_cat_match.query["schema"] == "default" + assert url_cat_match.query["schema"] == "finance" assert args_cat_match["catalog"] == "main" - assert args_cat_match["schema"] == "default" + assert args_cat_match["schema"] == "finance" + + # Test switching away from a catalog configured in connect_args without a + # schema: the saved schema belongs to the old catalog, so "default" is used + url_cat_switch, args_cat_switch = DatabricksNativeEngineSpec.adjust_engine_params( + make_url("databricks+connector://token:test-token@localhost:443/finance"), + connect_args={"catalog": "main"}, + catalog="other", + ) + assert url_cat_switch.query["catalog"] == "other" + assert url_cat_switch.query["schema"] == "default" + assert args_cat_switch["catalog"] == "other" + assert args_cat_switch["schema"] == "default" # Test explicit catalog and schema with http_path in connect_args adjusted_url, adjusted_args = DatabricksNativeEngineSpec.adjust_engine_params( @@ -200,15 +213,13 @@ def test_databricks_native_adjust_engine_params() -> None: assert adjusted_args["http_path"] == "/sql/1.0/warehouses/test" # Test reconciling conflicting schema in connect_args with passed schema - reconciled_url, reconciled_args = ( - DatabricksNativeEngineSpec.adjust_engine_params( - make_url(uri_str), - connect_args={ - "catalog": "connect_arg_cat", - "schema": "stale_schema", - }, - schema="reconciled_schema", - ) + reconciled_url, reconciled_args = DatabricksNativeEngineSpec.adjust_engine_params( + make_url(uri_str), + connect_args={ + "catalog": "connect_arg_cat", + "schema": "stale_schema", + }, + schema="reconciled_schema", ) assert reconciled_url.query["catalog"] == "connect_arg_cat" assert reconciled_url.query["schema"] == "reconciled_schema" diff --git a/tests/unit_tests/db_engine_specs/test_lint_metadata.py b/tests/unit_tests/db_engine_specs/test_lint_metadata.py index 1361854bd4de..aca9495d7eaa 100644 --- a/tests/unit_tests/db_engine_specs/test_lint_metadata.py +++ b/tests/unit_tests/db_engine_specs/test_lint_metadata.py @@ -15,12 +15,12 @@ # specific language governing permissions and limitations # under the License. +import io from pathlib import Path from textwrap import dedent import pytest -import io from superset.db_engine_specs.lint_metadata import ( get_all_engine_specs_ast, main, @@ -161,6 +161,7 @@ class ComplexValuesBaseEngineSpec(BaseEngineSpec): "negative_float": -3.14, } + def test_main_ascii_stdout_utf8_reconfigure( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, @@ -241,4 +242,3 @@ class AsciiMarkdownBaseEngineSpec(BaseEngineSpec): decoded = buffer.getvalue().decode("utf-8") assert "ASCII MD Engine" in decoded - From d49752d642c84b0ccb89134f0d7ffde2a5a12127 Mon Sep 17 00:00:00 2001 From: Francesco Castaldi Date: Thu, 15 Oct 2026 15:29:48 +0200 Subject: [PATCH 57/62] fix(db_engine_specs): address review follow-ups for databricks and lint_metadata (#43695) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review feedback from @sadpandajoe on PR #43695: 1. In tests/unit_tests/db_engine_specs/test_databricks.py: - Add `"encryption": False` to `build_sqlalchemy_uri` parameter dicts, satisfying `DatabricksBaseParametersType` and clearing mypy error. - Add unit test case verifying `uri.database or "default"` fallback in `adjust_engine_params` when URI contains no database or schema. 2. In tests/unit_tests/db_engine_specs/test_lint_metadata.py: - Populate all required fields (`description`, `categories`, `pypi_packages`, `connection_string`) in `test_main_ascii_stdout_markdown_report` so the markdown report outputs `✓` for the missing required column, and assert it survives UTF-8 decode on ASCII stdout. 3. In superset/db_engine_specs/databricks.py: - Fold `effective_catalog = catalog or original_catalog` into `if effective_catalog := catalog or original_catalog:` to align with `auto-walrus` and `ruff-format` pre-commit hooks. Refs: apache/superset#43695 --- superset/db_engine_specs/databricks.py | 3 +-- .../db_engine_specs/test_databricks.py | 19 +++++++++++++++++++ .../db_engine_specs/test_lint_metadata.py | 4 ++++ 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/superset/db_engine_specs/databricks.py b/superset/db_engine_specs/databricks.py index 1d91ac22909a..411848283b88 100644 --- a/superset/db_engine_specs/databricks.py +++ b/superset/db_engine_specs/databricks.py @@ -1042,7 +1042,6 @@ def adjust_engine_params( and catalog != original_catalog ) - effective_catalog = catalog or original_catalog if schema: effective_schema = schema elif not is_catalog_changing: @@ -1063,7 +1062,7 @@ def adjust_engine_params( ) query_dict = dict(uri.query) - if effective_catalog: + if effective_catalog := catalog or original_catalog: query_dict["catalog"] = effective_catalog adjusted_connect_args["catalog"] = effective_catalog else: diff --git a/tests/unit_tests/db_engine_specs/test_databricks.py b/tests/unit_tests/db_engine_specs/test_databricks.py index d02490491cc1..9a5a2751c3dc 100644 --- a/tests/unit_tests/db_engine_specs/test_databricks.py +++ b/tests/unit_tests/db_engine_specs/test_databricks.py @@ -81,6 +81,7 @@ def test_dialect_supports_installed_sqlalchemy(mocker: MockerFixture) -> None: "host": "localhost", "port": 443, "database": "default", + "encryption": False, } ) native_url, native_connect_args = DatabricksNativeEngineSpec.adjust_engine_params( @@ -117,6 +118,7 @@ def test_databricks_native_adjust_engine_params() -> None: "host": "localhost", "port": 443, "database": "default", + "encryption": False, } ) assert uri_str == "databricks+connector://token:test-token@localhost:443/default" @@ -134,6 +136,23 @@ def test_databricks_native_adjust_engine_params() -> None: assert args_default["schema"] == "default" assert args_default["http_path"] == "/sql/1.0/warehouses/test" + # Test fallback to "default" schema when connection has no database or schema + url_no_db_schema, args_no_db_schema = ( + DatabricksNativeEngineSpec.adjust_engine_params( + make_url( + "databricks+connector://token:test-token@localhost:443/" + "?catalog=main&http_path=/sql/1.0/warehouses/test" + ), + connect_args={}, + ) + ) + assert url_no_db_schema.query["catalog"] == "main" + assert url_no_db_schema.query["schema"] == "default" + assert url_no_db_schema.query["http_path"] == "/sql/1.0/warehouses/test" + assert args_no_db_schema["catalog"] == "main" + assert args_no_db_schema["schema"] == "default" + assert args_no_db_schema["http_path"] == "/sql/1.0/warehouses/test" + # Test catalog on a connection with no configured catalog (dataset creation # with the server default catalog from `get_default_catalog`): the URI # database is kept as schema, so reflection targets `main.finance.
` diff --git a/tests/unit_tests/db_engine_specs/test_lint_metadata.py b/tests/unit_tests/db_engine_specs/test_lint_metadata.py index aca9495d7eaa..b848b7b8581c 100644 --- a/tests/unit_tests/db_engine_specs/test_lint_metadata.py +++ b/tests/unit_tests/db_engine_specs/test_lint_metadata.py @@ -223,6 +223,9 @@ class AsciiMarkdownBaseEngineSpec(BaseEngineSpec): engine_name: str = "ASCII MD Engine" metadata: dict[str, Any] = { "description": "An engine spec for ascii markdown testing", + "categories": ["DatabaseCategory.OTHER"], + "pypi_packages": ["ascii_pkg"], + "connection_string": "ascii://localhost", } """, ) @@ -242,3 +245,4 @@ class AsciiMarkdownBaseEngineSpec(BaseEngineSpec): decoded = buffer.getvalue().decode("utf-8") assert "ASCII MD Engine" in decoded + assert "✓" in decoded From ec4dc717c623c8ed38feb199ee333b78c57adbd3 Mon Sep 17 00:00:00 2001 From: Francesco Castaldi Date: Wed, 28 Oct 2026 13:58:45 +0100 Subject: [PATCH 58/62] fix(databricks): point legacy tracking url to crflynn repo and verify dialect resolution (#43695) - Update tracking_url for databricks-dbapi incompatibility to https://github.com/crflynn/databricks-dbapi in DatabricksODBCEngineSpec and DatabricksHiveEngineSpec. - Verify registry.load("databricks.connector") resolves to DatabricksDialect in test_databricks_connector_dialect_registration. Signed-off-by: Francesco Castaldi --- superset/db_engine_specs/databricks.py | 4 ++-- .../db_engine_specs/test_databricks.py | 16 +++++++++++++++- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/superset/db_engine_specs/databricks.py b/superset/db_engine_specs/databricks.py index 3a74f60a54d7..1bcbbd2e5cb4 100644 --- a/superset/db_engine_specs/databricks.py +++ b/superset/db_engine_specs/databricks.py @@ -1228,7 +1228,7 @@ class DatabricksODBCEngineSpec(DatabricksBaseEngineSpec): "support is planned for the legacy driver. Use the Databricks " "Python Connector (databricks-sql-connector) instead." ), - "tracking_url": "https://github.com/databricks/databricks-dbapi", + "tracking_url": "https://github.com/crflynn/databricks-dbapi", } ], } @@ -1283,7 +1283,7 @@ class DatabricksHiveEngineSpec(SparkSqlBooleanEqualityMixin, HiveEngineSpec): "support is planned for the legacy driver. Use the Databricks " "Python Connector (databricks-sql-connector) instead." ), - "tracking_url": "https://github.com/databricks/databricks-dbapi", + "tracking_url": "https://github.com/crflynn/databricks-dbapi", } ], } diff --git a/tests/unit_tests/db_engine_specs/test_databricks.py b/tests/unit_tests/db_engine_specs/test_databricks.py index 68aa854d549d..e4fd461fa919 100644 --- a/tests/unit_tests/db_engine_specs/test_databricks.py +++ b/tests/unit_tests/db_engine_specs/test_databricks.py @@ -271,11 +271,25 @@ def test_databricks_native_adjust_engine_params() -> None: def test_databricks_connector_dialect_registration() -> None: - """Test that databricks.connector dialect is registered in SQLAlchemy registry.""" + """Test that databricks.connector dialect is registered and resolves to DatabricksDialect.""" + import sys + from unittest import mock from sqlalchemy.dialects import registry assert "databricks.connector" in registry.impls + # Verify that registry.load accurately resolves to the DatabricksDialect class + # even when the optional databricks-sqlalchemy extra is not installed in the test environment + mock_dialect_cls = type("DatabricksDialect", (), {}) + mock_pkg = mock.MagicMock() + mock_mod = mock.MagicMock(DatabricksDialect=mock_dialect_cls) + mock_pkg.sqlalchemy = mock_mod + with mock.patch.dict( + sys.modules, + {"databricks": mock_pkg, "databricks.sqlalchemy": mock_mod}, + ): + assert registry.load("databricks.connector") is mock_dialect_cls + def test_get_parameters_from_uri() -> None: """ From 9e65e1d7d625f6cfa6b88fdb320b399ed56f59b3 Mon Sep 17 00:00:00 2001 From: Francesco Castaldi Date: Wed, 28 Oct 2026 15:20:41 +0100 Subject: [PATCH 59/62] test(db_engine_specs): exercise connect_args schema retention and format databricks test to 88 columns Signed-off-by: Francesco Castaldi --- .../db_engine_specs/test_databricks.py | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/tests/unit_tests/db_engine_specs/test_databricks.py b/tests/unit_tests/db_engine_specs/test_databricks.py index e4fd461fa919..98304923260b 100644 --- a/tests/unit_tests/db_engine_specs/test_databricks.py +++ b/tests/unit_tests/db_engine_specs/test_databricks.py @@ -269,17 +269,32 @@ def test_databricks_native_adjust_engine_params() -> None: assert adjusted_args_fallback["catalog"] == "connect_arg_cat" assert adjusted_args_fallback["schema"] == "default" + # Test preserving connect_args schema over URI query schema when no override is given + legacy_url = ( + "databricks+connector://token:abc@my_hostname:1234/legacy?schema=url_schema" + ) + adj_url_saved, adj_args_saved = DatabricksNativeEngineSpec.adjust_engine_params( + make_url(legacy_url), + connect_args={"schema": "finance"}, + ) + assert adj_url_saved.query["schema"] == "finance" + assert adj_args_saved["schema"] == "finance" + def test_databricks_connector_dialect_registration() -> None: - """Test that databricks.connector dialect is registered and resolves to DatabricksDialect.""" + """ + Test that databricks.connector dialect is registered and resolves + to DatabricksDialect. + """ import sys from unittest import mock + from sqlalchemy.dialects import registry assert "databricks.connector" in registry.impls - # Verify that registry.load accurately resolves to the DatabricksDialect class - # even when the optional databricks-sqlalchemy extra is not installed in the test environment + # Verify that registry.load accurately resolves to DatabricksDialect + # even when databricks-sqlalchemy is not installed in test environment mock_dialect_cls = type("DatabricksDialect", (), {}) mock_pkg = mock.MagicMock() mock_mod = mock.MagicMock(DatabricksDialect=mock_dialect_cls) From d51192071fd553c18736b36eae91ca0375ba5303 Mon Sep 17 00:00:00 2001 From: Francesco Castaldi Date: Wed, 28 Oct 2026 17:06:01 +0100 Subject: [PATCH 60/62] style(databricks): break line 272 comment to satisfy 88-char ruff limit Signed-off-by: Francesco Castaldi --- tests/unit_tests/db_engine_specs/test_databricks.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/unit_tests/db_engine_specs/test_databricks.py b/tests/unit_tests/db_engine_specs/test_databricks.py index 98304923260b..a85ae1338261 100644 --- a/tests/unit_tests/db_engine_specs/test_databricks.py +++ b/tests/unit_tests/db_engine_specs/test_databricks.py @@ -269,7 +269,8 @@ def test_databricks_native_adjust_engine_params() -> None: assert adjusted_args_fallback["catalog"] == "connect_arg_cat" assert adjusted_args_fallback["schema"] == "default" - # Test preserving connect_args schema over URI query schema when no override is given + # Test preserving connect_args schema over URI query schema when no override + # is given legacy_url = ( "databricks+connector://token:abc@my_hostname:1234/legacy?schema=url_schema" ) From 3f01bf03c0d76669010aff3bad45380a380ac171 Mon Sep 17 00:00:00 2001 From: Francesco Castaldi Date: Fri, 30 Oct 2026 11:09:54 +0100 Subject: [PATCH 61/62] fix(db_engine_specs): resolve no-catalog reflection and align schema fallback (#43695) - Resolve dialect catalog dynamically during get_columns and get_table_names when no catalog is configured on connection handshake. - Distinguish server default catalog from genuine catalog switches when original catalog is unconfigured. - Align ClickHouse Connect doc URLs to official live documentation guide. - Assert ast.Tuple evaluation in test_lint_metadata. - Add test coverage for catalog switch and no-catalog reflection. Signed-off-by: Francesco Castaldi --- superset/db_engine_specs/clickhouse.py | 10 +- superset/db_engine_specs/databricks.py | 111 ++++++++++++++++-- .../db_engine_specs/test_databricks.py | 90 ++++++++++++++ .../db_engine_specs/test_lint_metadata.py | 6 +- 4 files changed, 204 insertions(+), 13 deletions(-) diff --git a/superset/db_engine_specs/clickhouse.py b/superset/db_engine_specs/clickhouse.py index d9afecacfdfc..7b2df29ad4df 100644 --- a/superset/db_engine_specs/clickhouse.py +++ b/superset/db_engine_specs/clickhouse.py @@ -426,7 +426,10 @@ class ClickHouseConnectEngineSpec(BasicParametersMixin, ClickHouseEngineSpec): "logo": "clickhouse.svg", "logo_dark": "clickhouse-dark.svg", "homepage_url": "https://clickhouse.com/", - "sqlalchemy_docs_url": "https://clickhouse.com/docs/en/integrations/sql-clients/superset", + "sqlalchemy_docs_url": ( + "https://clickhouse.com/docs/integrations/connectors/" + "data-visualization/superset-and-clickhouse" + ), "categories": [ DatabaseCategory.ANALYTICAL_DATABASES, DatabaseCategory.OPEN_SOURCE, @@ -496,7 +499,10 @@ class ClickHouseConnectEngineSpec(BasicParametersMixin, ClickHouseEngineSpec): 'echo "clickhouse-connect>=1.8.0,<2.0" >> ./docker/requirements-local.txt\n' "# Restart the containers after adding the driver." ), - "docs_url": "https://clickhouse.com/docs/integrations/superset", + "docs_url": ( + "https://clickhouse.com/docs/integrations/connectors/" + "data-visualization/superset-and-clickhouse" + ), "notes": ( "The SQL playground at sql.clickhouse.com uses the database endpoint " "sql-clickhouse.clickhouse.com; the example connects to its uk sample " diff --git a/superset/db_engine_specs/databricks.py b/superset/db_engine_specs/databricks.py index 1bcbbd2e5cb4..eaacca0a1396 100644 --- a/superset/db_engine_specs/databricks.py +++ b/superset/db_engine_specs/databricks.py @@ -52,10 +52,12 @@ if TYPE_CHECKING: from superset.models.core import Database + from superset.sql.parse import Table from superset.superset_typing import ( OAuth2ClientConfig, OAuth2State, OAuth2TokenResponse, + ResultSetColumnType, ) @@ -1047,16 +1049,26 @@ def adjust_engine_params( original_catalog = adjusted_connect_args.get("catalog") or uri.query.get( "catalog" ) - # Only a switch away from an explicitly configured catalog invalidates - # the saved schema. When the connection has no catalog configured, the - # caller's catalog is the server default (see `get_default_catalog`), so - # the schema from the URI still applies. - is_catalog_changing = ( - catalog is not None - and original_catalog is not None - and catalog != original_catalog + server_default_catalog = ( + adjusted_connect_args.get("default_catalog") + or uri.query.get("default_catalog") + or getattr(cls, "default_catalog", None) + or "main" ) + # Distinguish the workspace default catalog from a genuine catalog switch: + # If an explicit catalog is configured on the connection, any change from it + # invalidates the saved schema. If no catalog is configured on the connection, + # caller's catalog matches the server default when it equals + # `server_default_catalog` (retaining the saved schema), whereas any other + # catalog is a genuine switch to a different catalog (resetting schema). + if original_catalog is not None: + is_catalog_changing = catalog is not None and catalog != original_catalog + else: + is_catalog_changing = ( + catalog is not None and catalog != server_default_catalog + ) + if schema: effective_schema = schema elif not is_catalog_changing: @@ -1151,7 +1163,9 @@ def get_default_catalog(cls, database: Database) -> str: single catalog is returned. """ connect_args = cls.get_extra_params(database)["engine_params"]["connect_args"] - if default_catalog := connect_args.get("catalog"): + if default_catalog := ( + connect_args.get("catalog") or database.url_object.query.get("catalog") + ): return default_catalog with database.get_sqla_engine() as engine: @@ -1164,6 +1178,85 @@ def get_default_catalog(cls, database: Database) -> str: return conn.execute(text("SELECT current_catalog()")).scalar() + @classmethod + def get_columns( + cls, + inspector: Inspector, + table: Table, + options: dict[str, Any] | None = None, + ) -> list[ResultSetColumnType]: + """ + Get all columns from a given schema and table. + + Resolves the server's actual default catalog dynamically if no catalog + is set on the connection/dialect, preventing reflection from generating + invalid queries like `DESCRIBE TABLE EXTENDED `None`.`default`.`orders`` + while leaving the connection handshake unforced. + """ + dialect = getattr(inspector, "bind", None) and inspector.bind.dialect + if dialect is None and hasattr(inspector, "engine"): + dialect = inspector.engine.dialect + + if dialect is not None and getattr(dialect, "catalog", None) is None: + engine = getattr(inspector, "bind", None) or getattr( + inspector, "engine", None + ) + if engine is not None: + try: + with engine.connect() as conn: + catalogs = { + cat for (cat,) in conn.execute(text("SHOW CATALOGS")) + } + if len(catalogs) == 1: + dialect.catalog = catalogs.pop() + else: + dialect.catalog = conn.execute( + text("SELECT current_catalog()") + ).scalar() + except Exception: # pylint: disable=broad-except + logger.warning( + "Unable to resolve default catalog from server " + "during reflection", + exc_info=True, + ) + + return super().get_columns(inspector, table, options) + + @classmethod + def get_table_names( + cls, + database: Database, + inspector: Inspector, + schema: str | None, + ) -> set[str]: + dialect = getattr(inspector, "bind", None) and inspector.bind.dialect + if dialect is None and hasattr(inspector, "engine"): + dialect = inspector.engine.dialect + + if dialect is not None and getattr(dialect, "catalog", None) is None: + engine = getattr(inspector, "bind", None) or getattr( + inspector, "engine", None + ) + if engine is not None: + try: + with engine.connect() as conn: + catalogs = { + cat for (cat,) in conn.execute(text("SHOW CATALOGS")) + } + if len(catalogs) == 1: + dialect.catalog = catalogs.pop() + else: + dialect.catalog = conn.execute( + text("SELECT current_catalog()") + ).scalar() + except Exception: # pylint: disable=broad-except + logger.warning( + "Unable to resolve default catalog from server for table names", + exc_info=True, + ) + + return super().get_table_names(database, inspector, schema) + @classmethod def get_prequeries( cls, diff --git a/tests/unit_tests/db_engine_specs/test_databricks.py b/tests/unit_tests/db_engine_specs/test_databricks.py index a85ae1338261..cbd9c0d076c3 100644 --- a/tests/unit_tests/db_engine_specs/test_databricks.py +++ b/tests/unit_tests/db_engine_specs/test_databricks.py @@ -182,6 +182,24 @@ def test_databricks_native_adjust_engine_params() -> None: assert args_no_cat["catalog"] == "main" assert args_no_cat["schema"] == "finance" + # Test switching away from default catalog on a connection with no configured + # catalog: switching to "other" resolves the catalog's default schema ("default") + url_no_cat_switch, args_no_cat_switch = ( + DatabricksNativeEngineSpec.adjust_engine_params( + make_url( + "databricks+connector://token:test-token@localhost:443/finance" + "?http_path=/sql/1.0/warehouses/test" + ), + connect_args={}, + catalog="other", + schema=None, + ) + ) + assert url_no_cat_switch.query["catalog"] == "other" + assert url_no_cat_switch.query["schema"] == "default" + assert args_no_cat_switch["catalog"] == "other" + assert args_no_cat_switch["schema"] == "default" + # Test catalog override when URI already contains ?catalog=main&schema=finance # Switching to a different catalog resolves the catalog's default schema url_query_cat_override, args_query_cat_override = ( @@ -281,6 +299,78 @@ def test_databricks_native_adjust_engine_params() -> None: assert adj_url_saved.query["schema"] == "finance" assert adj_args_saved["schema"] == "finance" + # Test that uri.query schema fallback applies when connect_args schema is empty + url_query_schema, args_query_schema = ( + DatabricksNativeEngineSpec.adjust_engine_params( + make_url( + "databricks+connector://token:abc@my_hostname:1234/legacy" + "?catalog=main&schema=finance" + ), + connect_args={}, + ) + ) + assert url_query_schema.query["schema"] == "finance" + assert args_query_schema["schema"] == "finance" + + +def test_databricks_native_get_default_catalog_no_catalog_path() -> None: + """ + Test get_default_catalog on a connection with no configured catalog. + Verifies that it resolves the server's default catalog via SHOW CATALOGS + without forcing one onto the connection handshake. + """ + from unittest.mock import MagicMock + + from superset.db_engine_specs.databricks import DatabricksNativeEngineSpec + + database = MagicMock() + database.url_object = make_url( + "databricks+connector://token:test-token@localhost:443/default" + "?http_path=/sql/1.0/warehouses/test" + ) + database.extra = "{}" + + mock_engine = MagicMock() + mock_conn = MagicMock() + mock_engine.connect.return_value.__enter__.return_value = mock_conn + mock_conn.execute.return_value = [("spark_catalog",)] + database.get_sqla_engine.return_value.__enter__.return_value = mock_engine + + default_cat = DatabricksNativeEngineSpec.get_default_catalog(database) + assert default_cat == "spark_catalog" + database.get_sqla_engine.assert_called_once_with() + + +def test_databricks_native_get_columns_resolves_no_catalog_reflection() -> None: + """ + Test get_columns on a connection with no configured catalog. + Verifies that it dynamically resolves the server's default catalog + onto the dialect, preventing `DESCRIBE TABLE EXTENDED `None`...` reflection. + """ + from unittest.mock import MagicMock + + from superset.db_engine_specs.databricks import DatabricksNativeEngineSpec + from superset.sql.parse import Table + + mock_inspector = MagicMock() + mock_dialect = MagicMock() + mock_dialect.catalog = None + mock_engine = MagicMock() + mock_conn = MagicMock() + mock_engine.connect.return_value.__enter__.return_value = mock_conn + mock_engine.dialect = mock_dialect + mock_inspector.bind = mock_engine + mock_inspector.engine = mock_engine + mock_conn.execute.return_value = [("spark_catalog",)] + + mock_inspector.get_columns.return_value = [{"name": "id", "type": MagicMock()}] + + table = Table("orders", "default") + cols = DatabricksNativeEngineSpec.get_columns(mock_inspector, table) + assert mock_dialect.catalog == "spark_catalog" + assert len(cols) == 1 + assert cols[0]["column_name"] == "id" + def test_databricks_connector_dialect_registration() -> None: """ diff --git a/tests/unit_tests/db_engine_specs/test_lint_metadata.py b/tests/unit_tests/db_engine_specs/test_lint_metadata.py index b848b7b8581c..c34407f402a2 100644 --- a/tests/unit_tests/db_engine_specs/test_lint_metadata.py +++ b/tests/unit_tests/db_engine_specs/test_lint_metadata.py @@ -124,8 +124,8 @@ def test_get_all_engine_specs_ast_set_and_unary_minus( monkeypatch: pytest.MonkeyPatch, ) -> None: """ - Regression: ast.Set and unary-minus in metadata values are correctly - evaluated and preserved in the AST scan. + Regression: ast.Set, ast.Tuple, and unary-minus in metadata values are + correctly evaluated and preserved in the AST scan. """ _write_spec( tmp_path, @@ -139,6 +139,7 @@ class ComplexValuesBaseEngineSpec(BaseEngineSpec): engine_name: str = "Complex Engine" metadata: dict[str, Any] = { "tags": {"analytics", "warehouse"}, + "pair": ("value", -1), "default_port": -1, "negative_float": -3.14, } @@ -157,6 +158,7 @@ class ComplexValuesBaseEngineSpec(BaseEngineSpec): assert spec["engine_name"] == "Complex Engine" assert spec["metadata"] == { "tags": {"analytics", "warehouse"}, + "pair": ("value", -1), "default_port": -1, "negative_float": -3.14, } From 15c8bb63fa97893d3a89d91b2de9b0b9c89f672e Mon Sep 17 00:00:00 2001 From: FrancescoCastaldi Date: Sun, 1 Nov 2026 02:01:26 +0100 Subject: [PATCH 62/62] fix(kusto): align KustoKqlEngineSpec known_incompatibilities since date with master Refs: apache/superset#43695 Signed-off-by: Francesco Castaldi --- superset/db_engine_specs/kusto.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/superset/db_engine_specs/kusto.py b/superset/db_engine_specs/kusto.py index a7255560b759..16068e38dbe3 100644 --- a/superset/db_engine_specs/kusto.py +++ b/superset/db_engine_specs/kusto.py @@ -251,7 +251,7 @@ class KustoKqlEngineSpec(BaseEngineSpec): # pylint: disable=abstract-method "sqlalchemy==1.4.*; no SQLAlchemy 2.0 work has started." ), "tracking_url": "https://github.com/dodopizza/sqlalchemy-kusto", - "since": "2026-07-28", + "since": "2026-10-27", } ], }