diff --git a/.github/workflows/generate-FOSSA-report.yml b/.github/workflows/generate-FOSSA-report.yml index 72ddb0fa09e1..951a178a3ee8 100644 --- a/.github/workflows/generate-FOSSA-report.yml +++ b/.github/workflows/generate-FOSSA-report.yml @@ -37,7 +37,7 @@ jobs: persist-credentials: false submodules: recursive - name: Setup Java - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 + uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 with: distribution: "temurin" java-version: "11" diff --git a/.github/workflows/license-check.yml b/.github/workflows/license-check.yml index 0e2e2dc6786a..2f87c4c84fd7 100644 --- a/.github/workflows/license-check.yml +++ b/.github/workflows/license-check.yml @@ -23,7 +23,7 @@ jobs: persist-credentials: false submodules: recursive - name: Setup Java - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 + uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 with: distribution: "temurin" java-version: "11" diff --git a/.github/workflows/superset-docs-deploy.yml b/.github/workflows/superset-docs-deploy.yml index 5641c8de1aec..d08eb75a52ef 100644 --- a/.github/workflows/superset-docs-deploy.yml +++ b/.github/workflows/superset-docs-deploy.yml @@ -118,7 +118,7 @@ jobs: node-version-file: "./docs/.nvmrc" - name: Setup Python uses: ./.github/actions/setup-backend/ - - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 + - uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0 with: distribution: "zulu" java-version: "21" diff --git a/docs/package.json b/docs/package.json index a8c7985c7b2b..ec68d44e32ed 100644 --- a/docs/package.json +++ b/docs/package.json @@ -62,7 +62,7 @@ "@superset-ui/core": "^0.20.4", "@swc/core": "^1.16.1", "antd": "^6.6.1", - "baseline-browser-mapping": "^2.11.17", + "baseline-browser-mapping": "^2.11.18", "caniuse-lite": "^1.0.30001809", "docusaurus-plugin-openapi-docs": "^5.2.0", "docusaurus-theme-openapi-docs": "^5.2.0", diff --git a/docs/yarn.lock b/docs/yarn.lock index e1909331df11..89deec370336 100644 --- a/docs/yarn.lock +++ b/docs/yarn.lock @@ -6471,10 +6471,10 @@ base64-js@^1.3.1, base64-js@^1.5.1: resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== -baseline-browser-mapping@^2.10.38, baseline-browser-mapping@^2.11.17, baseline-browser-mapping@^2.9.19: - version "2.11.17" - resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.11.17.tgz#a168205490077c5d7c542f1610016af4ade8f875" - integrity sha512-KAUDn1OSS0fmPlGO+NOUMRcOQ/b/shUBH3OgkG73mPgdf+JD/BQ6fHboGxNOxnUmlwcq+lLq3dTkayRPuSfXwg== +baseline-browser-mapping@^2.10.38, baseline-browser-mapping@^2.11.18, baseline-browser-mapping@^2.9.19: + version "2.11.18" + resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.11.18.tgz#49701f6ab6c58ccafb6d8e1ab2767574d9f0ba73" + integrity sha512-1iEmLEYSiE1SeBoAfPo/Mnx3PzfzHUkDK61ASkCpuk3YXugYLH5DYK1SzqV55F8FMI6s0F+/tCP7Polz1QRjxw== batch@0.6.1: version "0.6.1" diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index ea364f32b963..0bf3828a0067 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -218,7 +218,7 @@ "babel-loader": "^10.1.1", "babel-plugin-dynamic-import-node": "^2.3.3", "babel-plugin-jsx-remove-data-test-id": "^3.0.0", - "baseline-browser-mapping": "^2.11.17", + "baseline-browser-mapping": "^2.11.18", "cheerio": "1.2.0", "concurrently": "^10.0.5", "copy-webpack-plugin": "^14.0.0", @@ -277,7 +277,7 @@ "unzipper": "^0.12.5", "wait-on": "^9.1.0", "webpack": "^5.109.2", - "webpack-bundle-analyzer": "^5.3.1", + "webpack-bundle-analyzer": "^5.3.2", "webpack-cli": "^7.0.3", "webpack-dev-server": "^5.2.5", "webpack-manifest-plugin": "^6.0.1", @@ -15753,9 +15753,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.11.17", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.17.tgz", - "integrity": "sha512-KAUDn1OSS0fmPlGO+NOUMRcOQ/b/shUBH3OgkG73mPgdf+JD/BQ6fHboGxNOxnUmlwcq+lLq3dTkayRPuSfXwg==", + "version": "2.11.18", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.18.tgz", + "integrity": "sha512-1iEmLEYSiE1SeBoAfPo/Mnx3PzfzHUkDK61ASkCpuk3YXugYLH5DYK1SzqV55F8FMI6s0F+/tCP7Polz1QRjxw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -41117,9 +41117,9 @@ } }, "node_modules/webpack-bundle-analyzer": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/webpack-bundle-analyzer/-/webpack-bundle-analyzer-5.3.1.tgz", - "integrity": "sha512-wP2EusncRGL1tZyMHC/umLkjPdYMkTL9nPEKh8G8dkYCJ9TyF6xnXFqjhdqmv5J900irN/g0P5jMvLT22krEXQ==", + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/webpack-bundle-analyzer/-/webpack-bundle-analyzer-5.3.2.tgz", + "integrity": "sha512-IagCa/GrdxSz+ba9OMgK7UCfQp97HtXbgjXu7ObcqmRo9PTp0d+24rgY+mIFOx7JPQ9bo6FGsqZhA55rYRZrrQ==", "dev": true, "license": "MIT", "dependencies": { @@ -42317,9 +42317,9 @@ } }, "node_modules/yeoman-generator": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/yeoman-generator/-/yeoman-generator-8.2.2.tgz", - "integrity": "sha512-GIvRULf09VrTyJ1nMIxCRFTI8gzW9zsAxVXTHOmsWVKZ7QYPdByRQvFtnp0XOObM6dvDSoAwBhuYR6i5inp/ig==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/yeoman-generator/-/yeoman-generator-8.3.0.tgz", + "integrity": "sha512-stpxbDj2KQj4CysTd15z51uMJmfXdTzvlQ9TSMwZxVaN5NJ6a0zWMX84GNx+bEhntZs6FoFS5Hnkdck4NRyu1g==", "license": "BSD-2-Clause", "dependencies": { "@types/debug": "^4.1.13", @@ -42872,7 +42872,7 @@ "dependencies": { "chalk": "^6.0.0", "lodash-es": "^4.18.1", - "yeoman-generator": "^8.2.2", + "yeoman-generator": "^8.3.0", "yosay": "^3.0.0" }, "devDependencies": { diff --git a/superset-frontend/package.json b/superset-frontend/package.json index 96b5ad510dbf..5caad3aa328b 100644 --- a/superset-frontend/package.json +++ b/superset-frontend/package.json @@ -295,7 +295,7 @@ "babel-loader": "^10.1.1", "babel-plugin-dynamic-import-node": "^2.3.3", "babel-plugin-jsx-remove-data-test-id": "^3.0.0", - "baseline-browser-mapping": "^2.11.17", + "baseline-browser-mapping": "^2.11.18", "cheerio": "1.2.0", "concurrently": "^10.0.5", "copy-webpack-plugin": "^14.0.0", @@ -354,7 +354,7 @@ "unzipper": "^0.12.5", "wait-on": "^9.1.0", "webpack": "^5.109.2", - "webpack-bundle-analyzer": "^5.3.1", + "webpack-bundle-analyzer": "^5.3.2", "webpack-cli": "^7.0.3", "webpack-dev-server": "^5.2.5", "webpack-manifest-plugin": "^6.0.1", diff --git a/superset-frontend/packages/generator-superset/package.json b/superset-frontend/packages/generator-superset/package.json index 4205384710af..0d0c6945136a 100644 --- a/superset-frontend/packages/generator-superset/package.json +++ b/superset-frontend/packages/generator-superset/package.json @@ -33,7 +33,7 @@ "dependencies": { "chalk": "^6.0.0", "lodash-es": "^4.18.1", - "yeoman-generator": "^8.2.2", + "yeoman-generator": "^8.3.0", "yosay": "^3.0.0" }, "devDependencies": { diff --git a/superset/db_engine_specs/__init__.py b/superset/db_engine_specs/__init__.py index 78cb3e7f0278..112d0ba0c752 100644 --- a/superset/db_engine_specs/__init__.py +++ b/superset/db_engine_specs/__init__.py @@ -144,11 +144,14 @@ def get_available_engine_specs() -> dict[type[BaseEngineSpec], set[str]]: # noq issubclass(dialect, DefaultDialect) and hasattr(dialect, "driver") # adodbapi dialect is removed in SQLA 1.4 and doesn't implement the - # `dbapi` method, hence needs to be ignored to avoid logging a warning + # DBAPI import method, hence needs to be ignored to avoid a warning and dialect.driver != "adodbapi" ): try: - dialect.dbapi() + if hasattr(dialect, "import_dbapi"): + dialect.import_dbapi() + else: + dialect.dbapi() except ModuleNotFoundError: continue except Exception as ex: # pylint: disable=broad-except diff --git a/superset/mcp_service/dashboard/schemas.py b/superset/mcp_service/dashboard/schemas.py index 79f1ba5a829b..df0c757dc61a 100644 --- a/superset/mcp_service/dashboard/schemas.py +++ b/superset/mcp_service/dashboard/schemas.py @@ -272,6 +272,15 @@ class GetDashboardInfoRequest(MetadataCacheControl): "the shared active-tab and filter context; no identifier is required." ), ) + filter_state: dict[str, Any] | None = Field( + default=None, + description=( + "Active filters supplied directly rather than via a permalink, so the " + "tool can describe the dashboard as the user currently views it, " + 'filtered. Shape: {"applied_filters": [{"col", "op", "val"}]}. Ignored ' + "when permalink_key is provided." + ), + ) select_columns: Annotated[ List[str], Field( diff --git a/superset/mcp_service/dashboard/tool/get_dashboard_info.py b/superset/mcp_service/dashboard/tool/get_dashboard_info.py index a53718cf18ee..3070c9acda8a 100644 --- a/superset/mcp_service/dashboard/tool/get_dashboard_info.py +++ b/superset/mcp_service/dashboard/tool/get_dashboard_info.py @@ -40,25 +40,29 @@ dashboard_serializer, DashboardError, DashboardInfo, - DEFAULT_GET_DASHBOARD_INFO_COLUMNS, GetDashboardInfoRequest, + redact_filter_state_data_model_metadata, ) from superset.mcp_service.mcp_core import ModelGetInfoCore +from superset.mcp_service.privacy import user_can_view_data_model_metadata logger = logging.getLogger(__name__) def _apply_permalink_state( result: DashboardInfo, - permalink_key: str, + permalink_key: str | None, permalink_state: dict[str, object], + is_permalink: bool = True, ) -> DashboardInfo: - """Attach permalink fields without changing their stored values.""" + """Attach the filter state without changing its stored values. + is_permalink is False when the state was supplied directly, not resolved + from a permalink.""" return result.model_copy( update={ "permalink_key": permalink_key, "filter_state": permalink_state, - "is_permalink_state": True, + "is_permalink_state": is_permalink, } ) @@ -214,6 +218,15 @@ async def get_dashboard_info( "permalink_key provided but no permalink found. " "The permalink may have expired or is invalid." ) + elif request.filter_state is not None: + # Filter context supplied directly (no permalink), e.g. embedded. + await ctx.info("Applying caller-supplied filter_state") + filter_state = request.filter_state + if not user_can_view_data_model_metadata(): + filter_state = redact_filter_state_data_model_metadata(filter_state) + result = _apply_permalink_state( + result, None, filter_state, is_permalink=False + ) await ctx.info( "Dashboard information retrieved successfully: id=%s, title=%s, " @@ -226,12 +239,13 @@ async def get_dashboard_info( result.is_permalink_state, ) ) - # When permalink_key is supplied and the caller did not explicitly - # override select_columns, ensure filter_state is present so the - # caller gets the data they came for. + # Include filter_state by default when present, but honor an explicit + # select_columns projection (model_fields_set = caller chose it). effective_select_columns = list(request.select_columns) - if result.is_permalink_state and effective_select_columns == list( - DEFAULT_GET_DASHBOARD_INFO_COLUMNS + if ( + result.filter_state is not None + and "select_columns" not in request.model_fields_set + and "filter_state" not in effective_select_columns ): effective_select_columns.append("filter_state") diff --git a/tests/unit_tests/db_engine_specs/test_init.py b/tests/unit_tests/db_engine_specs/test_init.py index 17737e49b8be..deb8cfa64171 100644 --- a/tests/unit_tests/db_engine_specs/test_init.py +++ b/tests/unit_tests/db_engine_specs/test_init.py @@ -137,6 +137,41 @@ class ValidDialect(DefaultDialect): assert available[SqliteEngineSpec] == {"valid_driver"} +def test_get_available_engine_specs_supports_sqlalchemy_2_native_dialect( + mocker: MockerFixture, +) -> None: + """A native SQLAlchemy 2 dialect is discovered through import_dbapi().""" + import sqlalchemy.dialects + + from superset.db_engine_specs.mysql import MySQLEngineSpec + + class ValidDialect(DefaultDialect): + driver = "mysqldb" + + @classmethod + def import_dbapi(cls) -> object: + return object() + + mocker.patch.object(sqlalchemy.dialects, "__all__", ["mysql"]) + mocker.patch.object( + sqlalchemy.dialects.registry, + "load", + return_value=ValidDialect, + ) + mocker.patch( + "superset.db_engine_specs.load_engine_specs", + return_value=iter([MySQLEngineSpec]), + ) + mocker.patch( + "superset.db_engine_specs.entry_points", + return_value=[], + ) + + available = get_available_engine_specs() + + assert available[MySQLEngineSpec] == {"mysqldb"} + + @pytest.mark.parametrize( "app", [{"DBS_AVAILABLE_DENYLIST": {"databricks": {"pyhive", "pyodbc"}}}], diff --git a/tests/unit_tests/mcp_service/dashboard/test_dashboard_schemas.py b/tests/unit_tests/mcp_service/dashboard/test_dashboard_schemas.py index b4061998d2cb..7be4185fd6f0 100644 --- a/tests/unit_tests/mcp_service/dashboard/test_dashboard_schemas.py +++ b/tests/unit_tests/mcp_service/dashboard/test_dashboard_schemas.py @@ -976,6 +976,17 @@ def test_get_dashboard_info_select_columns_columns_alias(self) -> None: ) assert req.select_columns == ["id", "dashboard_title"] + def test_get_dashboard_info_accepts_filter_state(self) -> None: + applied = {"applied_filters": [{"col": "gender", "op": "IN", "val": ["F"]}]} + req = GetDashboardInfoRequest.model_validate( + {"identifier": 42, "filter_state": applied} + ) + assert req.filter_state == applied + + def test_get_dashboard_info_filter_state_defaults_none(self) -> None: + req = GetDashboardInfoRequest.model_validate({"identifier": 42}) + assert req.filter_state is None + @pytest.mark.parametrize( "payload", [ diff --git a/tests/unit_tests/mcp_service/dashboard/tool/test_dashboard_tools.py b/tests/unit_tests/mcp_service/dashboard/tool/test_dashboard_tools.py index 365d0dea49f9..2370deac288d 100644 --- a/tests/unit_tests/mcp_service/dashboard/tool/test_dashboard_tools.py +++ b/tests/unit_tests/mcp_service/dashboard/tool/test_dashboard_tools.py @@ -31,6 +31,7 @@ from superset.mcp_service.dashboard.schemas import ( DashboardError, DashboardInfo, + DEFAULT_GET_DASHBOARD_INFO_COLUMNS, ListDashboardsRequest, ) from superset.utils import json @@ -1586,3 +1587,168 @@ async def test_list_dashboards_no_arguments(mock_list, mcp_server): result = await client.call_tool("list_dashboards", {}) data = json.loads(result.content[0].text) assert "dashboards" in data + + +def _minimal_dashboard() -> Mock: + dashboard = Mock() + dashboard.id = 1 + dashboard.dashboard_title = "Test Dashboard" + dashboard.slug = "test-dashboard" + dashboard.description = None + dashboard.css = None + dashboard.certified_by = None + dashboard.certification_details = None + dashboard.json_metadata = json.dumps({"native_filter_configuration": []}) + dashboard.published = True + dashboard.is_managed_externally = False + dashboard.external_url = None + dashboard.created_on = None + dashboard.changed_on = None + dashboard.created_by = None + dashboard.changed_by = None + dashboard.uuid = "dashboard-uuid-1" + dashboard.url = "/dashboard/1" + dashboard.thumbnail_url = None + dashboard.created_on_humanized = None + dashboard.changed_on_humanized = None + dashboard.slices = [] + dashboard.editors = [] + dashboard.tags = [] + dashboard.embedded = [] + dashboard.charts = [] + return dashboard + + +@patch("superset.daos.dashboard.DashboardDAO.find_by_id") +@pytest.mark.asyncio +async def test_get_dashboard_info_direct_filter_state(mock_info, mcp_server): + """filter_state supplied directly (no permalink) is attached to the result.""" + mock_info.return_value = _minimal_dashboard() + filter_state = {"applied_filters": [{"col": "gender", "op": "IN", "val": ["F"]}]} + with patch( + "superset.mcp_service.dashboard.schemas.user_can_view_data_model_metadata", + return_value=True, + ): + async with Client(mcp_server) as client: + result = await client.call_tool( + "get_dashboard_info", + {"request": {"identifier": 1, "filter_state": filter_state}}, + ) + assert result.data["is_permalink_state"] is False + assert result.data["permalink_key"] is None + assert result.data["filter_state"]["applied_filters"][0]["col"] == "gender" + + +@patch("superset.daos.dashboard.DashboardDAO.find_by_id") +@pytest.mark.asyncio +async def test_direct_filter_state_redacts_data_model_metadata(mock_info, mcp_server): + """A caller without data-model metadata access gets dataMask/chartStates + stripped from a directly supplied filter_state, as on the permalink path.""" + mock_info.return_value = _minimal_dashboard() + filter_state = { + "applied_filters": [{"col": "gender", "op": "IN", "val": ["F"]}], + "dataMask": {"native-1": {}}, + "chartStates": {"c1": {}}, + } + with ( + patch( + "superset.mcp_service.dashboard.schemas.user_can_view_data_model_metadata", + return_value=True, + ), + patch( + "superset.mcp_service.dashboard.tool.get_dashboard_info." + "user_can_view_data_model_metadata", + return_value=False, + ), + ): + async with Client(mcp_server) as client: + result = await client.call_tool( + "get_dashboard_info", + {"request": {"identifier": 1, "filter_state": filter_state}}, + ) + assert "dataMask" not in result.data["filter_state"] + assert "chartStates" not in result.data["filter_state"] + assert "applied_filters" in result.data["filter_state"] + + +@patch("superset.mcp_service.dashboard.permalink.get_dashboard_permalink") +@patch("superset.daos.dashboard.DashboardDAO.find_by_id") +@pytest.mark.asyncio +async def test_get_dashboard_info_permalink_wins_over_filter_state( + mock_info, mock_permalink, mcp_server +): + """When both are given, permalink_key takes precedence over filter_state.""" + mock_info.return_value = _minimal_dashboard() + mock_permalink.return_value = ( + "permalink-1", + {"dashboardId": "1", "state": {"dataMask": {"native-filter-1": {}}}}, + ) + filter_state = {"applied_filters": [{"col": "gender", "op": "IN", "val": ["F"]}]} + with ( + patch( + "superset.mcp_service.dashboard.schemas.user_can_view_data_model_metadata", + return_value=True, + ), + patch( + "superset.mcp_service.dashboard.permalink." + "user_can_view_data_model_metadata", + return_value=True, + ), + ): + async with Client(mcp_server) as client: + result = await client.call_tool( + "get_dashboard_info", + { + "request": { + "identifier": 1, + "permalink_key": "permalink-1", + "filter_state": filter_state, + } + }, + ) + assert result.data["permalink_key"] == "permalink-1" + assert "dataMask" in result.data["filter_state"] + assert "applied_filters" not in result.data["filter_state"] + + +@patch("superset.daos.dashboard.DashboardDAO.find_by_id") +@pytest.mark.asyncio +async def test_direct_empty_filter_state_is_honored(mock_info, mcp_server): + """An explicit empty {} filter_state is a cleared context, not an absent one.""" + mock_info.return_value = _minimal_dashboard() + with patch( + "superset.mcp_service.dashboard.schemas.user_can_view_data_model_metadata", + return_value=True, + ): + async with Client(mcp_server) as client: + result = await client.call_tool( + "get_dashboard_info", + {"request": {"identifier": 1, "filter_state": {}}}, + ) + assert result.data["is_permalink_state"] is False + assert result.data["filter_state"] == {} + + +@patch("superset.daos.dashboard.DashboardDAO.find_by_id") +@pytest.mark.asyncio +async def test_explicit_default_columns_excludes_filter_state(mock_info, mcp_server): + """A caller who explicitly projects the default columns keeps that projection: + filter_state is not force-appended even though the values equal the defaults.""" + mock_info.return_value = _minimal_dashboard() + filter_state = {"applied_filters": [{"col": "gender", "op": "IN", "val": ["F"]}]} + with patch( + "superset.mcp_service.dashboard.schemas.user_can_view_data_model_metadata", + return_value=True, + ): + async with Client(mcp_server) as client: + result = await client.call_tool( + "get_dashboard_info", + { + "request": { + "identifier": 1, + "filter_state": filter_state, + "select_columns": list(DEFAULT_GET_DASHBOARD_INFO_COLUMNS), + } + }, + ) + assert "filter_state" not in result.data