From dc8a966aae04fb7f2533d562f963b3eed0477d25 Mon Sep 17 00:00:00 2001 From: Elizabeth Thompson Date: Fri, 30 Oct 2026 00:29:31 +0000 Subject: [PATCH 1/3] fix(api): handle DBAPI ProgrammingError raised on database connect in schemas and chart data Some Postgres-wire-compatible engines do not implement every GUC that SQLAlchemy's postgresql dialect queries on first connect (for example `show standard_conforming_strings`). The resulting `sqlalchemy.exc.ProgrammingError` is a sibling of `OperationalError` under `DBAPIError`, so it escaped the existing handlers and surfaced as an unhandled 500 with an ERROR log from two endpoints: - GET /api/v1/database//schemas/ (and the sibling /catalogs/), which only caught `OperationalError`. - POST/GET /api/v1/chart/data, where the connection is first opened while computing the cache key (`get_default_schema` -> `get_inspector`), outside the code that wraps query execution failures as `ChartDataQueryFailedError`. The schemas and catalogs handlers now catch `DBAPIError` (these endpoints run no user SQL, so any DBAPI error is a connect/introspection failure), keep the existing 500 response, and log a warning with the traceback so the failure is not silent. `_get_data_response` now catches `DBAPIError`, logs at WARNING, and returns a 422 with a generic, sanitized message instead of leaking the raw driver error or SQL. Genuine query execution failures are already wrapped as `ChartDataQueryFailedError` and keep their 400 response. Tradeoff: any DBAPIError acquiring the chart payload (including transient connection drops) now returns 422 rather than 500. Co-Authored-By: Claude Sonnet 5.5 --- superset/charts/data/api.py | 13 ++++++++ superset/databases/api.py | 12 +++++-- .../unit_tests/charts/test_chart_data_api.py | 23 +++++++++++++ tests/unit_tests/databases/api_test.py | 33 +++++++++++++++++++ 4 files changed, 78 insertions(+), 3 deletions(-) diff --git a/superset/charts/data/api.py b/superset/charts/data/api.py index be0316d40b6d..465d03d4b983 100644 --- a/superset/charts/data/api.py +++ b/superset/charts/data/api.py @@ -27,6 +27,7 @@ from flask_babel import gettext as _ from flask_caching.backends import NullCache from marshmallow import ValidationError +from sqlalchemy.exc import DBAPIError from werkzeug.utils import secure_filename from superset import is_feature_enabled, security_manager @@ -699,6 +700,18 @@ def _get_data_response( QueryObjectValidationError, ) as exc: return self.response_400(message=sanitize_error_message(exc.message)) + except DBAPIError: + # Query execution failures are wrapped as ChartDataQueryFailedError, so a + # raw DBAPIError here comes from acquiring a connection outside query + # execution (e.g. dialect initialization while computing the cache key). + logger.warning( + "Database error while acquiring chart data payload", exc_info=True + ) + return self.response_422( + message=sanitize_error_message( + _("There was an error connecting to the database") + ) + ) # Log is_cached if extra payload callback is provided materialized_result = result.materialize() diff --git a/superset/databases/api.py b/superset/databases/api.py index 1fd874d0861f..79499fe7dd4b 100644 --- a/superset/databases/api.py +++ b/superset/databases/api.py @@ -36,7 +36,7 @@ from flask_appbuilder.api.schemas import get_list_schema from flask_appbuilder.models.sqla.interface import SQLAInterface from marshmallow import ValidationError -from sqlalchemy.exc import NoSuchTableError, OperationalError, SQLAlchemyError +from sqlalchemy.exc import DBAPIError, NoSuchTableError, SQLAlchemyError from superset import event_logger from superset.commands.database.create import CreateDatabaseCommand @@ -782,7 +782,10 @@ def catalogs(self, pk: int, **kwargs: Any) -> FlaskResponse: catalogs, ) return self.response(200, result=list(catalogs)) - except OperationalError: + except DBAPIError: + logger.warning( + "Error connecting to database %s to list catalogs", pk, exc_info=True + ) return self.response( 500, message="There was an error connecting to the database", @@ -867,7 +870,10 @@ def schemas(self, pk: int, **kwargs: Any) -> FlaskResponse: ], ) return self.response(200, result=list(schemas)) - except OperationalError: + except DBAPIError: + logger.warning( + "Error connecting to database %s to list schemas", pk, exc_info=True + ) return self.response( 500, message="There was an error connecting to the database" ) diff --git a/tests/unit_tests/charts/test_chart_data_api.py b/tests/unit_tests/charts/test_chart_data_api.py index a86d4bf75df7..40fdf4e00210 100644 --- a/tests/unit_tests/charts/test_chart_data_api.py +++ b/tests/unit_tests/charts/test_chart_data_api.py @@ -1665,3 +1665,26 @@ def test_get_data_response_streaming_up_front_maps_setup_errors( assert response.json["message"] == str(exc) mock_stream.assert_called_once() command.execute.assert_not_called() + + +def test_get_data_response_handles_dbapi_error_on_connect(app: SupersetApp) -> None: + """ + A raw DBAPIError raised outside query execution (e.g. while computing the cache + key) returns a generic 422 instead of escaping as a 500. + """ + from sqlalchemy.exc import ProgrammingError + + command = MagicMock() + command.execute.side_effect = ProgrammingError( + "show standard_conforming_strings", {}, Exception("secret driver detail") + ) + api = ChartDataRestApi() + + with app.test_request_context("/api/v1/chart/data"): + response = api._get_data_response(command) + + assert response.status_code == 422 + body = response.get_data(as_text=True) + assert "There was an error connecting to the database" in body + assert "secret driver detail" not in body + assert "standard_conforming_strings" not in body diff --git a/tests/unit_tests/databases/api_test.py b/tests/unit_tests/databases/api_test.py index b5e7b488cd66..f4f9dcbc4d5f 100644 --- a/tests/unit_tests/databases/api_test.py +++ b/tests/unit_tests/databases/api_test.py @@ -3044,3 +3044,36 @@ def test_related_objects_limits_dataset_details( assert payload["count"] == MAX_RELATED_DATASETS + 2 assert len(payload["result"]) == MAX_RELATED_DATASETS assert can_access.call_count == MAX_RELATED_DATASETS + + +@pytest.mark.parametrize( + "endpoint, method", + [("schemas", "get_all_schema_names"), ("catalogs", "get_all_catalog_names")], +) +def test_schemas_and_catalogs_dbapi_programming_error( + mocker: MockerFixture, + client: Any, + full_api_access: None, + endpoint: str, + method: str, +) -> None: + """ + A `ProgrammingError` raised while connecting (e.g. dialect initialization + against a Postgres-compatible engine) is reported like other connection errors. + """ + from sqlalchemy.exc import ProgrammingError + + from superset.databases.api import DatabaseRestApi + + database = mocker.MagicMock() + getattr(database, method).side_effect = ProgrammingError( + "show standard_conforming_strings", {}, Exception("unrecognized parameter") + ) + DatabaseDAO = mocker.patch("superset.databases.api.DatabaseDAO") # noqa: N806 + DatabaseDAO.find_by_id.return_value = database + mocker.patch.object(DatabaseRestApi, "datamodel").get.return_value = database + + response = client.get(f"/api/v1/database/1/{endpoint}/") + + assert response.status_code == 500 + assert response.json == {"message": "There was an error connecting to the database"} From bfebf94773be5ada344b2116f9ed0e92815c302a Mon Sep 17 00:00:00 2001 From: Elizabeth Thompson Date: Sat, 31 Oct 2026 02:09:22 +0000 Subject: [PATCH 2/3] fix(api): narrow DBAPIError handlers to database engine errors, align status codes Co-Authored-By: Claude Sonnet 5.5 --- superset/charts/data/api.py | 6 +- superset/databases/api.py | 13 +- superset/translations/messages.pot | 3 + .../unit_tests/charts/test_chart_data_api.py | 24 +- tests/unit_tests/databases/api_test.py | 225 ++++++++++-------- 5 files changed, 171 insertions(+), 100 deletions(-) diff --git a/superset/charts/data/api.py b/superset/charts/data/api.py index 465d03d4b983..320aa6a4c0f4 100644 --- a/superset/charts/data/api.py +++ b/superset/charts/data/api.py @@ -55,6 +55,7 @@ from superset.connectors.sqla.models import BaseDatasource from superset.constants import CACHE_DISABLED_TIMEOUT, SemanticCacheStatus from superset.daos.exceptions import DatasourceNotFound +from superset.databases.error_provenance import is_database_engine_error from superset.errors import SupersetErrorType from superset.exceptions import QueryObjectValidationError, SupersetSecurityException from superset.extensions import cache_manager, event_logger @@ -700,7 +701,10 @@ def _get_data_response( QueryObjectValidationError, ) as exc: return self.response_400(message=sanitize_error_message(exc.message)) - except DBAPIError: + except DBAPIError as ex: + if not is_database_engine_error(ex): + # e.g. a Superset metadata-DB failure: keep it a 5xx + raise # Query execution failures are wrapped as ChartDataQueryFailedError, so a # raw DBAPIError here comes from acquiring a connection outside query # execution (e.g. dialect initialization while computing the cache key). diff --git a/superset/databases/api.py b/superset/databases/api.py index 79499fe7dd4b..3bfc4c9685f6 100644 --- a/superset/databases/api.py +++ b/superset/databases/api.py @@ -89,6 +89,7 @@ from superset.constants import MODEL_API_RW_METHOD_PERMISSION_MAP, RouteMethod from superset.daos.database import DatabaseDAO from superset.databases.decorators import check_table_access +from superset.databases.error_provenance import is_database_engine_error from superset.databases.filters import ( DatabaseFilter, DatabaseOAuth2TokensUserFilter, @@ -782,12 +783,14 @@ def catalogs(self, pk: int, **kwargs: Any) -> FlaskResponse: catalogs, ) return self.response(200, result=list(catalogs)) - except DBAPIError: + except DBAPIError as ex: + if not is_database_engine_error(ex): + raise logger.warning( "Error connecting to database %s to list catalogs", pk, exc_info=True ) return self.response( - 500, + 422, message="There was an error connecting to the database", ) except OAuth2RedirectError: @@ -870,12 +873,14 @@ def schemas(self, pk: int, **kwargs: Any) -> FlaskResponse: ], ) return self.response(200, result=list(schemas)) - except DBAPIError: + except DBAPIError as ex: + if not is_database_engine_error(ex): + raise logger.warning( "Error connecting to database %s to list schemas", pk, exc_info=True ) return self.response( - 500, message="There was an error connecting to the database" + 422, message="There was an error connecting to the database" ) except OAuth2RedirectError: raise diff --git a/superset/translations/messages.pot b/superset/translations/messages.pot index d14275155227..16fc97309db1 100644 --- a/superset/translations/messages.pot +++ b/superset/translations/messages.pot @@ -14995,6 +14995,9 @@ msgstr "" msgid "There was a problem refreshing your dashboard. We'll try again in %s, as scheduled." msgstr "" +msgid "There was an error connecting to the database" +msgstr "" + msgid "There was an error creating the group. Please, try again." msgstr "" diff --git a/tests/unit_tests/charts/test_chart_data_api.py b/tests/unit_tests/charts/test_chart_data_api.py index 40fdf4e00210..8274dc64a0f7 100644 --- a/tests/unit_tests/charts/test_chart_data_api.py +++ b/tests/unit_tests/charts/test_chart_data_api.py @@ -1674,10 +1674,14 @@ def test_get_data_response_handles_dbapi_error_on_connect(app: SupersetApp) -> N """ from sqlalchemy.exc import ProgrammingError - command = MagicMock() - command.execute.side_effect = ProgrammingError( + from superset.databases.error_provenance import _DATABASE_ENGINE_ERROR_MARKER + + error = ProgrammingError( "show standard_conforming_strings", {}, Exception("secret driver detail") ) + setattr(error, _DATABASE_ENGINE_ERROR_MARKER, True) # noqa: B010 + command = MagicMock() + command.execute.side_effect = error api = ChartDataRestApi() with app.test_request_context("/api/v1/chart/data"): @@ -1688,3 +1692,19 @@ def test_get_data_response_handles_dbapi_error_on_connect(app: SupersetApp) -> N assert "There was an error connecting to the database" in body assert "secret driver detail" not in body assert "standard_conforming_strings" not in body + + +def test_get_data_response_reraises_unmarked_dbapi_error(app: SupersetApp) -> None: + """ + A DBAPIError that did not come from a customer Database engine (e.g. the + Superset metadata DB) is not reported as a connection error to the user. + """ + from sqlalchemy.exc import ProgrammingError + + command = MagicMock() + command.execute.side_effect = ProgrammingError("select 1", {}, Exception("meta")) + api = ChartDataRestApi() + + with app.test_request_context("/api/v1/chart/data"): + with pytest.raises(ProgrammingError): + api._get_data_response(command) diff --git a/tests/unit_tests/databases/api_test.py b/tests/unit_tests/databases/api_test.py index f4f9dcbc4d5f..cc241e60f3b6 100644 --- a/tests/unit_tests/databases/api_test.py +++ b/tests/unit_tests/databases/api_test.py @@ -319,101 +319,104 @@ def test_database_connection( mocker.patch("superset.utils.log.DBEventLogger.log") response = client.get("/api/v1/database/1/connection") - assert response.json == { - "id": 1, - "result": { - "allow_ctas": False, - "allow_cvas": False, - "allow_dml": False, - "allow_file_upload": False, - "allow_run_async": False, - "backend": "gsheets", - "cache_timeout": None, - "configuration_method": "sqlalchemy_form", - "database_name": "my_database", - "driver": "gsheets", - "engine_information": { - "oauth2_enabled": False, - "disable_ssh_tunneling": True, - "supports_dynamic_catalog": False, - "supports_file_upload": True, - "supports_oauth2": True, - "supports_offset": True, - "supports_schemas": True, - "identifier_quote": { - "start": '"', - "end": '"', - "escape_by_doubling": True, - }, - }, - "expose_in_sqllab": True, - "extra": '{\n "metadata_params": {},\n "engine_params": {},\n "metadata_cache_timeout": {},\n "schemas_allowed_for_file_upload": []\n}\n', # noqa: E501 - "force_ctas_schema": None, + assert ( + response.json + == { "id": 1, - "impersonate_user": False, - "is_managed_externally": False, - "masked_encrypted_extra": json.dumps( - { + "result": { + "allow_ctas": False, + "allow_cvas": False, + "allow_dml": False, + "allow_file_upload": False, + "allow_run_async": False, + "backend": "gsheets", + "cache_timeout": None, + "configuration_method": "sqlalchemy_form", + "database_name": "my_database", + "driver": "gsheets", + "engine_information": { + "oauth2_enabled": False, + "disable_ssh_tunneling": True, + "supports_dynamic_catalog": False, + "supports_file_upload": True, + "supports_oauth2": True, + "supports_offset": True, + "supports_schemas": True, + "identifier_quote": { + "start": '"', + "end": '"', + "escape_by_doubling": True, + }, + }, + "expose_in_sqllab": True, + "extra": '{\n "metadata_params": {},\n "engine_params": {},\n "metadata_cache_timeout": {},\n "schemas_allowed_for_file_upload": []\n}\n', # noqa: E501 + "force_ctas_schema": None, + "id": 1, + "impersonate_user": False, + "is_managed_externally": False, + "masked_encrypted_extra": json.dumps( + { + "service_account_info": { + "type": "service_account", + "project_id": "black-sanctum-314419", + "private_key_id": "259b0d419a8f840056158763ff54d8b08f7b8173", # noqa: E501 + "private_key": "XXXXXXXXXX", + "client_email": "google-spreadsheets-demo-servi@black-sanctum-314419.iam.gserviceaccount.com", # noqa: E501 + "client_id": "114567578578109757129", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/google-spreadsheets-demo-servi%40black-sanctum-314419.iam.gserviceaccount.com", + } + } + ), + "parameters": { "service_account_info": { - "type": "service_account", - "project_id": "black-sanctum-314419", - "private_key_id": "259b0d419a8f840056158763ff54d8b08f7b8173", # noqa: E501 - "private_key": "XXXXXXXXXX", + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", "client_email": "google-spreadsheets-demo-servi@black-sanctum-314419.iam.gserviceaccount.com", # noqa: E501 "client_id": "114567578578109757129", - "auth_uri": "https://accounts.google.com/o/oauth2/auth", - "token_uri": "https://oauth2.googleapis.com/token", - "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/google-spreadsheets-demo-servi%40black-sanctum-314419.iam.gserviceaccount.com", + "private_key": "XXXXXXXXXX", + "private_key_id": "259b0d419a8f840056158763ff54d8b08f7b8173", + "project_id": "black-sanctum-314419", + "token_uri": "https://oauth2.googleapis.com/token", + "type": "service_account", } - } - ), - "parameters": { - "service_account_info": { - "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", - "auth_uri": "https://accounts.google.com/o/oauth2/auth", - "client_email": "google-spreadsheets-demo-servi@black-sanctum-314419.iam.gserviceaccount.com", # noqa: E501 - "client_id": "114567578578109757129", - "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/google-spreadsheets-demo-servi%40black-sanctum-314419.iam.gserviceaccount.com", - "private_key": "XXXXXXXXXX", - "private_key_id": "259b0d419a8f840056158763ff54d8b08f7b8173", - "project_id": "black-sanctum-314419", - "token_uri": "https://oauth2.googleapis.com/token", - "type": "service_account", - } - }, - "parameters_schema": { - "properties": { - "catalog": {"type": "object"}, - "oauth2_client_info": { - "default": { - "authorization_request_uri": "https://accounts.google.com/o/oauth2/v2/auth", - "scope": ( - "https://www.googleapis.com/auth/drive.readonly " - "https://www.googleapis.com/auth/spreadsheets " - "https://spreadsheets.google.com/feeds" - ), - "token_request_uri": "https://oauth2.googleapis.com/token", + }, + "parameters_schema": { + "properties": { + "catalog": {"type": "object"}, + "oauth2_client_info": { + "default": { + "authorization_request_uri": "https://accounts.google.com/o/oauth2/v2/auth", + "scope": ( + "https://www.googleapis.com/auth/drive.readonly " + "https://www.googleapis.com/auth/spreadsheets " + "https://spreadsheets.google.com/feeds" + ), + "token_request_uri": "https://oauth2.googleapis.com/token", + }, + "description": "OAuth2 client information", + "nullable": True, + "type": "string", + "x-encrypted-extra": True, + }, + "service_account_info": { + "description": "Contents of GSheets JSON credentials.", + "type": "string", + "x-encrypted-extra": True, }, - "description": "OAuth2 client information", - "nullable": True, - "type": "string", - "x-encrypted-extra": True, - }, - "service_account_info": { - "description": "Contents of GSheets JSON credentials.", - "type": "string", - "x-encrypted-extra": True, }, + "type": "object", }, - "type": "object", + "server_cert": None, + "sqlalchemy_uri": "gsheets://", + "ssh_tunnel": None, + "uuid": "02feae18-2dd6-4bb4-a9c0-49e9d4f29d58", }, - "server_cert": None, - "sqlalchemy_uri": "gsheets://", - "ssh_tunnel": None, - "uuid": "02feae18-2dd6-4bb4-a9c0-49e9d4f29d58", - }, - } + } + ) response = client.get("/api/v1/database/1") assert response.json == { @@ -2903,9 +2906,9 @@ def find_by_id_side_effect(db_id): .first() ) assert db_obj is not None, "Database not found in SQLAlchemy session after import" - assert hasattr(db_obj, "configuration_method"), ( - "'configuration_method' not found on model" - ) + assert hasattr( + db_obj, "configuration_method" + ), "'configuration_method' not found on model" assert db_obj.configuration_method == "dynamic_form", ( "Expected configuration_method 'dynamic_form', got " f"{db_obj.configuration_method}" @@ -2923,9 +2926,9 @@ def find_by_id_side_effect(db_id): result = get_resp.json["result"] assert result, "No database returned from API after import." db_obj_api = result[0] - assert "configuration_method" in db_obj_api, ( - f"'configuration_method' not found in database list response: {db_obj_api}" - ) + assert ( + "configuration_method" in db_obj_api + ), f"'configuration_method' not found in database list response: {db_obj_api}" assert db_obj_api["configuration_method"] == "dynamic_form" @@ -3063,11 +3066,47 @@ def test_schemas_and_catalogs_dbapi_programming_error( """ from sqlalchemy.exc import ProgrammingError + from superset.databases.api import DatabaseRestApi + from superset.databases.error_provenance import _DATABASE_ENGINE_ERROR_MARKER + + error = ProgrammingError( + "show standard_conforming_strings", {}, Exception("unrecognized parameter") + ) + setattr(error, _DATABASE_ENGINE_ERROR_MARKER, True) # noqa: B010 + database = mocker.MagicMock() + getattr(database, method).side_effect = error + DatabaseDAO = mocker.patch("superset.databases.api.DatabaseDAO") # noqa: N806 + DatabaseDAO.find_by_id.return_value = database + mocker.patch.object(DatabaseRestApi, "datamodel").get.return_value = database + + response = client.get(f"/api/v1/database/1/{endpoint}/") + + assert response.status_code == 422 + assert response.json == {"message": "There was an error connecting to the database"} + + +@pytest.mark.parametrize( + "endpoint, method", + [("schemas", "get_all_schema_names"), ("catalogs", "get_all_catalog_names")], +) +def test_schemas_and_catalogs_unmarked_dbapi_error_not_masked( + mocker: MockerFixture, + client: Any, + full_api_access: None, + endpoint: str, + method: str, +) -> None: + """ + A DBAPIError not marked as coming from a Database engine (e.g. a Superset + metadata-DB failure) is not reported as a customer connection error. + """ + from sqlalchemy.exc import ProgrammingError + from superset.databases.api import DatabaseRestApi database = mocker.MagicMock() getattr(database, method).side_effect = ProgrammingError( - "show standard_conforming_strings", {}, Exception("unrecognized parameter") + "select 1", {}, Exception("metadata failure") ) DatabaseDAO = mocker.patch("superset.databases.api.DatabaseDAO") # noqa: N806 DatabaseDAO.find_by_id.return_value = database @@ -3076,4 +3115,4 @@ def test_schemas_and_catalogs_dbapi_programming_error( response = client.get(f"/api/v1/database/1/{endpoint}/") assert response.status_code == 500 - assert response.json == {"message": "There was an error connecting to the database"} + assert "error connecting to the database" not in response.get_data(as_text=True) From d43c0ab9820be9161e5e7ec721c6d9868b4e37c4 Mon Sep 17 00:00:00 2001 From: Elizabeth Thompson Date: Sat, 31 Oct 2026 02:09:30 +0000 Subject: [PATCH 3/3] test: drop unrelated reformatting in databases api_test Co-Authored-By: Claude Sonnet 5.5 --- tests/unit_tests/databases/api_test.py | 185 ++++++++++++------------- 1 file changed, 91 insertions(+), 94 deletions(-) diff --git a/tests/unit_tests/databases/api_test.py b/tests/unit_tests/databases/api_test.py index cc241e60f3b6..68433349420b 100644 --- a/tests/unit_tests/databases/api_test.py +++ b/tests/unit_tests/databases/api_test.py @@ -319,104 +319,101 @@ def test_database_connection( mocker.patch("superset.utils.log.DBEventLogger.log") response = client.get("/api/v1/database/1/connection") - assert ( - response.json - == { - "id": 1, - "result": { - "allow_ctas": False, - "allow_cvas": False, - "allow_dml": False, - "allow_file_upload": False, - "allow_run_async": False, - "backend": "gsheets", - "cache_timeout": None, - "configuration_method": "sqlalchemy_form", - "database_name": "my_database", - "driver": "gsheets", - "engine_information": { - "oauth2_enabled": False, - "disable_ssh_tunneling": True, - "supports_dynamic_catalog": False, - "supports_file_upload": True, - "supports_oauth2": True, - "supports_offset": True, - "supports_schemas": True, - "identifier_quote": { - "start": '"', - "end": '"', - "escape_by_doubling": True, - }, + assert response.json == { + "id": 1, + "result": { + "allow_ctas": False, + "allow_cvas": False, + "allow_dml": False, + "allow_file_upload": False, + "allow_run_async": False, + "backend": "gsheets", + "cache_timeout": None, + "configuration_method": "sqlalchemy_form", + "database_name": "my_database", + "driver": "gsheets", + "engine_information": { + "oauth2_enabled": False, + "disable_ssh_tunneling": True, + "supports_dynamic_catalog": False, + "supports_file_upload": True, + "supports_oauth2": True, + "supports_offset": True, + "supports_schemas": True, + "identifier_quote": { + "start": '"', + "end": '"', + "escape_by_doubling": True, }, - "expose_in_sqllab": True, - "extra": '{\n "metadata_params": {},\n "engine_params": {},\n "metadata_cache_timeout": {},\n "schemas_allowed_for_file_upload": []\n}\n', # noqa: E501 - "force_ctas_schema": None, - "id": 1, - "impersonate_user": False, - "is_managed_externally": False, - "masked_encrypted_extra": json.dumps( - { - "service_account_info": { - "type": "service_account", - "project_id": "black-sanctum-314419", - "private_key_id": "259b0d419a8f840056158763ff54d8b08f7b8173", # noqa: E501 - "private_key": "XXXXXXXXXX", - "client_email": "google-spreadsheets-demo-servi@black-sanctum-314419.iam.gserviceaccount.com", # noqa: E501 - "client_id": "114567578578109757129", - "auth_uri": "https://accounts.google.com/o/oauth2/auth", - "token_uri": "https://oauth2.googleapis.com/token", - "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", - "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/google-spreadsheets-demo-servi%40black-sanctum-314419.iam.gserviceaccount.com", - } - } - ), - "parameters": { + }, + "expose_in_sqllab": True, + "extra": '{\n "metadata_params": {},\n "engine_params": {},\n "metadata_cache_timeout": {},\n "schemas_allowed_for_file_upload": []\n}\n', # noqa: E501 + "force_ctas_schema": None, + "id": 1, + "impersonate_user": False, + "is_managed_externally": False, + "masked_encrypted_extra": json.dumps( + { "service_account_info": { - "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", - "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "type": "service_account", + "project_id": "black-sanctum-314419", + "private_key_id": "259b0d419a8f840056158763ff54d8b08f7b8173", # noqa: E501 + "private_key": "XXXXXXXXXX", "client_email": "google-spreadsheets-demo-servi@black-sanctum-314419.iam.gserviceaccount.com", # noqa: E501 "client_id": "114567578578109757129", - "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/google-spreadsheets-demo-servi%40black-sanctum-314419.iam.gserviceaccount.com", - "private_key": "XXXXXXXXXX", - "private_key_id": "259b0d419a8f840056158763ff54d8b08f7b8173", - "project_id": "black-sanctum-314419", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", "token_uri": "https://oauth2.googleapis.com/token", - "type": "service_account", + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/google-spreadsheets-demo-servi%40black-sanctum-314419.iam.gserviceaccount.com", } - }, - "parameters_schema": { - "properties": { - "catalog": {"type": "object"}, - "oauth2_client_info": { - "default": { - "authorization_request_uri": "https://accounts.google.com/o/oauth2/v2/auth", - "scope": ( - "https://www.googleapis.com/auth/drive.readonly " - "https://www.googleapis.com/auth/spreadsheets " - "https://spreadsheets.google.com/feeds" - ), - "token_request_uri": "https://oauth2.googleapis.com/token", - }, - "description": "OAuth2 client information", - "nullable": True, - "type": "string", - "x-encrypted-extra": True, - }, - "service_account_info": { - "description": "Contents of GSheets JSON credentials.", - "type": "string", - "x-encrypted-extra": True, + } + ), + "parameters": { + "service_account_info": { + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "client_email": "google-spreadsheets-demo-servi@black-sanctum-314419.iam.gserviceaccount.com", # noqa: E501 + "client_id": "114567578578109757129", + "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/google-spreadsheets-demo-servi%40black-sanctum-314419.iam.gserviceaccount.com", + "private_key": "XXXXXXXXXX", + "private_key_id": "259b0d419a8f840056158763ff54d8b08f7b8173", + "project_id": "black-sanctum-314419", + "token_uri": "https://oauth2.googleapis.com/token", + "type": "service_account", + } + }, + "parameters_schema": { + "properties": { + "catalog": {"type": "object"}, + "oauth2_client_info": { + "default": { + "authorization_request_uri": "https://accounts.google.com/o/oauth2/v2/auth", + "scope": ( + "https://www.googleapis.com/auth/drive.readonly " + "https://www.googleapis.com/auth/spreadsheets " + "https://spreadsheets.google.com/feeds" + ), + "token_request_uri": "https://oauth2.googleapis.com/token", }, + "description": "OAuth2 client information", + "nullable": True, + "type": "string", + "x-encrypted-extra": True, + }, + "service_account_info": { + "description": "Contents of GSheets JSON credentials.", + "type": "string", + "x-encrypted-extra": True, }, - "type": "object", }, - "server_cert": None, - "sqlalchemy_uri": "gsheets://", - "ssh_tunnel": None, - "uuid": "02feae18-2dd6-4bb4-a9c0-49e9d4f29d58", + "type": "object", }, - } - ) + "server_cert": None, + "sqlalchemy_uri": "gsheets://", + "ssh_tunnel": None, + "uuid": "02feae18-2dd6-4bb4-a9c0-49e9d4f29d58", + }, + } response = client.get("/api/v1/database/1") assert response.json == { @@ -2906,9 +2903,9 @@ def find_by_id_side_effect(db_id): .first() ) assert db_obj is not None, "Database not found in SQLAlchemy session after import" - assert hasattr( - db_obj, "configuration_method" - ), "'configuration_method' not found on model" + assert hasattr(db_obj, "configuration_method"), ( + "'configuration_method' not found on model" + ) assert db_obj.configuration_method == "dynamic_form", ( "Expected configuration_method 'dynamic_form', got " f"{db_obj.configuration_method}" @@ -2926,9 +2923,9 @@ def find_by_id_side_effect(db_id): result = get_resp.json["result"] assert result, "No database returned from API after import." db_obj_api = result[0] - assert ( - "configuration_method" in db_obj_api - ), f"'configuration_method' not found in database list response: {db_obj_api}" + assert "configuration_method" in db_obj_api, ( + f"'configuration_method' not found in database list response: {db_obj_api}" + ) assert db_obj_api["configuration_method"] == "dynamic_form"