diff --git a/superset/db_engine_specs/arc.py b/superset/db_engine_specs/arc.py index c99402332a49..f167d9ce9da7 100644 --- a/superset/db_engine_specs/arc.py +++ b/superset/db_engine_specs/arc.py @@ -45,6 +45,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": 8000, "parameters": { "api_key": "Arc API key", "hostname": "Arc hostname", diff --git a/superset/db_engine_specs/aurora.py b/superset/db_engine_specs/aurora.py index d4c561afcd9d..9b555fba04d2 100644 --- a/superset/db_engine_specs/aurora.py +++ b/superset/db_engine_specs/aurora.py @@ -14,17 +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 DatabaseCategory +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" @@ -56,15 +55,21 @@ class AuroraMySQLDataAPI(MySQLEngineSpec): "secret_arn={secret_arn}®ion_name={region_name}" ), "default_port": 3306, + "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. - """ + """Amazon Aurora PostgreSQL via the Data API.""" engine = "postgresql" default_driver = "auroradataapi" @@ -96,6 +101,16 @@ class AuroraPostgresDataAPI(PostgresEngineSpec): "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", + "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, } @@ -125,8 +140,16 @@ class AuroraMySQLEngineSpec(MySQLEngineSpec): DatabaseCategory.HOSTED_OPEN_SOURCE, ], "pypi_packages": ["mysqlclient"], - "connection_string": "mysql://{user}:{password}@{host}:{port}/{database}", + "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/", } @@ -156,6 +179,14 @@ class AuroraPostgresEngineSpec(PostgresEngineSpec): DatabaseCategory.HOSTED_OPEN_SOURCE, ], "pypi_packages": ["psycopg2"], - "connection_string": "postgresql://{user}:{password}@{host}:{port}/{database}", + "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/", } diff --git a/superset/db_engine_specs/clickhouse.py b/superset/db_engine_specs/clickhouse.py index 4b51bbdaf8ce..ce836a2b3934 100644 --- a/superset/db_engine_specs/clickhouse.py +++ b/superset/db_engine_specs/clickhouse.py @@ -336,6 +336,8 @@ class ClickHouseEngineSpec(ClickHouseBaseEngineSpec): "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 @@ -480,6 +482,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/integrations/connectors/" + "data-visualization/superset-and-clickhouse" + ), "categories": [ DatabaseCategory.ANALYTICAL_DATABASES, DatabaseCategory.OPEN_SOURCE, @@ -549,7 +555,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 d15797eac698..e0561d3c2f42 100644 --- a/superset/db_engine_specs/databricks.py +++ b/superset/db_engine_specs/databricks.py @@ -30,6 +30,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 @@ -51,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, ) @@ -64,6 +67,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 @@ -818,7 +828,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}" ), @@ -962,7 +972,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}" ), @@ -1030,6 +1040,81 @@ 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]]: + adjusted_connect_args = connect_args.copy() + + original_catalog = adjusted_connect_args.get("catalog") or uri.query.get( + "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: + effective_schema = ( + adjusted_connect_args.get("schema") + or uri.query.get("schema") + or uri.database + or "default" + ) + else: + # 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" + ) + + query_dict = dict(uri.query) + if effective_catalog := catalog or original_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_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_dict["http_path"] = effective_http_path + adjusted_connect_args["http_path"] = effective_http_path + + uri = uri.set(query=query_dict) + + return uri, adjusted_connect_args + @classmethod def get_parameters_from_uri( # type: ignore cls, uri: str, *_, **__ @@ -1082,7 +1167,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: @@ -1095,6 +1182,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, @@ -1139,7 +1305,7 @@ class DatabricksODBCEngineSpec(DatabricksBaseEngineSpec): DatabaseCategory.ANALYTICAL_DATABASES, DatabaseCategory.PROPRIETARY, ], - "pypi_packages": ["pyodbc"], + "pypi_packages": ["databricks-dbapi[odbc]", "pyodbc"], "connection_string": ( "databricks+pyodbc://token:{access_token}@{host}:{port}/{database}?http_path={http_path}" ), @@ -1151,6 +1317,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/crflynn/databricks-dbapi", + } + ], } # Note: Primary metadata is in DatabricksPythonConnectorEngineSpec which @@ -1183,7 +1360,7 @@ class DatabricksHiveEngineSpec(SparkSqlBooleanEqualityMixin, HiveEngineSpec): DatabaseCategory.ANALYTICAL_DATABASES, DatabaseCategory.HOSTED_OPEN_SOURCE, ], - "pypi_packages": ["pyhive"], + "pypi_packages": ["databricks-dbapi[hive]", "pyhive", "thrift"], "connection_string": ( "databricks+pyhive://token:{access_token}@{host}:{port}/{database}?http_path={http_path}" ), @@ -1195,6 +1372,17 @@ class DatabricksHiveEngineSpec(SparkSqlBooleanEqualityMixin, 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/crflynn/databricks-dbapi", + } + ], } # Note: Primary metadata is in DatabricksPythonConnectorEngineSpec which diff --git a/superset/db_engine_specs/ibmi.py b/superset/db_engine_specs/ibmi.py index 50775a7a5469..ccd636dd0954 100644 --- a/superset/db_engine_specs/ibmi.py +++ b/superset/db_engine_specs/ibmi.py @@ -21,11 +21,7 @@ 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" @@ -48,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", diff --git a/superset/db_engine_specs/kusto.py b/superset/db_engine_specs/kusto.py index de94c77fe061..16068e38dbe3 100644 --- a/superset/db_engine_specs/kusto.py +++ b/superset/db_engine_specs/kusto.py @@ -209,24 +209,16 @@ 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 - allows_joins = True - allows_subqueries = True - allows_sql_comments = False - run_multiple_statements_as_one = True metadata = { "description": ( "Azure Data Explorer (Kusto) using native Kusto Query Language (KQL) " - "for high-performance log and telemetry analytics." + "for fast, exploratory telemetry and log analytics." ), "logo": "kusto.png", "homepage_url": "https://azure.microsoft.com/en-us/products/data-explorer/", @@ -249,7 +241,24 @@ class KustoKqlEngineSpec(BaseEngineSpec): # pylint: disable=abstract-method "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", + "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-10-27", + } + ], } + allows_joins = True + allows_subqueries = True + allows_sql_comments = False + run_multiple_statements_as_one = True _time_grain_expressions = { None: "{col}", diff --git a/superset/db_engine_specs/lint_metadata.py b/superset/db_engine_specs/lint_metadata.py index fff0146158f0..2f5804b98c79 100644 --- a/superset/db_engine_specs/lint_metadata.py +++ b/superset/db_engine_specs/lint_metadata.py @@ -48,11 +48,16 @@ from __future__ import annotations 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", @@ -103,7 +108,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"} @@ -227,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"): @@ -240,7 +247,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 +279,22 @@ 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 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 # Skip true base classes (no engine or empty engine attribute) if node.name.endswith("BaseEngineSpec") and not has_non_empty_engine: @@ -292,12 +311,25 @@ 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 +369,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 + elif hasattr(ast, "Str") and isinstance(node, ast.Str): # Python <3.8 compat return node.s - elif isinstance(node, ast.Num): # Python 3.7 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] + 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 +399,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 +643,18 @@ 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"): + with contextlib.suppress(Exception): + sys.stdout.reconfigure(encoding="utf-8") + if hasattr(sys.stderr, "reconfigure"): + with contextlib.suppress(Exception): + sys.stderr.reconfigure(encoding="utf-8") + parser = argparse.ArgumentParser( description="Lint DB engine spec metadata for completeness" ) @@ -673,7 +718,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: diff --git a/superset/db_engine_specs/ocient.py b/superset/db_engine_specs/ocient.py index 3f5e0e0bee36..418f6557b83f 100644 --- a/superset/db_engine_specs/ocient.py +++ b/superset/db_engine_specs/ocient.py @@ -238,13 +238,16 @@ class OcientEngineSpec(BaseEngineSpec): metadata = { "description": "Ocient is a hyperscale data analytics database.", + "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]]] = { diff --git a/superset/db_engine_specs/parseable.py b/superset/db_engine_specs/parseable.py index ce816633ee31..9c71ba17d60e 100644 --- a/superset/db_engine_specs/parseable.py +++ b/superset/db_engine_specs/parseable.py @@ -41,16 +41,18 @@ class ParseableEngineSpec(BaseEngineSpec): "Parseable is a distributed log analytics database " "with SQL-like query interface." ), + "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}" + "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+https://admin:admin@demo.parseable.com:443/ingress-nginx" ), }, ], 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..cf216bf585fd --- /dev/null +++ b/tests/unit_tests/db_engine_specs/test_arc.py @@ -0,0 +1,56 @@ +# 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 "arc-superset-arrow" in metadata["pypi_packages"] + assert metadata["default_port"] == 8000 + + +@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 + ) 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..8f7af5c8fca8 --- /dev/null +++ b/tests/unit_tests/db_engine_specs/test_ascend.py @@ -0,0 +1,56 @@ +# 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" 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 + ) diff --git a/tests/unit_tests/db_engine_specs/test_databricks.py b/tests/unit_tests/db_engine_specs/test_databricks.py index af8b50def4f3..cbd9c0d076c3 100644 --- a/tests/unit_tests/db_engine_specs/test_databricks.py +++ b/tests/unit_tests/db_engine_specs/test_databricks.py @@ -86,9 +86,317 @@ def test_dialect_supports_installed_sqlalchemy(mocker: MockerFixture) -> None: assert str(text("SELECT :value").compile(engine)) == "SELECT :value" finally: engine.dispose() + + native_uri_str = DatabricksNativeEngineSpec.build_sqlalchemy_uri( + { + "access_token": "test-token", + "host": "localhost", + "port": 443, + "database": "default", + "encryption": False, + } + ) + native_url, native_connect_args = DatabricksNativeEngineSpec.adjust_engine_params( + make_url(native_uri_str), + connect_args={ + "http_path": "/sql/1.0/warehouses/test", + "catalog": "hive_metastore", + }, + ) + connector_engine = create_engine(native_url, connect_args=native_connect_args) + try: + assert isinstance(connector_engine.dialect, dialect.DatabricksDialect) + _, connector_dialect_args = connector_engine.dialect.create_connect_args( + connector_engine.url + ) + 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 adjust_engine_params maps catalog/schema into the URL and args.""" + uri_str = DatabricksNativeEngineSpec.build_sqlalchemy_uri( + { + "access_token": "test-token", + "host": "localhost", + "port": 443, + "database": "default", + "encryption": False, + } + ) + assert uri_str == "databricks+connector://token:test-token@localhost:443/default" + + # 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 "catalog" not in url_default.query + assert url_default.query["schema"] == "default" + assert url_default.query["http_path"] == "/sql/1.0/warehouses/test" + assert "catalog" not in args_default + 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.` + 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_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 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 = ( + 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 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 args_query_cat_override["schema"] == "default" + assert args_query_cat_override["http_path"] == "/sql/1.0/warehouses/test" + + # 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"] == "finance" + assert args_cat_match["catalog"] == "main" + 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( + 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" + 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, 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" + + # 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" + + # 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: + """ + 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 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) + 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: """ Test that the result from ``get_parameters_from_uri`` is JSON serializable. diff --git a/tests/unit_tests/db_engine_specs/test_exasol.py b/tests/unit_tests/db_engine_specs/test_exasol.py index 9612b11e1573..e81766c3141e 100644 --- a/tests/unit_tests/db_engine_specs/test_exasol.py +++ b/tests/unit_tests/db_engine_specs/test_exasol.py @@ -17,10 +17,46 @@ import pytest from sqlalchemy.exc import DBAPIError, StatementError +from superset.constants import TimeGrain from superset.db_engine_specs.exasol import ExasolEngineSpec from superset.errors import SupersetErrorType +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 + ) + + @pytest.mark.parametrize( "message, expected", [ 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 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 diff --git a/tests/unit_tests/db_engine_specs/test_ibmi.py b/tests/unit_tests/db_engine_specs/test_ibmi.py index e71f96ddf1c8..5c1ef926eaeb 100644 --- a/tests/unit_tests/db_engine_specs/test_ibmi.py +++ b/tests/unit_tests/db_engine_specs/test_ibmi.py @@ -14,22 +14,36 @@ # 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.db2 import Db2EngineSpec +from superset.db_engine_specs.ibmi import IBMiEngineSpec -def test_epoch_to_dttm() -> None: - """ - Test the `epoch_to_dttm` method. - """ - 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 ( - IBMiEngineSpec.epoch_to_dttm().format(col="epoch_dttm") - == "(DAYS(epoch_dttm) - DAYS('1970-01-01')) * 86400" - " + MIDNIGHT_SECONDS(epoch_dttm)" + metadata["connection_string"] + == "ibmi://{username}:{password}@{host}/{database}" + ) + assert "default_port" not in metadata + + +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)" ) @@ -70,7 +84,5 @@ def test_time_grain_expressions(grain: TimeGrain, expected_expression: str) -> N """ Test that time grain expressions generate the expected SQL. """ - from superset.db_engine_specs.ibmi import IBMiEngineSpec - actual = IBMiEngineSpec._time_grain_expressions[grain].format(col="my_col") assert actual == expected_expression 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" 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_lint_metadata.py b/tests/unit_tests/db_engine_specs/test_lint_metadata.py new file mode 100644 index 000000000000..c34407f402a2 --- /dev/null +++ b/tests/unit_tests/db_engine_specs/test_lint_metadata.py @@ -0,0 +1,250 @@ +# 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 io +from pathlib import Path +from textwrap import dedent + +import pytest + +from superset.db_engine_specs.lint_metadata import ( + get_all_engine_specs_ast, + main, +) + + +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: + """ + Engine specs with type-annotated engine, engine_name, and metadata + (inline initializers) are correctly discovered, including when named + with the *BaseEngineSpec suffix. + """ + _write_spec( + tmp_path, + "_tmp_typed_inline.py", + """\ + from typing import Any + from superset.db_engine_specs.base import BaseEngineSpec + + class TypedInlineBaseEngineSpec(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), + ) + + specs = get_all_engine_specs_ast() + matching = [s for s in specs if s["class_name"] == "TypedInlineBaseEngineSpec"] + assert len(matching) == 1 + spec = matching[0] + assert spec["engine_name"] == "Typed Engine" + assert spec["metadata"] == {"description": "A typed engine spec"} + + +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, 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, + "_tmp_typed_decl.py", + """\ + from typing import Any + from superset.db_engine_specs.base import BaseEngineSpec + + class DeclThenAssignBaseEngineSpec(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"] == "DeclThenAssignBaseEngineSpec"] + found_names = [s["class_name"] for s in specs] + assert len(matching) == 1, ( + f"DeclThenAssignBaseEngineSpec not found; got: {found_names}" + ) + spec = matching[0] + assert spec["engine_name"] == "DeclThenAssign" + 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, ast.Tuple, 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"}, + "pair": ("value", -1), + "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"}, + "pair": ("value", -1), + "default_port": -1, + "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", + "categories": ["DatabaseCategory.OTHER"], + "pypi_packages": ["ascii_pkg"], + "connection_string": "ascii://localhost", + } + """, + ) + + 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 + assert "✓" in decoded 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..4eaa4dab6079 --- /dev/null +++ b/tests/unit_tests/db_engine_specs/test_monetdb.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 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 + ) 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..f6d53448aa5f --- /dev/null +++ b/tests/unit_tests/db_engine_specs/test_netezza.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 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 + ) diff --git a/tests/unit_tests/db_engine_specs/test_ocient.py b/tests/unit_tests/db_engine_specs/test_ocient.py index 4689c1a5a860..68c4a8b2b3be 100644 --- a/tests/unit_tests/db_engine_specs/test_ocient.py +++ b/tests/unit_tests/db_engine_specs/test_ocient.py @@ -14,16 +14,16 @@ # 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 +from collections.abc import Callable +from typing import Any import pytest +from superset.constants import TimeGrain from superset.db_engine_specs.ocient import ( _point_list_to_wkt, _sanitized_ocient_type_codes, + OcientEngineSpec, ) from superset.errors import ErrorLevel, SupersetError, SupersetErrorType @@ -32,7 +32,53 @@ def ocient_is_installed() -> bool: return len(_sanitized_ocient_type_codes) > 0 -# (msg,expected) +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__" + + +def test_ocient_metadata() -> None: + metadata = OcientEngineSpec.metadata + assert metadata["description"] == "Ocient is a hyperscale data analytics database." + assert "sqlalchemy-ocient" in metadata["pypi_packages"] + assert metadata["default_port"] == 4050 + + +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')" + ) + + +@pytest.mark.parametrize( + "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_time_grain_expressions(time_grain: str | None, expected: str) -> None: + assert ( + 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)", diff --git a/tests/unit_tests/db_engine_specs/test_odps.py b/tests/unit_tests/db_engine_specs/test_odps.py index 0bf349da4115..2a2be48b587b 100644 --- a/tests/unit_tests/db_engine_specs/test_odps.py +++ b/tests/unit_tests/db_engine_specs/test_odps.py @@ -25,6 +25,20 @@ from superset.sql.parse import Partition, Table +def test_odps_properties() -> None: + assert OdpsEngineSpec.engine == "odps" + assert OdpsEngineSpec.engine_name == "ODPS (MaxCompute)" + assert OdpsEngineSpec.default_driver == "odps" + assert issubclass(OdpsEngineSpec, OdpsBaseEngineSpec) + + +def test_odps_metadata() -> None: + metadata = OdpsEngineSpec.metadata + assert "MaxCompute" in metadata["description"] + 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): @@ -126,7 +140,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" @@ -151,9 +165,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" diff --git a/tests/unit_tests/db_engine_specs/test_parseable.py b/tests/unit_tests/db_engine_specs/test_parseable.py index 4e5a5783042b..73313587b550 100644 --- a/tests/unit_tests/db_engine_specs/test_parseable.py +++ b/tests/unit_tests/db_engine_specs/test_parseable.py @@ -15,46 +15,43 @@ # 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 +def test_parseable_properties() -> None: + assert ParseableEngineSpec.engine == "parseable" + assert ParseableEngineSpec.engine_name == "Parseable" - assert ParseableEngineSpec.epoch_to_dttm() == "to_timestamp({col})" +def test_parseable_metadata() -> None: + metadata = ParseableEngineSpec.metadata + assert ( + "Parseable is a distributed log analytics database" in metadata["description"] + ) + 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_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_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 +63,43 @@ 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: + 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 + ) + + +def test_alter_new_orm_column() -> None: """ - DB Eng Specs (parseable): Test conversion to date time + DB Eng Specs (parseable): Test alter orm column """ - from superset.db_engine_specs.parseable import ParseableEngineSpec + from superset.connectors.sqla.models import SqlaTable, TableColumn + from superset.models.core import Database - assert_convert_dttm(ParseableEngineSpec, target_type, expected_result, dttm) + 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 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..2a3e6cee94ce --- /dev/null +++ b/tests/unit_tests/db_engine_specs/test_phoenix.py @@ -0,0 +1,75 @@ +# 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 + ) 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"] diff --git a/tests/unit_tests/db_engine_specs/test_shillelagh.py b/tests/unit_tests/db_engine_specs/test_shillelagh.py index 8a7def2ed066..332efc6a62d6 100644 --- a/tests/unit_tests/db_engine_specs/test_shillelagh.py +++ b/tests/unit_tests/db_engine_specs/test_shillelagh.py @@ -14,7 +14,6 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. - import sqlite3 from contextlib import closing from datetime import datetime @@ -23,10 +22,28 @@ from sqlalchemy import create_engine, text from superset.db_engine_specs.shillelagh import ShillelaghEngineSpec +from superset.db_engine_specs.sqlite import SqliteEngineSpec from superset.db_engine_specs.superset import SupersetEngineSpec from superset.models.core import Database +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://" + + @pytest.fixture def local_sqlite_file(tmp_path): """A standalone SQLite file that ATTACH would otherwise be able to open.""" 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" 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..38bc733c4d47 --- /dev/null +++ b/tests/unit_tests/db_engine_specs/test_spark.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.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: + actual = SparkEngineSpec._time_grain_expressions[time_grain].format(col="ts") + assert actual == expected 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://" 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..ebe001daf7bf --- /dev/null +++ b/tests/unit_tests/db_engine_specs/test_teradata.py @@ -0,0 +1,59 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +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 + 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 + + +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))" + ) + + +@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: + actual = TeradataEngineSpec._time_grain_expressions[time_grain].format(col="ts") + assert actual == expected 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 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