diff --git a/superset/databases/api.py b/superset/databases/api.py index b0f29c5247a..e9178d1f24e 100644 --- a/superset/databases/api.py +++ b/superset/databases/api.py @@ -218,6 +218,7 @@ class DatabaseRestApi(BaseSupersetModelRestApi): "changed_by.last_name", "created_by.first_name", "created_by.last_name", + "configuration_method", "database_name", "explore_database_id", "expose_in_sqllab", diff --git a/superset/databases/schemas.py b/superset/databases/schemas.py index 105496efa4c..199da14b63c 100644 --- a/superset/databases/schemas.py +++ b/superset/databases/schemas.py @@ -888,6 +888,13 @@ class ImportV1DatabaseSchema(Schema): is_managed_externally = fields.Boolean(allow_none=True, dump_default=False) external_url = fields.String(allow_none=True) ssh_tunnel = fields.Nested(DatabaseSSHTunnel, allow_none=True) + configuration_method = fields.Enum( + ConfigurationMethod, + by_value=True, + required=False, + allow_none=True, + load_default=ConfigurationMethod.SQLALCHEMY_FORM, + ) @validates_schema def validate_password(self, data: dict[str, Any], **kwargs: Any) -> None: diff --git a/superset/models/core.py b/superset/models/core.py index d13c14b65ab..2de0a053913 100755 --- a/superset/models/core.py +++ b/superset/models/core.py @@ -197,6 +197,7 @@ class Database(CoreDatabase, AuditMixinNullable, ImportExportMixin): # pylint: "allow_file_upload", "extra", "impersonate_user", + "configuration_method", ] extra_import_fields = [ "password", diff --git a/tests/integration_tests/databases/api_tests.py b/tests/integration_tests/databases/api_tests.py index 94b24896a59..f77a47c8a33 100644 --- a/tests/integration_tests/databases/api_tests.py +++ b/tests/integration_tests/databases/api_tests.py @@ -189,6 +189,7 @@ class TestDatabaseApi(SupersetTestCase): "changed_by", "changed_on", "changed_on_delta_humanized", + "configuration_method", "created_by", "database_name", "disable_data_preview", diff --git a/tests/integration_tests/databases/commands_tests.py b/tests/integration_tests/databases/commands_tests.py index 529e4807e70..27c1ce56542 100644 --- a/tests/integration_tests/databases/commands_tests.py +++ b/tests/integration_tests/databases/commands_tests.py @@ -371,6 +371,7 @@ class TestExportDatabasesCommand(SupersetTestCase): "allow_csv_upload", "extra", "impersonate_user", + "configuration_method", "uuid", "version", ] diff --git a/tests/unit_tests/databases/api_test.py b/tests/unit_tests/databases/api_test.py index 0785d762e50..7b77f5099d9 100644 --- a/tests/unit_tests/databases/api_test.py +++ b/tests/unit_tests/databases/api_test.py @@ -2274,3 +2274,152 @@ def test_schemas_with_oauth2( } ] } + + +def test_export_includes_configuration_method( + mocker: MockerFixture, client: Any, full_api_access: None +) -> None: + """ + Test that exporting a database + includes the 'configuration_method' field in the YAML. + """ + import zipfile + + import prison + + from superset.models.core import Database + + # Create a database with a non-default configuration_method + db_obj = Database( + database_name="export_test_db", + sqlalchemy_uri="bigquery://gcp-project-id/", + configuration_method="dynamic_form", + uuid=UUID("12345678-1234-5678-1234-567812345678"), + ) + db.session.add(db_obj) + db.session.commit() + + rison_ids = prison.dumps([db_obj.id]) + response = client.get(f"/api/v1/database/export/?q={rison_ids}") + assert response.status_code == 200 + + # Read the zip file from the response + buf = BytesIO(response.data) + with zipfile.ZipFile(buf) as zf: + # Find the database yaml file + db_yaml_path = None + for name in zf.namelist(): + if ( + name.endswith(".yaml") + and name.startswith("database_export_") + and "/databases/" in name + ): + db_yaml_path = name + break + assert db_yaml_path, "Database YAML not found in export zip" + with zf.open(db_yaml_path) as f: + db_yaml = yaml.safe_load(f.read()) + # Assert configuration_method is present and correct + assert "configuration_method" in db_yaml + assert db_yaml["configuration_method"] == "dynamic_form" + + +def test_import_includes_configuration_method( + mocker: MockerFixture, + client: Any, + full_api_access: None, +) -> None: + """ + Test that importing a database YAML with configuration_method + sets the value on the imported DB connection. + """ + from io import BytesIO + from unittest.mock import patch + + import yaml + from flask import g, has_app_context, has_request_context + + from superset import db, security_manager + from superset.databases.api import DatabaseRestApi + from superset.models.core import Database + + DatabaseRestApi.datamodel._session = db.session + Database.metadata.create_all(db.session.get_bind()) + + def find_by_id_side_effect(db_id): + return db.session.query(Database).filter_by(id=db_id).first() + + DatabaseDAO = mocker.patch("superset.databases.api.DatabaseDAO") # noqa: N806 + DatabaseDAO.find_by_id.side_effect = find_by_id_side_effect + + metadata = { + "version": "1.0.0", + "type": "Database", + "timestamp": "2025-12-08T18:06:31.356738+00:00", + } + db_yaml = { + "database_name": "Test_Import_Configuration_Method", + "sqlalchemy_uri": "bigquery://gcp-project-id/", + "cache_timeout": 0, + "expose_in_sqllab": True, + "allow_run_async": False, + "allow_ctas": False, + "allow_cvas": False, + "allow_dml": False, + "allow_csv_upload": False, + "extra": {"allows_virtual_table_explore": True}, + "impersonate_user": False, + "uuid": "87654321-4321-8765-4321-876543218765", + "configuration_method": "dynamic_form", + "version": "1.0.0", + } + contents = { + "metadata.yaml": yaml.safe_dump(metadata), + "databases/test.yaml": yaml.safe_dump(db_yaml), + } + + with ( + patch("superset.databases.api.is_zipfile", return_value=True), + patch("superset.databases.api.ZipFile"), + patch("superset.databases.api.get_contents_from_bundle", return_value=contents), + ): + form_data = {"formData": (BytesIO(b"test"), "test.zip")} + response = client.post( + "/api/v1/database/import/", + data=form_data, + content_type="multipart/form-data", + ) + db.session.commit() + db.session.remove() + assert response.status_code == 200, response.data + + db_obj = ( + db.session.query(Database) + .filter_by(database_name="Test_Import_Configuration_Method") + .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 db_obj.configuration_method == "dynamic_form", ( + "Expected configuration_method 'dynamic_form', got " + f"{db_obj.configuration_method}" + ) + + user = None + if has_request_context() or has_app_context(): + user = getattr(g, "user", None) + if user and getattr(user, "is_authenticated", False) and hasattr(user, "id"): + db_obj.created_by = security_manager.get_user_by_id(user.id) + db.session.commit() + get_resp = client.get( + "/api/v1/database/?q=(filters:!((col:database_name,opr:eq,value:'Test_Import_Configuration_Method')))" + ) + 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 db_obj_api["configuration_method"] == "dynamic_form" diff --git a/tests/unit_tests/datasets/commands/export_test.py b/tests/unit_tests/datasets/commands/export_test.py index d2bb3a66ccf..a449b764084 100644 --- a/tests/unit_tests/datasets/commands/export_test.py +++ b/tests/unit_tests/datasets/commands/export_test.py @@ -298,6 +298,7 @@ extra: metadata_cache_timeout: {{}} schemas_allowed_for_file_upload: [] impersonate_user: false +configuration_method: sqlalchemy_form uuid: {database.uuid} version: 1.0.0 """,