From 13fe88000a9677f3188f4a5496f0d3bc24dd33d9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 10 Mar 2026 16:51:58 +0700 Subject: [PATCH 01/58] chore(deps-dev): bump lightningcss from 1.31.1 to 1.32.0 in /superset-frontend (#38511) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Evan Rusackas --- superset-frontend/package-lock.json | 96 ++++++++++++++--------------- superset-frontend/package.json | 2 +- 2 files changed, 49 insertions(+), 49 deletions(-) diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index 6a6997b316f..eaf5acb1325 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -261,7 +261,7 @@ "js-yaml-loader": "^1.2.2", "jsdom": "^28.1.0", "lerna": "^8.2.3", - "lightningcss": "^1.31.1", + "lightningcss": "^1.32.0", "mini-css-extract-plugin": "^2.10.0", "open-cli": "^8.0.0", "oxlint": "^1.51.0", @@ -35258,9 +35258,9 @@ } }, "node_modules/lightningcss": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz", - "integrity": "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", "dev": true, "license": "MPL-2.0", "dependencies": { @@ -35274,23 +35274,23 @@ "url": "https://opencollective.com/parcel" }, "optionalDependencies": { - "lightningcss-android-arm64": "1.31.1", - "lightningcss-darwin-arm64": "1.31.1", - "lightningcss-darwin-x64": "1.31.1", - "lightningcss-freebsd-x64": "1.31.1", - "lightningcss-linux-arm-gnueabihf": "1.31.1", - "lightningcss-linux-arm64-gnu": "1.31.1", - "lightningcss-linux-arm64-musl": "1.31.1", - "lightningcss-linux-x64-gnu": "1.31.1", - "lightningcss-linux-x64-musl": "1.31.1", - "lightningcss-win32-arm64-msvc": "1.31.1", - "lightningcss-win32-x64-msvc": "1.31.1" + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" } }, "node_modules/lightningcss-android-arm64": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz", - "integrity": "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", "cpu": [ "arm64" ], @@ -35309,9 +35309,9 @@ } }, "node_modules/lightningcss-darwin-arm64": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.31.1.tgz", - "integrity": "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", "cpu": [ "arm64" ], @@ -35330,9 +35330,9 @@ } }, "node_modules/lightningcss-darwin-x64": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.31.1.tgz", - "integrity": "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", "cpu": [ "x64" ], @@ -35351,9 +35351,9 @@ } }, "node_modules/lightningcss-freebsd-x64": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.31.1.tgz", - "integrity": "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", "cpu": [ "x64" ], @@ -35372,9 +35372,9 @@ } }, "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.31.1.tgz", - "integrity": "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", "cpu": [ "arm" ], @@ -35393,9 +35393,9 @@ } }, "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.31.1.tgz", - "integrity": "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", "cpu": [ "arm64" ], @@ -35414,9 +35414,9 @@ } }, "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.31.1.tgz", - "integrity": "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", "cpu": [ "arm64" ], @@ -35435,9 +35435,9 @@ } }, "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz", - "integrity": "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", "cpu": [ "x64" ], @@ -35456,9 +35456,9 @@ } }, "node_modules/lightningcss-linux-x64-musl": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.31.1.tgz", - "integrity": "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", "cpu": [ "x64" ], @@ -35477,9 +35477,9 @@ } }, "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.31.1.tgz", - "integrity": "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", "cpu": [ "arm64" ], @@ -35498,9 +35498,9 @@ } }, "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.1.tgz", - "integrity": "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", "cpu": [ "x64" ], diff --git a/superset-frontend/package.json b/superset-frontend/package.json index 945b677c0e3..e5b6e7c5321 100644 --- a/superset-frontend/package.json +++ b/superset-frontend/package.json @@ -342,7 +342,7 @@ "js-yaml-loader": "^1.2.2", "jsdom": "^28.1.0", "lerna": "^8.2.3", - "lightningcss": "^1.31.1", + "lightningcss": "^1.32.0", "mini-css-extract-plugin": "^2.10.0", "open-cli": "^8.0.0", "oxlint": "^1.51.0", From 0cfd760a3657c1f78474bba52fc19fd49170f57d Mon Sep 17 00:00:00 2001 From: Amin Ghadersohi Date: Tue, 10 Mar 2026 10:53:05 +0100 Subject: [PATCH 02/58] fix(mcp): improve default chart names with descriptive format (#38406) Co-authored-by: Claude Opus 4.6 --- superset/mcp_service/chart/chart_utils.py | 157 ++++++++++++++++-- .../mcp_service/chart/tool/generate_chart.py | 13 +- .../mcp_service/chart/test_chart_utils.py | 133 +++++++++++++-- .../mcp_service/chart/test_new_chart_types.py | 8 +- 4 files changed, 280 insertions(+), 31 deletions(-) diff --git a/superset/mcp_service/chart/chart_utils.py b/superset/mcp_service/chart/chart_utils.py index ca5e1dfeb47..b1e5be880c1 100644 --- a/superset/mcp_service/chart/chart_utils.py +++ b/superset/mcp_service/chart/chart_utils.py @@ -801,34 +801,165 @@ def map_filter_operator(op: str) -> str: return operator_map.get(op, op) +def _humanize_column(col: ColumnRef) -> str: + """Return a human-readable label for a column reference.""" + if col.label: + return col.label + name = col.name.replace("_", " ").title() + if col.aggregate: + return f"{col.aggregate.capitalize()}({name})" + return name + + +def _summarize_filters( + filters: list[Any] | None, +) -> str | None: + """Extract a short context string from filter configs.""" + if not filters: + return None + parts: list[str] = [] + for f in filters[:2]: + col = getattr(f, "column", "") + val = getattr(f, "value", "") + if isinstance(val, list): + val = ", ".join(str(v) for v in val[:3]) + parts.append(f"{str(col).replace('_', ' ').title()} {val}") + return ", ".join(parts) if parts else None + + +def _truncate(name: str, max_length: int = 60) -> str: + """Truncate to *max_length*, preserving the en-dash context portion.""" + if len(name) <= max_length: + return name + if " \u2013 " in name: + what, _context = name.split(" \u2013 ", 1) + if len(what) <= max_length: + return what + return name[: max_length - 1] + "\u2026" + + +def _table_chart_what(config: TableChartConfig, dataset_name: str | None) -> str: + """Build the descriptive fragment for a table chart.""" + has_agg = any(col.aggregate for col in config.columns) + if has_agg: + metrics = [col for col in config.columns if col.aggregate] + what = ", ".join(_humanize_column(m) for m in metrics[:2]) + return f"{what} Summary" + if dataset_name: + return f"{dataset_name} Records" + cols = ", ".join(_humanize_column(c) for c in config.columns[:3]) + return f"{cols} Table" + + +def _xy_chart_what(config: XYChartConfig) -> str: + """Build the descriptive fragment for an XY chart.""" + primary_metric = _humanize_column(config.y[0]) if config.y else "Value" + dimension = _humanize_column(config.x) + + if config.kind in ("line", "area") and config.group_by is None: + return f"{primary_metric} Over Time" + if config.group_by is not None: + group_label = _humanize_column(config.group_by) + return f"{primary_metric} by {group_label}" + if config.kind == "scatter": + return f"{primary_metric} vs {dimension}" + return f"{primary_metric} by {dimension}" + + +_GRAIN_MAP: dict[str, str] = { + "PT1H": "Hourly", + "P1D": "Daily", + "P1W": "Weekly", + "P1M": "Monthly", + "P3M": "Quarterly", + "P1Y": "Yearly", +} + + +def _xy_chart_context(config: XYChartConfig) -> str | None: + """Build context (time grain / filters) for an XY chart name.""" + parts: list[str] = [] + if config.time_grain: + grain_val = ( + config.time_grain.value + if hasattr(config.time_grain, "value") + else str(config.time_grain) + ) + grain_str = _GRAIN_MAP.get(grain_val, grain_val) + parts.append(grain_str) + if filter_ctx := _summarize_filters(config.filters): + parts.append(filter_ctx) + return ", ".join(parts) if parts else None + + +def _pie_chart_what(config: PieChartConfig) -> str: + """Build the 'what' portion for a pie chart name.""" + dim = config.dimension.name + metric_label = config.metric.label or config.metric.name + return f"{dim} by {metric_label}" + + +def _pivot_table_what(config: PivotTableChartConfig) -> str: + """Build the 'what' portion for a pivot table chart name.""" + row_names = ", ".join(r.name for r in config.rows) + return f"Pivot Table \u2013 {row_names}" + + +def _mixed_timeseries_what(config: MixedTimeseriesChartConfig) -> str: + """Build the 'what' portion for a mixed timeseries chart name.""" + primary = config.y[0].label or config.y[0].name if config.y else "primary" + secondary = ( + config.y_secondary[0].label or config.y_secondary[0].name + if config.y_secondary + else "secondary" + ) + return f"{primary} + {secondary}" + + def generate_chart_name( config: TableChartConfig | XYChartConfig | PieChartConfig | PivotTableChartConfig | MixedTimeseriesChartConfig, + dataset_name: str | None = None, ) -> str: - """Generate a chart name based on the configuration.""" + """Generate a descriptive chart name following a standard format. + + Format conventions (by chart type): + Aggregated (bar/scatter with group_by): [Metric] by [Dimension] + Time-series (line/area, no group_by): [Metric] Over Time + Table (no aggregates): [Dataset] Records + Table (with aggregates): [Metric] Summary + Pie: [Dimension] by [Metric] + Pivot Table: Pivot Table – [Row1, Row2] + Mixed Timeseries: [Primary] + [Secondary] + An en-dash followed by context (filters / time grain) is appended + when such information is available. + """ if isinstance(config, TableChartConfig): - return f"Table Chart - {', '.join(col.name for col in config.columns)}" + what = _table_chart_what(config, dataset_name) + context = _summarize_filters(config.filters) elif isinstance(config, XYChartConfig): - chart_type = config.kind.capitalize() - x_col = config.x.name - y_cols = ", ".join(col.name for col in config.y) - return f"{chart_type} Chart - {x_col} vs {y_cols}" + what = _xy_chart_what(config) + context = _xy_chart_context(config) elif isinstance(config, PieChartConfig): - metric_label = config.metric.label or config.metric.name - return f"Pie Chart - {config.dimension.name} by {metric_label}" + what = _pie_chart_what(config) + context = _summarize_filters(config.filters) elif isinstance(config, PivotTableChartConfig): - rows = ", ".join(col.name for col in config.rows) - return f"Pivot Table - {rows}" + what = _pivot_table_what(config) + context = _summarize_filters(config.filters) elif isinstance(config, MixedTimeseriesChartConfig): - primary = ", ".join(col.name for col in config.y) - secondary = ", ".join(col.name for col in config.y_secondary) - return f"Mixed Chart - {primary} + {secondary}" + what = _mixed_timeseries_what(config) + context = _summarize_filters(config.filters) else: return "Chart" + name = what + if context: + name = f"{what} \u2013 {context}" + return _truncate(name) + def analyze_chart_capabilities(chart: Any | None, config: Any) -> ChartCapabilities: """Analyze chart capabilities based on type and configuration.""" diff --git a/superset/mcp_service/chart/tool/generate_chart.py b/superset/mcp_service/chart/tool/generate_chart.py index b5711d5e5f7..f09d8f89fdf 100644 --- a/superset/mcp_service/chart/tool/generate_chart.py +++ b/superset/mcp_service/chart/tool/generate_chart.py @@ -264,10 +264,6 @@ async def generate_chart( # noqa: C901 await ctx.report_progress(2, 5, "Creating chart in database") from superset.commands.chart.create import CreateChartCommand - # Use custom chart name if provided, otherwise auto-generate - chart_name = request.chart_name or generate_chart_name(request.config) - await ctx.debug("Chart name: chart_name=%s" % (chart_name,)) - # Find the dataset to get its numeric ID from superset.daos.dataset import DatasetDAO @@ -344,6 +340,15 @@ async def generate_chart( # noqa: C901 } ) + # Generate chart name after dataset lookup so we can include dataset name + dataset_name = getattr(dataset, "datasource_name", None) or getattr( + dataset, "table_name", None + ) + chart_name = request.chart_name or generate_chart_name( + request.config, dataset_name=dataset_name + ) + await ctx.debug("Chart name: chart_name=%s" % (chart_name,)) + try: with event_logger.log_context(action="mcp.generate_chart.db_write"): command = CreateChartCommand( diff --git a/tests/unit_tests/mcp_service/chart/test_chart_utils.py b/tests/unit_tests/mcp_service/chart/test_chart_utils.py index 9b53024b175..ce5c618e03b 100644 --- a/tests/unit_tests/mcp_service/chart/test_chart_utils.py +++ b/tests/unit_tests/mcp_service/chart/test_chart_utils.py @@ -568,8 +568,8 @@ class TestMapConfigToFormData: class TestGenerateChartName: """Test generate_chart_name function""" - def test_generate_table_chart_name(self) -> None: - """Test generating name for table chart""" + def test_table_no_aggregates(self) -> None: + """Table without aggregates uses column names.""" config = TableChartConfig( chart_type="table", columns=[ @@ -579,25 +579,138 @@ class TestGenerateChartName: ) result = generate_chart_name(config) - assert result == "Table Chart - product, revenue" + assert result == "Product, Revenue Table" - def test_generate_xy_chart_name(self) -> None: - """Test generating name for XY chart""" + def test_table_no_aggregates_with_dataset_name(self) -> None: + """Table without aggregates includes dataset name when available.""" + config = TableChartConfig( + chart_type="table", + columns=[ColumnRef(name="product")], + ) + + result = generate_chart_name(config, dataset_name="Orders") + assert result == "Orders Records" + + def test_table_with_aggregates(self) -> None: + """Table with aggregates produces a summary name.""" + config = TableChartConfig( + chart_type="table", + columns=[ + ColumnRef(name="product"), + ColumnRef(name="revenue", aggregate="SUM"), + ], + ) + + result = generate_chart_name(config) + assert result == "Sum(Revenue) Summary" + + def test_line_chart_over_time(self) -> None: + """Line chart without group_by uses 'Over Time' format.""" config = XYChartConfig( chart_type="xy", - x=ColumnRef(name="date"), - y=[ColumnRef(name="revenue"), ColumnRef(name="orders")], + x=ColumnRef(name="order_date"), + y=[ColumnRef(name="revenue", aggregate="SUM")], kind="line", ) result = generate_chart_name(config) - assert result == "Line Chart - date vs revenue, orders" + assert result == "Sum(Revenue) Over Time" - def test_generate_chart_name_unsupported(self) -> None: - """Test generating name for unsupported config type""" + def test_bar_chart_by_dimension(self) -> None: + """Bar chart uses 'by [X]' format.""" + config = XYChartConfig( + chart_type="xy", + x=ColumnRef(name="product_category"), + y=[ColumnRef(name="order_count", aggregate="COUNT")], + kind="bar", + ) + + result = generate_chart_name(config) + assert result == "Count(Order Count) by Product Category" + + def test_line_chart_with_group_by(self) -> None: + """Line chart with group_by uses 'by [group]' format.""" + config = XYChartConfig( + chart_type="xy", + x=ColumnRef(name="date"), + y=[ColumnRef(name="revenue", aggregate="SUM")], + kind="line", + group_by=ColumnRef(name="sales_rep"), + ) + + result = generate_chart_name(config) + assert result == "Sum(Revenue) by Sales Rep" + + def test_scatter_plot(self) -> None: + """Scatter plot uses 'Y vs X' format.""" + config = XYChartConfig( + chart_type="xy", + x=ColumnRef(name="age"), + y=[ColumnRef(name="income")], + kind="scatter", + ) + + result = generate_chart_name(config) + assert result == "Income vs Age" + + def test_time_grain_in_context(self) -> None: + """Time grain is appended as context.""" + config = XYChartConfig( + chart_type="xy", + x=ColumnRef(name="date"), + y=[ColumnRef(name="revenue", aggregate="SUM")], + kind="line", + time_grain="P1M", + ) + + result = generate_chart_name(config) + assert result == "Sum(Revenue) Over Time \u2013 Monthly" + + def test_filter_context(self) -> None: + """Filters are appended as context.""" + config = TableChartConfig( + chart_type="table", + columns=[ColumnRef(name="product")], + filters=[FilterConfig(column="region", op="=", value="West")], + ) + + result = generate_chart_name(config, dataset_name="Orders") + assert result == "Orders Records \u2013 Region West" + + def test_name_truncation(self) -> None: + """Names exceeding 60 chars are truncated.""" + config = XYChartConfig( + chart_type="xy", + x=ColumnRef(name="date"), + y=[ + ColumnRef( + name="very_long_metric_name_that_goes_on_and_on", aggregate="SUM" + ) + ], + kind="line", + group_by=ColumnRef(name="another_very_long_dimension_name_here"), + ) + + result = generate_chart_name(config) + assert len(result) <= 60 + + def test_unsupported_config_type(self) -> None: + """Unsupported config type returns generic name.""" result = generate_chart_name("invalid_config") # type: ignore assert result == "Chart" + def test_custom_labels_used(self) -> None: + """Column labels are preferred over names.""" + config = XYChartConfig( + chart_type="xy", + x=ColumnRef(name="ds", label="Date"), + y=[ColumnRef(name="cnt", aggregate="COUNT", label="Order Count")], + kind="bar", + ) + + result = generate_chart_name(config) + assert result == "Order Count by Date" + class TestGenerateExploreLink: """Test generate_explore_link function""" diff --git a/tests/unit_tests/mcp_service/chart/test_new_chart_types.py b/tests/unit_tests/mcp_service/chart/test_new_chart_types.py index e930ab83091..e2e37ba99e6 100644 --- a/tests/unit_tests/mcp_service/chart/test_new_chart_types.py +++ b/tests/unit_tests/mcp_service/chart/test_new_chart_types.py @@ -723,7 +723,7 @@ class TestGenerateChartNameNewTypes: metric=ColumnRef(name="revenue", aggregate="SUM"), ) result = generate_chart_name(config) - assert result == "Pie Chart - product by revenue" + assert result == "product by revenue" def test_pie_chart_name_with_custom_label(self) -> None: config = PieChartConfig( @@ -732,7 +732,7 @@ class TestGenerateChartNameNewTypes: metric=ColumnRef(name="revenue", aggregate="SUM", label="Total Revenue"), ) result = generate_chart_name(config) - assert result == "Pie Chart - product by Total Revenue" + assert result == "product by Total Revenue" def test_pivot_table_chart_name(self) -> None: config = PivotTableChartConfig( @@ -741,7 +741,7 @@ class TestGenerateChartNameNewTypes: metrics=[ColumnRef(name="revenue", aggregate="SUM")], ) result = generate_chart_name(config) - assert result == "Pivot Table - product, region" + assert result == "Pivot Table \u2013 product, region" def test_mixed_timeseries_chart_name(self) -> None: config = MixedTimeseriesChartConfig( @@ -751,7 +751,7 @@ class TestGenerateChartNameNewTypes: y_secondary=[ColumnRef(name="orders", aggregate="COUNT")], ) result = generate_chart_name(config) - assert result == "Mixed Chart - revenue + orders" + assert result == "revenue + orders" # ============================================================ From bde48e563ef937470465fa50ff95be76df0fdded Mon Sep 17 00:00:00 2001 From: "Michael S. Molina" <70410625+michael-s-molina@users.noreply.github.com> Date: Tue, 10 Mar 2026 11:44:31 -0300 Subject: [PATCH 03/58] fix: SQL Lab tab content padding (#38561) --- superset-frontend/src/SqlLab/components/ResultSet/index.tsx | 2 +- superset-frontend/src/SqlLab/components/SouthPane/index.tsx | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/superset-frontend/src/SqlLab/components/ResultSet/index.tsx b/superset-frontend/src/SqlLab/components/ResultSet/index.tsx index 510228596d7..7c33304890a 100644 --- a/superset-frontend/src/SqlLab/components/ResultSet/index.tsx +++ b/superset-frontend/src/SqlLab/components/ResultSet/index.tsx @@ -118,7 +118,7 @@ export interface ResultSetProps { const ResultContainer = styled.div` display: flex; flex-direction: column; - row-gap: ${({ theme }) => theme.sizeUnit * 2}px; + row-gap: ${({ theme }) => theme.sizeUnit * 3}px; height: 100%; `; diff --git a/superset-frontend/src/SqlLab/components/SouthPane/index.tsx b/superset-frontend/src/SqlLab/components/SouthPane/index.tsx index 5ee5b172f6d..114a2345a2f 100644 --- a/superset-frontend/src/SqlLab/components/SouthPane/index.tsx +++ b/superset-frontend/src/SqlLab/components/SouthPane/index.tsx @@ -63,6 +63,7 @@ const TABS_KEYS = { const StyledPane = styled.div` width: 100%; height: 100%; + .ant-tabs .ant-tabs-content-holder { overflow: visible; } @@ -79,6 +80,7 @@ const StyledPane = styled.div` ${({ theme }) => theme.sizeUnit * 2}px; } .ant-tabs-tabpane { + padding-top: ${({ theme }) => theme.sizeUnit * 3}px; .scrollable { overflow-y: auto; } From 31754a39c938523391b1c004eb418985632b72b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C4=90=E1=BB=97=20Tr=E1=BB=8Dng=20H=E1=BA=A3i?= <41283691+hainenber@users.noreply.github.com> Date: Tue, 10 Mar 2026 21:50:14 +0700 Subject: [PATCH 04/58] fix(i18n): correct variable name for translated SQL Lab query message (#38494) Signed-off-by: hainenber Co-authored-by: Evan Rusackas --- superset/translations/ar/LC_MESSAGES/messages.po | 2 +- superset/translations/ca/LC_MESSAGES/messages.po | 2 +- superset/translations/de/LC_MESSAGES/messages.po | 2 +- superset/translations/es/LC_MESSAGES/messages.po | 2 +- superset/translations/fa/LC_MESSAGES/messages.po | 2 +- superset/translations/fr/LC_MESSAGES/messages.po | 2 +- superset/translations/ja/LC_MESSAGES/messages.po | 2 +- superset/translations/mi/LC_MESSAGES/messages.po | 4 ++-- superset/translations/nl/LC_MESSAGES/messages.po | 2 +- superset/translations/pl/LC_MESSAGES/messages.po | 2 +- superset/translations/pt_BR/LC_MESSAGES/messages.po | 2 +- superset/translations/sl/LC_MESSAGES/messages.po | 2 +- superset/translations/uk/LC_MESSAGES/messages.po | 2 +- 13 files changed, 14 insertions(+), 14 deletions(-) diff --git a/superset/translations/ar/LC_MESSAGES/messages.po b/superset/translations/ar/LC_MESSAGES/messages.po index f62eaedc2c1..b92b34b48cd 100644 --- a/superset/translations/ar/LC_MESSAGES/messages.po +++ b/superset/translations/ar/LC_MESSAGES/messages.po @@ -10547,7 +10547,7 @@ msgstr "قيد التشغيل" #, fuzzy, python-format msgid "Running block %(block_num)s out of %(block_count)s" -msgstr "جاري تشغيل البيان %(statement_num)s من %(statement_count)s" +msgstr "جاري تشغيل البيان %(block_num)s من %(block_count)s" msgid "SAT" msgstr "جلس" diff --git a/superset/translations/ca/LC_MESSAGES/messages.po b/superset/translations/ca/LC_MESSAGES/messages.po index 8d3a7993282..4c16bfabfd4 100644 --- a/superset/translations/ca/LC_MESSAGES/messages.po +++ b/superset/translations/ca/LC_MESSAGES/messages.po @@ -10500,7 +10500,7 @@ msgstr "Executant" #, fuzzy, python-format msgid "Running block %(block_num)s out of %(block_count)s" -msgstr "Executant sentència %(statement_num)s de %(statement_count)s" +msgstr "Executant sentència %(block_num)s de %(block_count)s" msgid "SAT" msgstr "DIS" diff --git a/superset/translations/de/LC_MESSAGES/messages.po b/superset/translations/de/LC_MESSAGES/messages.po index 6982701e681..d1217a1aba3 100644 --- a/superset/translations/de/LC_MESSAGES/messages.po +++ b/superset/translations/de/LC_MESSAGES/messages.po @@ -10706,7 +10706,7 @@ msgstr "Läuft" #, fuzzy, python-format msgid "Running block %(block_num)s out of %(block_count)s" -msgstr "Führe Anweisung %(statement_num)s von %(statement_count)s aus" +msgstr "Führe Anweisung %(block_num)s von %(block_count)s aus" msgid "SAT" msgstr "SA" diff --git a/superset/translations/es/LC_MESSAGES/messages.po b/superset/translations/es/LC_MESSAGES/messages.po index 679d22f163a..45be448f2b0 100644 --- a/superset/translations/es/LC_MESSAGES/messages.po +++ b/superset/translations/es/LC_MESSAGES/messages.po @@ -10612,7 +10612,7 @@ msgstr "Ejecutando" #, fuzzy, python-format msgid "Running block %(block_num)s out of %(block_count)s" -msgstr "Ejecutando instrucción %(statement_num)s de %(statement_count)s" +msgstr "Ejecutando instrucción %(block_num)s de %(block_count)s" msgid "SAT" msgstr "SÁB" diff --git a/superset/translations/fa/LC_MESSAGES/messages.po b/superset/translations/fa/LC_MESSAGES/messages.po index c5b741358f6..ca081ae29bf 100644 --- a/superset/translations/fa/LC_MESSAGES/messages.po +++ b/superset/translations/fa/LC_MESSAGES/messages.po @@ -10463,7 +10463,7 @@ msgstr "در حال اجرا" #, fuzzy, python-format msgid "Running block %(block_num)s out of %(block_count)s" -msgstr "در حال اجرای بیانیه %(statement_num)s از %(statement_count)s" +msgstr "در حال اجرای بیانیه %(block_num)s از %(block_count)s" msgid "SAT" msgstr "سَت" diff --git a/superset/translations/fr/LC_MESSAGES/messages.po b/superset/translations/fr/LC_MESSAGES/messages.po index dd5a5b97daa..9af7e3e5e84 100644 --- a/superset/translations/fr/LC_MESSAGES/messages.po +++ b/superset/translations/fr/LC_MESSAGES/messages.po @@ -11363,7 +11363,7 @@ msgstr "En cours d’exécution" #, fuzzy, python-format msgid "Running block %(block_num)s out of %(block_count)s" -msgstr "Exécution de l’instruction %(statement_num)s sur %(statement_count)s" +msgstr "Exécution de l’instruction %(block_num)s sur %(block_count)s" msgid "SAT" msgstr "SAT" diff --git a/superset/translations/ja/LC_MESSAGES/messages.po b/superset/translations/ja/LC_MESSAGES/messages.po index 0721fd71263..613f5eb960f 100644 --- a/superset/translations/ja/LC_MESSAGES/messages.po +++ b/superset/translations/ja/LC_MESSAGES/messages.po @@ -10018,7 +10018,7 @@ msgstr "実行中" #, fuzzy, python-format msgid "Running block %(block_num)s out of %(block_count)s" -msgstr "%(statement_count)s 個中 %(statement_num)s 個のステートメントを実行中" +msgstr "%(block_count)s 個中 %(block_num)s 個のステートメントを実行中" msgid "SAT" msgstr "土" diff --git a/superset/translations/mi/LC_MESSAGES/messages.po b/superset/translations/mi/LC_MESSAGES/messages.po index 84ca99c8b5b..b8fdc9835b1 100644 --- a/superset/translations/mi/LC_MESSAGES/messages.po +++ b/superset/translations/mi/LC_MESSAGES/messages.po @@ -10425,8 +10425,8 @@ msgstr "E Rere Ana" #, fuzzy, python-format msgid "Running block %(block_num)s out of %(block_count)s" msgstr "" -"E whakahaere ana i te kīanga %(statement_num)s i waho o " -"%(statement_count)s" +"E whakahaere ana i te kīanga %(block_num)s i waho o " +"%(block_count)s" msgid "SAT" msgstr "HĀT" diff --git a/superset/translations/nl/LC_MESSAGES/messages.po b/superset/translations/nl/LC_MESSAGES/messages.po index c3b4e796949..8cacbb40573 100644 --- a/superset/translations/nl/LC_MESSAGES/messages.po +++ b/superset/translations/nl/LC_MESSAGES/messages.po @@ -10623,7 +10623,7 @@ msgstr "Bezig" #, fuzzy, python-format msgid "Running block %(block_num)s out of %(block_count)s" -msgstr "Lopende verklaring %(statement_num)s van %(statement_count)s" +msgstr "Lopende verklaring %(block_num)s van %(block_count)s" msgid "SAT" msgstr "ZA" diff --git a/superset/translations/pl/LC_MESSAGES/messages.po b/superset/translations/pl/LC_MESSAGES/messages.po index ff5c21e65a2..0cdedc66e4a 100644 --- a/superset/translations/pl/LC_MESSAGES/messages.po +++ b/superset/translations/pl/LC_MESSAGES/messages.po @@ -10973,7 +10973,7 @@ msgstr "W toku" #, fuzzy, python-format msgid "Running block %(block_num)s out of %(block_count)s" -msgstr "Wykonywanie instrukcji %(statement_num)s z %(statement_count)s" +msgstr "Wykonywanie instrukcji %(block_num)s z %(block_count)s" msgid "SAT" msgstr "SAT" diff --git a/superset/translations/pt_BR/LC_MESSAGES/messages.po b/superset/translations/pt_BR/LC_MESSAGES/messages.po index 58f487b4dfe..7d05412d3df 100644 --- a/superset/translations/pt_BR/LC_MESSAGES/messages.po +++ b/superset/translations/pt_BR/LC_MESSAGES/messages.po @@ -10744,7 +10744,7 @@ msgstr "Executando" #, fuzzy, python-format msgid "Running block %(block_num)s out of %(block_count)s" -msgstr "Executando instrução %(statement_num)s de %(statement_count)s" +msgstr "Executando instrução %(block_num)s de %(block_count)s" msgid "SAT" msgstr "SAB" diff --git a/superset/translations/sl/LC_MESSAGES/messages.po b/superset/translations/sl/LC_MESSAGES/messages.po index 46ce85eb75e..9172e8dec79 100644 --- a/superset/translations/sl/LC_MESSAGES/messages.po +++ b/superset/translations/sl/LC_MESSAGES/messages.po @@ -10511,7 +10511,7 @@ msgstr "V teku" #, fuzzy, python-format msgid "Running block %(block_num)s out of %(block_count)s" -msgstr "Poganjanje izraza %(statement_num)s od %(statement_count)s" +msgstr "Poganjanje izraza %(block_num)s od %(block_count)s" msgid "SAT" msgstr "SOB" diff --git a/superset/translations/uk/LC_MESSAGES/messages.po b/superset/translations/uk/LC_MESSAGES/messages.po index 178cc33988a..91544eefb23 100644 --- a/superset/translations/uk/LC_MESSAGES/messages.po +++ b/superset/translations/uk/LC_MESSAGES/messages.po @@ -10638,7 +10638,7 @@ msgstr "Біг" #, fuzzy, python-format msgid "Running block %(block_num)s out of %(block_count)s" -msgstr "Запуск оператора %(statement_num)s з %(statement_count)s" +msgstr "Запуск оператора %(block_num)s з %(block_count)s" msgid "SAT" msgstr "Сидіти" From 6d7cfac8b2de43939e66862f40f605e33dbff64b Mon Sep 17 00:00:00 2001 From: Amin Ghadersohi Date: Tue, 10 Mar 2026 17:48:08 +0100 Subject: [PATCH 05/58] fix(mcp): wrap LoggingMiddleware.on_message event_logger in try/except (#38560) --- superset/mcp_service/middleware.py | 73 ++++++++++++++++-------------- 1 file changed, 38 insertions(+), 35 deletions(-) diff --git a/superset/mcp_service/middleware.py b/superset/mcp_service/middleware.py index cb30b47e5b8..0fadd648ae8 100644 --- a/superset/mcp_service/middleware.py +++ b/superset/mcp_service/middleware.py @@ -22,6 +22,7 @@ from typing import Any, Awaitable, Callable, Dict, Protocol from fastmcp.exceptions import ToolError from fastmcp.server.middleware import Middleware, MiddlewareContext +from flask import has_app_context from pydantic import ValidationError from sqlalchemy.exc import OperationalError, TimeoutError from starlette.exceptions import HTTPException @@ -171,24 +172,25 @@ class LoggingMiddleware(Middleware): return result finally: duration_ms = int((time.time() - start_time) * 1000) - event_logger.log( - user_id=user_id, - action="mcp_tool_call", - dashboard_id=dashboard_id, - duration_ms=duration_ms, - slice_id=slice_id, - referrer=None, - curated_payload={ - "tool": tool_name, - "agent_id": agent_id, - "params": _sanitize_params(params), - "method": context.method, - "dashboard_id": dashboard_id, - "slice_id": slice_id, - "dataset_id": dataset_id, - "success": success, - }, - ) + if has_app_context(): + event_logger.log( + user_id=user_id, + action="mcp_tool_call", + dashboard_id=dashboard_id, + duration_ms=duration_ms, + slice_id=slice_id, + referrer=None, + curated_payload={ + "tool": tool_name, + "agent_id": agent_id, + "params": _sanitize_params(params), + "method": context.method, + "dashboard_id": dashboard_id, + "slice_id": slice_id, + "dataset_id": dataset_id, + "success": success, + }, + ) logger.info( "MCP tool call: tool=%s, agent_id=%s, user_id=%s, method=%s, " "dashboard_id=%s, slice_id=%s, dataset_id=%s, duration_ms=%s, " @@ -213,23 +215,24 @@ class LoggingMiddleware(Middleware): agent_id, user_id, dashboard_id, slice_id, dataset_id, params = ( self._extract_context_info(context) ) - event_logger.log( - user_id=user_id, - action="mcp_message", - dashboard_id=dashboard_id, - duration_ms=None, - slice_id=slice_id, - referrer=None, - curated_payload={ - "tool": getattr(context.message, "name", None), - "agent_id": agent_id, - "params": _sanitize_params(params), - "method": context.method, - "dashboard_id": dashboard_id, - "slice_id": slice_id, - "dataset_id": dataset_id, - }, - ) + if has_app_context(): + event_logger.log( + user_id=user_id, + action="mcp_message", + dashboard_id=dashboard_id, + duration_ms=None, + slice_id=slice_id, + referrer=None, + curated_payload={ + "tool": getattr(context.message, "name", None), + "agent_id": agent_id, + "params": _sanitize_params(params), + "method": context.method, + "dashboard_id": dashboard_id, + "slice_id": slice_id, + "dataset_id": dataset_id, + }, + ) logger.info( "MCP message: tool=%s, agent_id=%s, user_id=%s, method=%s", getattr(context.message, "name", None), From eb77452857d14d3ee6b50c491e7f70cfe7f70029 Mon Sep 17 00:00:00 2001 From: Amin Ghadersohi Date: Tue, 10 Mar 2026 18:56:58 +0100 Subject: [PATCH 06/58] feat(mcp): auto-generate dashboard title from chart names when omitted (#38410) Co-authored-by: Claude Opus 4.6 --- superset/mcp_service/dashboard/schemas.py | 8 +- .../dashboard/tool/generate_dashboard.py | 53 +++++++- .../tool/test_dashboard_generation.py | 122 ++++++++++++++++++ 3 files changed, 180 insertions(+), 3 deletions(-) diff --git a/superset/mcp_service/dashboard/schemas.py b/superset/mcp_service/dashboard/schemas.py index 62e4b46a0ec..7b002677c69 100644 --- a/superset/mcp_service/dashboard/schemas.py +++ b/superset/mcp_service/dashboard/schemas.py @@ -451,7 +451,13 @@ class GenerateDashboardRequest(BaseModel): chart_ids: List[int] = Field( ..., description="List of chart IDs to include in the dashboard", min_length=1 ) - dashboard_title: str = Field(..., description="Title for the new dashboard") + dashboard_title: str | None = Field( + None, + description=( + "Title for the new dashboard. When omitted a descriptive title " + "is generated from the included chart names." + ), + ) description: str | None = Field(None, description="Description for the dashboard") published: bool = Field( default=True, description="Whether to publish the dashboard" diff --git a/superset/mcp_service/dashboard/tool/generate_dashboard.py b/superset/mcp_service/dashboard/tool/generate_dashboard.py index a34c21beafb..8126ee387ca 100644 --- a/superset/mcp_service/dashboard/tool/generate_dashboard.py +++ b/superset/mcp_service/dashboard/tool/generate_dashboard.py @@ -138,6 +138,45 @@ def _create_dashboard_layout(chart_objects: List[Any]) -> Dict[str, Any]: return layout +_DEFAULT_DASHBOARD_TITLE = "Dashboard" +_MAX_TITLE_LENGTH = 150 + + +def _generate_title_from_charts(chart_objects: List[Any]) -> str: + """ + Build a descriptive dashboard title from the included chart names. + + Joins up to three chart ``slice_name`` values with " & " (two charts) + or ", " (three charts). When there are more than three charts the + remaining count is appended as "+ N more". The result is capped at + ``_MAX_TITLE_LENGTH`` characters. + + Returns ``"Dashboard"`` when *chart_objects* is empty or no chart has + a usable name. + """ + names = [ + c.slice_name + for c in sorted(chart_objects, key=lambda c: getattr(c, "id", 0)) + if getattr(c, "slice_name", None) + ] + if not names: + return _DEFAULT_DASHBOARD_TITLE + + if len(names) == 1: + title = names[0] + elif len(names) == 2: + title = f"{names[0]} & {names[1]}" + elif len(names) == 3: + title = f"{names[0]}, {names[1]}, {names[2]}" + else: + title = f"{names[0]}, {names[1]}, {names[2]} + {len(names) - 3} more" + + if len(title) > _MAX_TITLE_LENGTH: + title = title[: _MAX_TITLE_LENGTH - 1] + "\u2026" + + return title + + @tool(tags=["mutate"]) @parse_request(GenerateDashboardRequest) def generate_dashboard( @@ -160,7 +199,10 @@ def generate_dashboard( with event_logger.log_context(action="mcp.generate_dashboard.chart_validation"): chart_objects = ( - db.session.query(Slice).filter(Slice.id.in_(request.chart_ids)).all() + db.session.query(Slice) + .filter(Slice.id.in_(request.chart_ids)) + .order_by(Slice.id) + .all() ) found_chart_ids = [chart.id for chart in chart_objects] @@ -177,10 +219,17 @@ def generate_dashboard( with event_logger.log_context(action="mcp.generate_dashboard.layout"): layout = _create_dashboard_layout(chart_objects) + # Resolve dashboard title: use provided title or derive from chart names + dashboard_title = ( + request.dashboard_title + if request.dashboard_title is not None + else _generate_title_from_charts(chart_objects) + ) + # Prepare dashboard data and create dashboard with event_logger.log_context(action="mcp.generate_dashboard.db_write"): dashboard_data = { - "dashboard_title": request.dashboard_title, + "dashboard_title": dashboard_title, "slug": None, # Let Superset auto-generate slug "css": "", "json_metadata": json.dumps( diff --git a/tests/unit_tests/mcp_service/dashboard/tool/test_dashboard_generation.py b/tests/unit_tests/mcp_service/dashboard/tool/test_dashboard_generation.py index 83fe00df0fb..a0d3091744e 100644 --- a/tests/unit_tests/mcp_service/dashboard/tool/test_dashboard_generation.py +++ b/tests/unit_tests/mcp_service/dashboard/tool/test_dashboard_generation.py @@ -33,6 +33,9 @@ from superset.mcp_service.dashboard.tool.add_chart_to_existing_dashboard import _find_next_row_position, _find_tab_insert_target, ) +from superset.mcp_service.dashboard.tool.generate_dashboard import ( + _generate_title_from_charts, +) from superset.utils import json logging.basicConfig(level=logging.DEBUG) @@ -98,6 +101,7 @@ class TestGenerateDashboard: mock_query = Mock() mock_filter = Mock() mock_query.filter.return_value = mock_filter + mock_filter.order_by.return_value = mock_filter mock_filter.all.return_value = [ _mock_chart(id=1, slice_name="Sales Chart"), _mock_chart(id=2, slice_name="Revenue Chart"), @@ -136,6 +140,7 @@ class TestGenerateDashboard: mock_query = Mock() mock_filter = Mock() mock_query.filter.return_value = mock_filter + mock_filter.order_by.return_value = mock_filter mock_filter.all.return_value = [_mock_chart(id=1)] mock_db_session.query.return_value = mock_query @@ -159,6 +164,7 @@ class TestGenerateDashboard: mock_query = Mock() mock_filter = Mock() mock_query.filter.return_value = mock_filter + mock_filter.order_by.return_value = mock_filter mock_filter.all.return_value = [_mock_chart(id=5, slice_name="Single Chart")] mock_db_session.query.return_value = mock_query @@ -189,6 +195,7 @@ class TestGenerateDashboard: mock_query = Mock() mock_filter = Mock() mock_query.filter.return_value = mock_filter + mock_filter.order_by.return_value = mock_filter mock_filter.all.return_value = [ _mock_chart(id=i, slice_name=f"Chart {i}") for i in chart_ids ] @@ -258,6 +265,7 @@ class TestGenerateDashboard: mock_query = Mock() mock_filter = Mock() mock_query.filter.return_value = mock_filter + mock_filter.order_by.return_value = mock_filter mock_filter.all.return_value = [_mock_chart(id=1)] mock_db_session.query.return_value = mock_query mock_create_command.return_value.run.side_effect = Exception("Creation failed") @@ -281,6 +289,7 @@ class TestGenerateDashboard: mock_query = Mock() mock_filter = Mock() mock_query.filter.return_value = mock_filter + mock_filter.order_by.return_value = mock_filter mock_filter.all.return_value = [_mock_chart(id=3)] mock_db_session.query.return_value = mock_query @@ -307,6 +316,67 @@ class TestGenerateDashboard: "description" not in call_args or call_args.get("description") is None ) + @patch("superset.commands.dashboard.create.CreateDashboardCommand") + @patch("superset.db.session") + @pytest.mark.asyncio + async def test_generate_dashboard_auto_title_from_charts( + self, mock_db_session, mock_create_command, mcp_server + ): + """Test that omitting dashboard_title generates a title from chart names.""" + mock_query = Mock() + mock_filter = Mock() + mock_query.filter.return_value = mock_filter + mock_filter.order_by.return_value = mock_filter + mock_filter.all.return_value = [ + _mock_chart(id=1, slice_name="Sales Revenue"), + _mock_chart(id=2, slice_name="Customer Count"), + ] + mock_db_session.query.return_value = mock_query + + mock_dashboard = _mock_dashboard(id=50, title="Sales Revenue & Customer Count") + mock_create_command.return_value.run.return_value = mock_dashboard + + # No dashboard_title provided + request = {"chart_ids": [1, 2]} + + async with Client(mcp_server) as client: + result = await client.call_tool("generate_dashboard", {"request": request}) + + assert result.structured_content["error"] is None + + call_args = mock_create_command.call_args[0][0] + assert call_args["dashboard_title"] == "Sales Revenue & Customer Count" + + @patch("superset.commands.dashboard.create.CreateDashboardCommand") + @patch("superset.db.session") + @pytest.mark.asyncio + async def test_generate_dashboard_empty_string_title_preserved( + self, mock_db_session, mock_create_command, mcp_server + ): + """Test that an explicit empty-string title is NOT replaced by auto-gen.""" + mock_query = Mock() + mock_filter = Mock() + mock_query.filter.return_value = mock_filter + mock_filter.order_by.return_value = mock_filter + mock_filter.all.return_value = [ + _mock_chart(id=1, slice_name="Sales Revenue"), + ] + mock_db_session.query.return_value = mock_query + + mock_dashboard = _mock_dashboard(id=60, title="") + mock_create_command.return_value.run.return_value = mock_dashboard + + # Explicit empty string title + request = {"chart_ids": [1], "dashboard_title": ""} + + async with Client(mcp_server) as client: + result = await client.call_tool("generate_dashboard", {"request": request}) + + assert result.structured_content["error"] is None + + call_args = mock_create_command.call_args[0][0] + assert call_args["dashboard_title"] == "" + class TestAddChartToExistingDashboard: """Tests for add_chart_to_existing_dashboard MCP tool.""" @@ -766,3 +836,55 @@ class TestLayoutHelpers: assert "ROW-new" in layout["TAB-first"]["children"] assert "ROW-new" not in layout["GRID_ID"]["children"] + + +class TestGenerateTitleFromCharts: + """Tests for _generate_title_from_charts helper.""" + + def test_empty_list_returns_dashboard(self): + assert _generate_title_from_charts([]) == "Dashboard" + + def test_single_chart(self): + charts = [_mock_chart(id=1, slice_name="Revenue")] + assert _generate_title_from_charts(charts) == "Revenue" + + def test_two_charts_joined_with_ampersand(self): + charts = [ + _mock_chart(id=1, slice_name="Revenue"), + _mock_chart(id=2, slice_name="Costs"), + ] + assert _generate_title_from_charts(charts) == "Revenue & Costs" + + def test_three_charts_joined_with_commas(self): + charts = [ + _mock_chart(id=1, slice_name="Revenue"), + _mock_chart(id=2, slice_name="Costs"), + _mock_chart(id=3, slice_name="Profit"), + ] + assert _generate_title_from_charts(charts) == "Revenue, Costs, Profit" + + def test_four_charts_shows_plus_more(self): + charts = [_mock_chart(id=i, slice_name=f"Chart {i}") for i in range(1, 5)] + assert ( + _generate_title_from_charts(charts) == "Chart 1, Chart 2, Chart 3 + 1 more" + ) + + def test_many_charts_shows_plus_more(self): + charts = [_mock_chart(id=i, slice_name=f"Chart {i}") for i in range(1, 8)] + assert ( + _generate_title_from_charts(charts) == "Chart 1, Chart 2, Chart 3 + 4 more" + ) + + def test_charts_without_names_returns_dashboard(self): + chart = Mock() + chart.slice_name = None + assert _generate_title_from_charts([chart]) == "Dashboard" + + def test_long_title_is_truncated(self): + charts = [ + _mock_chart(id=1, slice_name="A" * 100), + _mock_chart(id=2, slice_name="B" * 100), + ] + title = _generate_title_from_charts(charts) + assert len(title) <= 150 + assert title.endswith("\u2026") From 3bb9704cd5108a5f35acdfc0ac7c377dfafc258d Mon Sep 17 00:00:00 2001 From: Amin Ghadersohi Date: Tue, 10 Mar 2026 18:57:15 +0100 Subject: [PATCH 07/58] fix(mcp): honor target_tab parameter when adding charts to tabbed dashboards (#38409) Co-authored-by: Claude Opus 4.6 --- .../tool/add_chart_to_existing_dashboard.py | 127 +++++++++++--- .../tool/test_dashboard_generation.py | 158 ++++++++++++++++++ 2 files changed, 263 insertions(+), 22 deletions(-) diff --git a/superset/mcp_service/dashboard/tool/add_chart_to_existing_dashboard.py b/superset/mcp_service/dashboard/tool/add_chart_to_existing_dashboard.py index 48b21790465..2ffec57f267 100644 --- a/superset/mcp_service/dashboard/tool/add_chart_to_existing_dashboard.py +++ b/superset/mcp_service/dashboard/tool/add_chart_to_existing_dashboard.py @@ -22,6 +22,7 @@ This tool adds a chart to an existing dashboard with automatic layout positionin """ import logging +import re from typing import Any, Dict from fastmcp import Context @@ -44,6 +45,22 @@ from superset.utils import json logger = logging.getLogger(__name__) +# Compiled regex for stripping common emoji Unicode ranges from tab text. +# Uses specific Unicode blocks to avoid overly permissive ranges. +_EMOJI_RE = re.compile( + "[" + "\U0001f300-\U0001f5ff" # Misc Symbols and Pictographs + "\U0001f600-\U0001f64f" # Emoticons + "\U0001f680-\U0001f6ff" # Transport and Map Symbols + "\U0001f900-\U0001f9ff" # Supplemental Symbols and Pictographs + "\U0001fa70-\U0001faff" # Symbols and Pictographs Extended-A + "\u2600-\u26ff" # Misc Symbols + "\u2700-\u27bf" # Dingbats + "\ufe00-\ufe0f" # Variation Selectors + "\u200d" # Zero-width joiner + "]+" +) + def _find_next_row_position(layout: Dict[str, Any]) -> str: """ @@ -63,35 +80,99 @@ def _find_next_row_position(layout: Dict[str, Any]) -> str: return row_key -def _find_tab_insert_target(layout: Dict[str, Any]) -> str | None: +def _normalize_tab_text(text: str | None) -> str: + """Strip emoji and extra whitespace from tab text for flexible matching.""" + if not text: + return "" + cleaned = _EMOJI_RE.sub("", text) + return cleaned.strip().lower() + + +def _match_tab_in_children( + layout: Dict[str, Any], + tabs_children: list[str], + target_tab: str, +) -> str | None: + """Search tabs_children for a tab matching target_tab by ID or name. + + Matching is flexible: exact ID match, exact text match, or + case-insensitive text match after stripping emoji characters. """ - Detect if the dashboard uses tabs and return the first tab's ID. + target_normalized = _normalize_tab_text(target_tab) + for tab_id in tabs_children: + tab = layout.get(tab_id) + if not tab or tab.get("type") != "TAB": + continue + tab_text = (tab.get("meta") or {}).get("text", "") + # Exact match on ID or text + if target_tab in (tab_id, tab_text): + return tab_id + # Flexible match: case-insensitive, emoji-stripped + if target_normalized and _normalize_tab_text(tab_text) == target_normalized: + return tab_id + return None - If ``GRID_ID`` has children that are ``TABS`` components, this walks - into the first ``TAB`` child so that new rows are placed inside the - active tab rather than directly under GRID_ID. - Returns: - The ID of the first TAB component, or ``None`` if the dashboard - does not use top-level tabs. +def _collect_tabs_groups(layout: Dict[str, Any]) -> list[list[str]]: + """Collect all TABS groups from ROOT_ID and GRID_ID children. + + Superset dashboards can place TABS under either ROOT_ID or GRID_ID + depending on how the layout was constructed. """ - grid = layout.get("GRID_ID") - if not grid: - return None - - for child_id in grid.get("children", []): - child = layout.get(child_id) - if child and child.get("type") == "TABS": - # Found a TABS component; use its first TAB child + groups: list[list[str]] = [] + for parent_key in ("ROOT_ID", "GRID_ID"): + parent = layout.get(parent_key) + if not parent: + continue + for child_id in parent.get("children", []): + child = layout.get(child_id) + if not child or child.get("type") != "TABS": + continue tabs_children = child.get("children", []) if tabs_children: - first_tab_id = tabs_children[0] - first_tab = layout.get(first_tab_id) - if first_tab and first_tab.get("type") == "TAB": - return first_tab_id + groups.append(tabs_children) + return groups + + +def _first_tab_from_groups( + layout: Dict[str, Any], groups: list[list[str]] +) -> str | None: + """Return the first valid TAB ID from the collected groups.""" + for tabs_children in groups: + first_tab_id = tabs_children[0] + first_tab = layout.get(first_tab_id) + if first_tab and first_tab.get("type") == "TAB": + return first_tab_id return None +def _find_tab_insert_target( + layout: Dict[str, Any], target_tab: str | None = None +) -> str | None: + """ + Detect if the dashboard uses tabs and return the appropriate tab's ID. + + If *target_tab* is provided the function first tries to match it against + tab ``meta.text`` (display name) or the raw component ID. When no match + is found (or *target_tab* is ``None``) the first ``TAB`` child is used as + a fallback so that new rows are still placed inside the tab structure + rather than directly under ``GRID_ID``. + + Returns: + The ID of the matched (or first) TAB component, or ``None`` if the + dashboard does not use top-level tabs. + """ + groups = _collect_tabs_groups(layout) + + if target_tab: + for tabs_children in groups: + matched = _match_tab_in_children(layout, tabs_children, target_tab) + if matched: + return matched + + return _first_tab_from_groups(layout, groups) + + def _add_chart_to_layout( layout: Dict[str, Any], chart: Any, @@ -284,8 +365,10 @@ def add_chart_to_existing_dashboard( # Generate a unique ROW ID for the new row row_key = _find_next_row_position(current_layout) - # Detect tabbed dashboards: if GRID has TABS, target the first tab - tab_target = _find_tab_insert_target(current_layout) + # Detect tabbed dashboards and resolve target_tab by name or ID + tab_target = _find_tab_insert_target( + current_layout, target_tab=request.target_tab + ) parent_id = tab_target if tab_target else "GRID_ID" # Add chart, column, and row to layout diff --git a/tests/unit_tests/mcp_service/dashboard/tool/test_dashboard_generation.py b/tests/unit_tests/mcp_service/dashboard/tool/test_dashboard_generation.py index a0d3091744e..912bb74fc08 100644 --- a/tests/unit_tests/mcp_service/dashboard/tool/test_dashboard_generation.py +++ b/tests/unit_tests/mcp_service/dashboard/tool/test_dashboard_generation.py @@ -676,6 +676,107 @@ class TestAddChartToExistingDashboard: assert "TABS-abc123" in chart_parents assert "TAB-tab1" in chart_parents + @patch("superset.commands.dashboard.update.UpdateDashboardCommand") + @patch("superset.daos.dashboard.DashboardDAO.find_by_id") + @patch("superset.db.session") + @pytest.mark.asyncio + async def test_add_chart_to_specific_tab_by_name( + self, mock_db_session, mock_find_dashboard, mock_update_command, mcp_server + ): + """Test adding chart to a specific tab using target_tab name.""" + mock_dashboard = _mock_dashboard(id=3, title="Tabbed Dashboard") + mock_dashboard.slices = [Mock(id=10)] + mock_dashboard.position_json = json.dumps( + { + "ROOT_ID": { + "children": ["GRID_ID"], + "id": "ROOT_ID", + "type": "ROOT", + }, + "GRID_ID": { + "children": ["TABS-abc123"], + "id": "GRID_ID", + "parents": ["ROOT_ID"], + "type": "GRID", + }, + "TABS-abc123": { + "children": ["TAB-tab1", "TAB-tab2"], + "id": "TABS-abc123", + "parents": ["ROOT_ID", "GRID_ID"], + "type": "TABS", + }, + "TAB-tab1": { + "children": ["ROW-existing"], + "id": "TAB-tab1", + "meta": {"text": "Activity Metrics"}, + "parents": ["ROOT_ID", "GRID_ID", "TABS-abc123"], + "type": "TAB", + }, + "TAB-tab2": { + "children": [], + "id": "TAB-tab2", + "meta": {"text": "Customers"}, + "parents": ["ROOT_ID", "GRID_ID", "TABS-abc123"], + "type": "TAB", + }, + "ROW-existing": { + "children": ["CHART-10"], + "id": "ROW-existing", + "meta": {"background": "BACKGROUND_TRANSPARENT"}, + "parents": ["ROOT_ID", "GRID_ID", "TABS-abc123", "TAB-tab1"], + "type": "ROW", + }, + "CHART-10": { + "id": "CHART-10", + "type": "CHART", + "parents": [ + "ROOT_ID", + "GRID_ID", + "TABS-abc123", + "TAB-tab1", + "ROW-existing", + ], + }, + "DASHBOARD_VERSION_KEY": "v2", + } + ) + mock_find_dashboard.return_value = mock_dashboard + + mock_chart = _mock_chart(id=30, slice_name="Customer Chart") + mock_db_session.get.return_value = mock_chart + + updated_dashboard = _mock_dashboard(id=3, title="Tabbed Dashboard") + updated_dashboard.slices = [Mock(id=10), Mock(id=30)] + mock_update_command.return_value.run.return_value = updated_dashboard + + request = {"dashboard_id": 3, "chart_id": 30, "target_tab": "Customers"} + + async with Client(mcp_server) as client: + result = await client.call_tool( + "add_chart_to_existing_dashboard", {"request": request} + ) + + assert result.structured_content["error"] is None + + call_args = mock_update_command.call_args[0][1] + layout = json.loads(call_args["position_json"]) + + row_key = result.structured_content["position"]["row_key"] + assert row_key in layout + + # Chart should be in TAB-tab2 (Customers), NOT TAB-tab1 + assert row_key in layout["TAB-tab2"]["children"] + assert row_key not in layout["TAB-tab1"]["children"] + + # GRID_ID should still only have TABS, not the new row + assert layout["GRID_ID"]["children"] == ["TABS-abc123"] + + # Verify correct parent chain includes TAB-tab2 + chart_parents = layout["CHART-30"]["parents"] + assert "TABS-abc123" in chart_parents + assert "TAB-tab2" in chart_parents + assert "TAB-tab1" not in chart_parents + @patch("superset.commands.dashboard.update.UpdateDashboardCommand") @patch("superset.daos.dashboard.DashboardDAO.find_by_id") @patch("superset.db.session") @@ -780,6 +881,63 @@ class TestLayoutHelpers: } assert _find_tab_insert_target(layout) == "TAB-first" + def test_find_tab_insert_target_by_tab_name(self): + """Test _find_tab_insert_target resolves target_tab by display name.""" + layout = { + "GRID_ID": {"children": ["TABS-main"], "type": "GRID"}, + "TABS-main": {"children": ["TAB-first", "TAB-second"], "type": "TABS"}, + "TAB-first": { + "children": [], + "type": "TAB", + "meta": {"text": "Activity Metrics"}, + }, + "TAB-second": { + "children": [], + "type": "TAB", + "meta": {"text": "Customers"}, + }, + } + assert _find_tab_insert_target(layout, target_tab="Customers") == "TAB-second" + + def test_find_tab_insert_target_by_tab_id(self): + """Test _find_tab_insert_target resolves target_tab by component ID.""" + layout = { + "GRID_ID": {"children": ["TABS-main"], "type": "GRID"}, + "TABS-main": {"children": ["TAB-first", "TAB-second"], "type": "TABS"}, + "TAB-first": { + "children": [], + "type": "TAB", + "meta": {"text": "Tab 1"}, + }, + "TAB-second": { + "children": [], + "type": "TAB", + "meta": {"text": "Tab 2"}, + }, + } + assert _find_tab_insert_target(layout, target_tab="TAB-second") == "TAB-second" + + def test_find_tab_insert_target_unmatched_falls_back_to_first(self): + """Test _find_tab_insert_target falls back to first tab when target_tab + doesn't match any tab name or ID.""" + layout = { + "GRID_ID": {"children": ["TABS-main"], "type": "GRID"}, + "TABS-main": {"children": ["TAB-first", "TAB-second"], "type": "TABS"}, + "TAB-first": { + "children": [], + "type": "TAB", + "meta": {"text": "Tab 1"}, + }, + "TAB-second": { + "children": [], + "type": "TAB", + "meta": {"text": "Tab 2"}, + }, + } + assert ( + _find_tab_insert_target(layout, target_tab="Nonexistent Tab") == "TAB-first" + ) + def test_find_tab_insert_target_no_grid(self): """Test _find_tab_insert_target with missing GRID_ID.""" assert _find_tab_insert_target({"ROOT_ID": {"type": "ROOT"}}) is None From fe7f220c210689cc9ccb5d4ab46456be6823a28f Mon Sep 17 00:00:00 2001 From: Amin Ghadersohi Date: Tue, 10 Mar 2026 19:09:09 +0100 Subject: [PATCH 08/58] fix(charts): set reasonable default y-axis title margin to prevent label overlap (#38389) Co-authored-by: Claude Opus 4.6 --- .../src/Timeseries/EchartsTimeseries.test.tsx | 2 +- .../plugins/plugin-chart-echarts/src/Timeseries/constants.ts | 2 +- superset-frontend/plugins/plugin-chart-echarts/src/constants.ts | 2 +- .../test/MixedTimeseries/transformProps.test.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/EchartsTimeseries.test.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/EchartsTimeseries.test.tsx index b290596d153..4c52463936b 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/EchartsTimeseries.test.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/EchartsTimeseries.test.tsx @@ -158,7 +158,7 @@ const defaultFormData: EchartsTimeseriesFormData & { xAxisTitle: '', xAxisTitleMargin: 0, yAxisTitle: '', - yAxisTitleMargin: 0, + yAxisTitleMargin: 15, yAxisTitlePosition: '', time_range: 'No filter', granularity: undefined, diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/constants.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/constants.ts index c07be715216..472d5fc6525 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/constants.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/constants.ts @@ -46,7 +46,7 @@ export const DEFAULT_FORM_DATA: EchartsTimeseriesFormData = { xAxisTitle: '', xAxisTitleMargin: 0, yAxisTitle: '', - yAxisTitleMargin: 0, + yAxisTitleMargin: 15, yAxisTitlePosition: 'Top', // Now that the weird bug workaround is over, here's the rest... ...DEFAULT_SORT_SERIES_DATA, diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/constants.ts b/superset-frontend/plugins/plugin-chart-echarts/src/constants.ts index 286f611e6d3..76de9217868 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/constants.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/constants.ts @@ -104,7 +104,7 @@ export const DEFAULT_TITLE_FORM_DATA: TitleFormData = { xAxisTitle: '', xAxisTitleMargin: 0, yAxisTitle: '', - yAxisTitleMargin: 0, + yAxisTitleMargin: 15, yAxisTitlePosition: 'Top', }; diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/MixedTimeseries/transformProps.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/MixedTimeseries/transformProps.test.ts index f17924ec66e..e8707d0c8c2 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/test/MixedTimeseries/transformProps.test.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/test/MixedTimeseries/transformProps.test.ts @@ -112,7 +112,7 @@ const formData: EchartsMixedTimeseriesFormData = { yAxisBounds: [undefined, undefined], yAxisBoundsSecondary: [undefined, undefined], yAxisTitle: '', - yAxisTitleMargin: 0, + yAxisTitleMargin: 15, yAxisTitlePosition: '', yAxisTitleSecondary: '', zoomable: false, From 9215eb5e45c19212a851e6b1004c569fe1764aaf Mon Sep 17 00:00:00 2001 From: amaannawab923 Date: Wed, 11 Mar 2026 01:56:24 +0530 Subject: [PATCH 09/58] fix(ag-grid): persist AG Grid column filters in explore permalinks (#38393) --- superset-frontend/src/dataMask/reducer.ts | 20 +++++- .../src/explore/actions/exploreActions.ts | 13 ++++ .../src/explore/actions/hydrateExplore.ts | 14 ++++- .../components/ExploreChartPanel/index.tsx | 61 ++++++++++++++++++- .../useExploreAdditionalActionsMenu/index.tsx | 24 +++++++- .../src/explore/reducers/exploreReducer.ts | 33 +++++++++- superset-frontend/src/explore/types.ts | 5 +- superset-frontend/src/pages/Chart/index.tsx | 16 +++++ superset-frontend/src/utils/urlUtils.ts | 9 ++- superset/commands/explore/get.py | 7 ++- superset/explore/permalink/schemas.py | 10 +++ superset/explore/permalink/types.py | 1 + 12 files changed, 198 insertions(+), 15 deletions(-) diff --git a/superset-frontend/src/dataMask/reducer.ts b/superset-frontend/src/dataMask/reducer.ts index 011cc49440c..90051cbe54a 100644 --- a/superset-frontend/src/dataMask/reducer.ts +++ b/superset-frontend/src/dataMask/reducer.ts @@ -36,6 +36,10 @@ import { isChartCustomization, } from 'src/dashboard/components/nativeFilters/FiltersConfigModal/utils'; import { HYDRATE_DASHBOARD } from 'src/dashboard/actions/hydrate'; +import { + HYDRATE_EXPLORE, + HydrateExplore, +} from 'src/explore/actions/hydrateExplore'; import { SaveFilterChangesType } from 'src/dashboard/components/nativeFilters/FiltersConfigModal/types'; import { migrateChartCustomizationArray, @@ -195,7 +199,7 @@ function updateDataMaskForFilterChanges( const dataMaskReducer = produce( ( draft: DataMaskStateWithId, - action: AnyDataMaskAction | HydrateDashboardAction, + action: AnyDataMaskAction | HydrateDashboardAction | HydrateExplore, ) => { const cleanState: DataMaskStateWithId = {}; switch (action.type) { @@ -286,6 +290,20 @@ const dataMaskReducer = produce( return cleanState; } + case HYDRATE_EXPLORE: { + const hydrateExploreAction = action as HydrateExplore; + const loadedDataMask = hydrateExploreAction.data.dataMask; + if (loadedDataMask) { + Object.entries(loadedDataMask).forEach(([id, mask]) => { + draft[id] = { + ...getInitialDataMask(id), + ...draft[id], + ...mask, + }; + }); + } + return draft; + } case SET_DATA_MASK_FOR_FILTER_CHANGES_COMPLETE: updateDataMaskForFilterChanges( action.filterChanges, diff --git a/superset-frontend/src/explore/actions/exploreActions.ts b/superset-frontend/src/explore/actions/exploreActions.ts index cfcbd8a524e..1863acaebbb 100644 --- a/superset-frontend/src/explore/actions/exploreActions.ts +++ b/superset-frontend/src/explore/actions/exploreActions.ts @@ -153,6 +153,19 @@ export function setForceQuery(force: boolean) { }; } +export const UPDATE_EXPLORE_CHART_STATE = 'UPDATE_EXPLORE_CHART_STATE'; +export function updateExploreChartState( + chartId: number, + chartState: Record, +) { + return { + type: UPDATE_EXPLORE_CHART_STATE, + chartId, + chartState, + lastModified: Date.now(), + }; +} + export const SET_STASH_FORM_DATA = 'SET_STASH_FORM_DATA'; export function setStashFormData( isHidden: boolean, diff --git a/superset-frontend/src/explore/actions/hydrateExplore.ts b/superset-frontend/src/explore/actions/hydrateExplore.ts index 0ceb1259b7b..0639ff9f949 100644 --- a/superset-frontend/src/explore/actions/hydrateExplore.ts +++ b/superset-frontend/src/explore/actions/hydrateExplore.ts @@ -28,6 +28,8 @@ import { getControlsState } from 'src/explore/store'; import { Dispatch } from 'redux'; import { Currency, + DataMaskStateWithId, + JsonObject, ensureIsArray, FeatureFlag, getCategoricalSchemeRegistry, @@ -60,7 +62,12 @@ export const hydrateExplore = dataset, metadata, saveAction = null, - }: ExplorePageInitialData) => + dataMask, + chartStates, + }: ExplorePageInitialData & { + dataMask?: DataMaskStateWithId; + chartStates?: Record; + }) => (dispatch: Dispatch, getState: () => ExplorePageState) => { const { user, datasources, charts, sliceEntities, common, explore } = getState(); @@ -224,12 +231,13 @@ export const hydrateExplore = saveModalAlert: null, isVisible: false, }, - explore: exploreState, + explore: { ...exploreState, chartStates }, + dataMask, }, }); }; export type HydrateExplore = { type: typeof HYDRATE_EXPLORE; - data: ExplorePageState; + data: ExplorePageState & { dataMask?: DataMaskStateWithId }; }; diff --git a/superset-frontend/src/explore/components/ExploreChartPanel/index.tsx b/superset-frontend/src/explore/components/ExploreChartPanel/index.tsx index c692e65bfd1..b7bd508f959 100644 --- a/superset-frontend/src/explore/components/ExploreChartPanel/index.tsx +++ b/superset-frontend/src/explore/components/ExploreChartPanel/index.tsx @@ -17,6 +17,7 @@ * under the License. */ import { useState, useEffect, useCallback, useMemo, ReactNode } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; import Split from 'react-split'; import { t } from '@apache-superset/core/translation'; import { @@ -33,6 +34,11 @@ import { import { Alert } from '@apache-superset/core/components'; import { css, styled, useTheme } from '@apache-superset/core/theme'; import ChartContainer from 'src/components/Chart/ChartContainer'; +import { updateExploreChartState } from 'src/explore/actions/exploreActions'; +import { + convertChartStateToOwnState, + hasChartStateConverter, +} from 'src/dashboard/util/chartStateConverter'; import { getItem, setItem, @@ -43,6 +49,7 @@ import { getDatasourceAsSaveableDataset } from 'src/utils/datasourceUtils'; import { buildV1ChartDataPayload } from 'src/explore/exploreUtils'; import { getChartRequiredFieldsMissingMessage } from 'src/utils/getChartRequiredFieldsMissingMessage'; import type { ChartState, Datasource } from 'src/explore/types'; +import type { ExploreState } from 'src/explore/reducers/exploreReducer'; import type { Slice } from 'src/types/Chart'; import LastQueriedLabel from 'src/components/LastQueriedLabel'; import { DataTablesPane } from '../DataTablesPane'; @@ -126,6 +133,28 @@ const Styles = styled.div<{ showSplite: boolean }>` } `; +const EMPTY_OBJECT: Record = {}; + +const createOwnStateWithChartState = ( + baseOwnState: JsonObject, + chartState: { state?: JsonObject } | undefined, + vizTypeArg: string, +): JsonObject => { + if (!hasChartStateConverter(vizTypeArg)) { + return baseOwnState; + } + const state = chartState?.state; + if (!state) { + return baseOwnState; + } + const convertedState = convertChartStateToOwnState(vizTypeArg, state); + return { + ...baseOwnState, + ...convertedState, + chartState: state, + }; +}; + const ExploreChartPanel = ({ chart, slice, @@ -145,8 +174,34 @@ const ExploreChartPanel = ({ can_download: canDownload, }: ExploreChartPanelProps) => { const theme = useTheme(); + const dispatch = useDispatch(); const gutterMargin = theme.sizeUnit * GUTTER_SIZE_FACTOR; const gutterHeight = theme.sizeUnit * GUTTER_SIZE_FACTOR; + + const chartState = useSelector( + (state: { explore?: ExploreState }) => + state.explore?.chartStates?.[chart.id], + ); + + const handleChartStateChange = useCallback( + (chartStateArg: JsonObject) => { + if (hasChartStateConverter(vizType)) { + dispatch(updateExploreChartState(chart.id, chartStateArg)); + } + }, + [dispatch, chart.id, vizType], + ); + + const mergedOwnState = useMemo( + () => + createOwnStateWithChartState( + ownState || EMPTY_OBJECT, + chartState as { state?: JsonObject } | undefined, + vizType, + ), + [ownState, chartState, vizType], + ); + const { ref: chartPanelRef, observerRef: resizeObserverRef, @@ -259,7 +314,7 @@ const ExploreChartPanel = ({ ; - explore?: ExploreSlice; + explore?: ExploreSlice & { + chartStates?: Record; + }; common?: { conf?: { CSV_STREAMING_ROW_THRESHOLD?: number; @@ -221,6 +223,15 @@ export const useExploreAdditionalActionsMenu = ( state.common?.conf?.CSV_STREAMING_ROW_THRESHOLD || DEFAULT_CSV_STREAMING_ROW_THRESHOLD, ); + const exploreChartState = useSelector< + ExploreState, + JsonObject | undefined + >(state => { + const chartKey = state.explore ? getChartKey(state.explore) : undefined; + return chartKey != null + ? state.explore?.chartStates?.[chartKey] + : undefined; + }); // Streaming export state and handlers const [isStreamingModalVisible, setIsStreamingModalVisible] = useState(false); @@ -274,6 +285,9 @@ export const useExploreAdditionalActionsMenu = ( 'EXPORT_CURRENT_VIEW' as Behavior, ); + const permalinkChartState = (exploreChartState as { state?: JsonObject }) + ?.state; + const shareByEmail = useCallback(async () => { try { const subject = t('Superset Chart'); @@ -282,6 +296,8 @@ export const useExploreAdditionalActionsMenu = ( } const result = await getChartPermalink( latestQueryFormData as Pick, + undefined, + permalinkChartState, ); if (!result?.url) { throw new Error('Failed to generate permalink'); @@ -293,7 +309,7 @@ export const useExploreAdditionalActionsMenu = ( } catch (error) { addDangerToast(t('Sorry, something went wrong. Try again later.')); } - }, [addDangerToast, latestQueryFormData]); + }, [addDangerToast, latestQueryFormData, permalinkChartState]); const exportCSV = useCallback(() => { if (!canDownloadCSV) return null; @@ -411,6 +427,8 @@ export const useExploreAdditionalActionsMenu = ( await copyTextToClipboard(async () => { const result = await getChartPermalink( latestQueryFormData as Pick, + undefined, + permalinkChartState, ); if (!result?.url) { throw new Error('Failed to generate permalink'); @@ -421,7 +439,7 @@ export const useExploreAdditionalActionsMenu = ( } catch (error) { addDangerToast(t('Sorry, something went wrong. Try again later.')); } - }, [addDangerToast, addSuccessToast, latestQueryFormData]); + }, [addDangerToast, addSuccessToast, latestQueryFormData, permalinkChartState]); // Minimal client-side CSV builder used for "Current View" when pagination is disabled const downloadClientCSV = ( diff --git a/superset-frontend/src/explore/reducers/exploreReducer.ts b/superset-frontend/src/explore/reducers/exploreReducer.ts index e940699e32c..faffbfac5fa 100644 --- a/superset-frontend/src/explore/reducers/exploreReducer.ts +++ b/superset-frontend/src/explore/reducers/exploreReducer.ts @@ -17,7 +17,12 @@ * under the License. */ /* eslint camelcase: 0 */ -import { ensureIsArray, QueryFormData, JsonValue } from '@superset-ui/core'; +import { + ensureIsArray, + QueryFormData, + JsonValue, + JsonObject, +} from '@superset-ui/core'; import { ControlState, ControlStateMapping, @@ -66,6 +71,7 @@ export interface ExploreState { owners?: string[] | null; }; saveAction?: SaveActionType | null; + chartStates?: Record; } // Action type definitions @@ -165,6 +171,13 @@ interface SetForceQueryAction { force: boolean; } +interface UpdateExploreChartStateAction { + type: typeof actions.UPDATE_EXPLORE_CHART_STATE; + chartId: number; + chartState: Record; + lastModified: number; +} + type ExploreAction = | DynamicPluginControlsReadyAction | ToggleFaveStarAction @@ -183,6 +196,7 @@ type ExploreAction = | SetStashFormDataAction | SliceUpdatedAction | SetForceQueryAction + | UpdateExploreChartStateAction | HydrateExplore; // Extended control state for dynamic form controls - uses Record for flexibility @@ -621,10 +635,25 @@ export default function exploreReducer( force: typedAction.force, }; }, + [actions.UPDATE_EXPLORE_CHART_STATE]() { + const typedAction = action as UpdateExploreChartStateAction; + return { + ...state, + chartStates: { + ...state.chartStates, + [typedAction.chartId]: { + chartId: typedAction.chartId, + state: typedAction.chartState, + lastModified: typedAction.lastModified, + }, + }, + }; + }, [HYDRATE_EXPLORE]() { const typedAction = action as HydrateExplore; + const exploreData = typedAction.data.explore; return { - ...typedAction.data.explore, + ...exploreData, } as ExploreState; }, }; diff --git a/superset-frontend/src/explore/types.ts b/superset-frontend/src/explore/types.ts index a032de3dd76..d1420fbb3fd 100644 --- a/superset-frontend/src/explore/types.ts +++ b/superset-frontend/src/explore/types.ts @@ -98,7 +98,10 @@ export interface ExplorePageInitialData { } export interface ExploreResponsePayload { - result: ExplorePageInitialData & { message: string }; + result: ExplorePageInitialData & { + message: string; + chartState?: JsonObject; + }; } export interface ExplorePageState { diff --git a/superset-frontend/src/pages/Chart/index.tsx b/superset-frontend/src/pages/Chart/index.tsx index 9f04ade2328..948abdc47f9 100644 --- a/superset-frontend/src/pages/Chart/index.tsx +++ b/superset-frontend/src/pages/Chart/index.tsx @@ -150,11 +150,27 @@ export default function ExplorePage() { ) : result.form_data; + let chartStates: Record | undefined; + if (result.chartState) { + const sliceId = + getUrlParam(URL_PARAMS.sliceId) || + (formData as JsonObject).slice_id || + 0; + chartStates = { + [sliceId]: { + chartId: sliceId, + state: result.chartState, + lastModified: Date.now(), + }, + }; + } + dispatch( hydrateExplore({ ...result, form_data: formData, saveAction, + chartStates, }), ); }) diff --git a/superset-frontend/src/utils/urlUtils.ts b/superset-frontend/src/utils/urlUtils.ts index 6c3a51a987a..4b218201144 100644 --- a/superset-frontend/src/utils/urlUtils.ts +++ b/superset-frontend/src/utils/urlUtils.ts @@ -195,11 +195,16 @@ async function resolvePermalinkUrl( export async function getChartPermalink( formData: Pick, excludedUrlParams?: string[], + chartState?: JsonObject, ): Promise { - const result = await getPermalink('/api/v1/explore/permalink', { + const payload: JsonObject = { formData, urlParams: getChartUrlParams(excludedUrlParams), - }); + }; + if (chartState && Object.keys(chartState).length > 0) { + payload.chartState = chartState; + } + const result = await getPermalink('/api/v1/explore/permalink', payload); return resolvePermalinkUrl(result); } diff --git a/superset/commands/explore/get.py b/superset/commands/explore/get.py index 78142eb5ec1..31b10c42049 100644 --- a/superset/commands/explore/get.py +++ b/superset/commands/explore/get.py @@ -62,6 +62,7 @@ class GetExploreCommand(BaseCommand, ABC): # pylint: disable=too-many-locals,too-many-branches,too-many-statements def run(self) -> Optional[dict[str, Any]]: # noqa: C901 initial_form_data = {} + permalink_chart_state = None if self._permalink_key is not None: command = GetExplorePermalinkCommand(self._permalink_key) permalink_value = command.run() @@ -72,6 +73,7 @@ class GetExploreCommand(BaseCommand, ABC): url_params = state.get("urlParams") if url_params: initial_form_data["url_params"] = dict(url_params) + permalink_chart_state = state.get("chartState") elif self._form_data_key: parameters = FormDataCommandParameters(key=self._form_data_key) value = GetFormDataCommand(parameters).run() @@ -168,13 +170,16 @@ class GetExploreCommand(BaseCommand, ABC): if slc.changed_by: metadata["changed_by"] = slc.changed_by.get_full_name() - return { + result: dict[str, Any] = { "dataset": sanitize_datasource_data(datasource_data), "form_data": form_data, "slice": slc.data if slc else None, "message": message, "metadata": metadata, } + if permalink_chart_state: + result["chartState"] = permalink_chart_state + return result def validate(self) -> None: pass diff --git a/superset/explore/permalink/schemas.py b/superset/explore/permalink/schemas.py index 63466b0c1ba..aa5fef0c46b 100644 --- a/superset/explore/permalink/schemas.py +++ b/superset/explore/permalink/schemas.py @@ -41,6 +41,16 @@ class ExplorePermalinkStateSchema(Schema): allow_none=True, metadata={"description": "URL Parameters"}, ) + chartState = fields.Dict( # noqa: N815 + required=False, + allow_none=True, + metadata={ + "description": ( + "Chart-level state for stateful tables " + "(column filters, sorting, column order)" + ) + }, + ) class ExplorePermalinkSchema(Schema): diff --git a/superset/explore/permalink/types.py b/superset/explore/permalink/types.py index 7eb4a7cb6b1..8cf8fc930ab 100644 --- a/superset/explore/permalink/types.py +++ b/superset/explore/permalink/types.py @@ -20,6 +20,7 @@ from typing import Any, Optional, TypedDict class ExplorePermalinkState(TypedDict, total=False): formData: dict[str, Any] urlParams: Optional[list[tuple[str, str]]] + chartState: Optional[dict[str, Any]] class ExplorePermalinkValue(TypedDict): From 9b8106b3820094399a3406ac473c03890cabd01d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 11 Mar 2026 22:23:08 +0700 Subject: [PATCH 10/58] chore(deps-dev): bump mini-css-extract-plugin from 2.10.0 to 2.10.1 in /superset-frontend (#38573) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- superset-frontend/package-lock.json | 10 +++++----- superset-frontend/package.json | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index eaf5acb1325..4bf0e3b6a24 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -262,7 +262,7 @@ "jsdom": "^28.1.0", "lerna": "^8.2.3", "lightningcss": "^1.32.0", - "mini-css-extract-plugin": "^2.10.0", + "mini-css-extract-plugin": "^2.10.1", "open-cli": "^8.0.0", "oxlint": "^1.51.0", "po2json": "^0.4.5", @@ -36954,9 +36954,9 @@ } }, "node_modules/mini-css-extract-plugin": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.10.0.tgz", - "integrity": "sha512-540P2c5dYnJlyJxTaSloliZexv8rji6rY8FhQN+WF/82iHQfA23j/xtJx97L+mXOML27EqksSek/g4eK7jaL3g==", + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.10.1.tgz", + "integrity": "sha512-k7G3Y5QOegl380tXmZ68foBRRjE9Ljavx835ObdvmZjQ639izvZD8CS7BkWw1qKPPzHsGL/JDhl0uyU1zc2rJw==", "dev": true, "license": "MIT", "dependencies": { @@ -51505,7 +51505,7 @@ "react-js-cron": "^5.2.0", "react-markdown": "^8.0.7", "react-resize-detector": "^7.1.2", - "react-syntax-highlighter": "^16.1.0", + "react-syntax-highlighter": "^16.1.1", "react-ultimate-pagination": "^1.3.2", "regenerator-runtime": "^0.14.1", "rehype-raw": "^7.0.0", diff --git a/superset-frontend/package.json b/superset-frontend/package.json index e5b6e7c5321..f35228484c0 100644 --- a/superset-frontend/package.json +++ b/superset-frontend/package.json @@ -343,7 +343,7 @@ "jsdom": "^28.1.0", "lerna": "^8.2.3", "lightningcss": "^1.32.0", - "mini-css-extract-plugin": "^2.10.0", + "mini-css-extract-plugin": "^2.10.1", "open-cli": "^8.0.0", "oxlint": "^1.51.0", "po2json": "^0.4.5", From 591294194252bb147a28b0b739064fd0f253d8b1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 11 Mar 2026 22:50:11 +0700 Subject: [PATCH 11/58] chore(deps-dev): bump @typescript-eslint/parser from 8.56.1 to 8.57.0 in /superset-websocket (#38570) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- superset-websocket/package-lock.json | 332 +++++++++++++++++++++++++-- superset-websocket/package.json | 2 +- 2 files changed, 318 insertions(+), 16 deletions(-) diff --git a/superset-websocket/package-lock.json b/superset-websocket/package-lock.json index 86085f03ab9..8b11dccd977 100644 --- a/superset-websocket/package-lock.json +++ b/superset-websocket/package-lock.json @@ -26,7 +26,7 @@ "@types/node": "^25.3.3", "@types/ws": "^8.18.1", "@typescript-eslint/eslint-plugin": "^8.55.0", - "@typescript-eslint/parser": "^8.55.0", + "@typescript-eslint/parser": "^8.57.0", "eslint": "^10.0.2", "eslint-config-prettier": "^10.1.8", "eslint-plugin-lodash": "^8.0.0", @@ -1883,16 +1883,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.1.tgz", - "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.0.tgz", + "integrity": "sha512-XZzOmihLIr8AD1b9hL9ccNMzEMWt/dE2u7NyTY9jJG6YNiNthaD5XtUHVF2uCXZ15ng+z2hT3MVuxnUYhq6k1g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.56.1", - "@typescript-eslint/types": "8.56.1", - "@typescript-eslint/typescript-estree": "8.56.1", - "@typescript-eslint/visitor-keys": "8.56.1", + "@typescript-eslint/scope-manager": "8.57.0", + "@typescript-eslint/types": "8.57.0", + "@typescript-eslint/typescript-estree": "8.57.0", + "@typescript-eslint/visitor-keys": "8.57.0", "debug": "^4.4.3" }, "engines": { @@ -1907,6 +1907,175 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/project-service": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.0.tgz", + "integrity": "sha512-pR+dK0BlxCLxtWfaKQWtYr7MhKmzqZxuii+ZjuFlZlIGRZm22HnXFqa2eY+90MUz8/i80YJmzFGDUsi8dMOV5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.57.0", + "@typescript-eslint/types": "^8.57.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/scope-manager": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.0.tgz", + "integrity": "sha512-nvExQqAHF01lUM66MskSaZulpPL5pgy5hI5RfrxviLgzZVffB5yYzw27uK/ft8QnKXI2X0LBrHJFr1TaZtAibw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.57.0", + "@typescript-eslint/visitor-keys": "8.57.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.0.tgz", + "integrity": "sha512-LtXRihc5ytjJIQEH+xqjB0+YgsV4/tW35XKX3GTZHpWtcC8SPkT/d4tqdf1cKtesryHm2bgp6l555NYcT2NLvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/types": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.0.tgz", + "integrity": "sha512-dTLI8PEXhjUC7B9Kre+u0XznO696BhXcTlOn0/6kf1fHaQW8+VjJAVHJ3eTI14ZapTxdkOmc80HblPQLaEeJdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.0.tgz", + "integrity": "sha512-m7faHcyVg0BT3VdYTlX8GdJEM7COexXxS6KqGopxdtkQRvBanK377QDHr4W/vIPAR+ah9+B/RclSW5ldVniO1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.57.0", + "@typescript-eslint/tsconfig-utils": "8.57.0", + "@typescript-eslint/types": "8.57.0", + "@typescript-eslint/visitor-keys": "8.57.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/visitor-keys": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.0.tgz", + "integrity": "sha512-zm6xx8UT/Xy2oSr2ZXD0pZo7Jx2XsCoID2IUh9YSTFRu7z+WdwYTRk6LhUftm1crwqbuoF6I8zAFeCMw0YjwDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.57.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/brace-expansion": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@typescript-eslint/project-service": { "version": "8.56.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.1.tgz", @@ -6222,6 +6391,31 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/parser": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.1.tgz", + "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.56.1", + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, "node_modules/uglify-js": { "version": "3.19.3", "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", @@ -7962,16 +8156,109 @@ } }, "@typescript-eslint/parser": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.1.tgz", - "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.0.tgz", + "integrity": "sha512-XZzOmihLIr8AD1b9hL9ccNMzEMWt/dE2u7NyTY9jJG6YNiNthaD5XtUHVF2uCXZ15ng+z2hT3MVuxnUYhq6k1g==", "dev": true, "requires": { - "@typescript-eslint/scope-manager": "8.56.1", - "@typescript-eslint/types": "8.56.1", - "@typescript-eslint/typescript-estree": "8.56.1", - "@typescript-eslint/visitor-keys": "8.56.1", + "@typescript-eslint/scope-manager": "8.57.0", + "@typescript-eslint/types": "8.57.0", + "@typescript-eslint/typescript-estree": "8.57.0", + "@typescript-eslint/visitor-keys": "8.57.0", "debug": "^4.4.3" + }, + "dependencies": { + "@typescript-eslint/project-service": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.0.tgz", + "integrity": "sha512-pR+dK0BlxCLxtWfaKQWtYr7MhKmzqZxuii+ZjuFlZlIGRZm22HnXFqa2eY+90MUz8/i80YJmzFGDUsi8dMOV5w==", + "dev": true, + "requires": { + "@typescript-eslint/tsconfig-utils": "^8.57.0", + "@typescript-eslint/types": "^8.57.0", + "debug": "^4.4.3" + } + }, + "@typescript-eslint/scope-manager": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.0.tgz", + "integrity": "sha512-nvExQqAHF01lUM66MskSaZulpPL5pgy5hI5RfrxviLgzZVffB5yYzw27uK/ft8QnKXI2X0LBrHJFr1TaZtAibw==", + "dev": true, + "requires": { + "@typescript-eslint/types": "8.57.0", + "@typescript-eslint/visitor-keys": "8.57.0" + } + }, + "@typescript-eslint/tsconfig-utils": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.0.tgz", + "integrity": "sha512-LtXRihc5ytjJIQEH+xqjB0+YgsV4/tW35XKX3GTZHpWtcC8SPkT/d4tqdf1cKtesryHm2bgp6l555NYcT2NLvA==", + "dev": true, + "requires": {} + }, + "@typescript-eslint/types": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.0.tgz", + "integrity": "sha512-dTLI8PEXhjUC7B9Kre+u0XznO696BhXcTlOn0/6kf1fHaQW8+VjJAVHJ3eTI14ZapTxdkOmc80HblPQLaEeJdg==", + "dev": true + }, + "@typescript-eslint/typescript-estree": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.0.tgz", + "integrity": "sha512-m7faHcyVg0BT3VdYTlX8GdJEM7COexXxS6KqGopxdtkQRvBanK377QDHr4W/vIPAR+ah9+B/RclSW5ldVniO1Q==", + "dev": true, + "requires": { + "@typescript-eslint/project-service": "8.57.0", + "@typescript-eslint/tsconfig-utils": "8.57.0", + "@typescript-eslint/types": "8.57.0", + "@typescript-eslint/visitor-keys": "8.57.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" + } + }, + "@typescript-eslint/visitor-keys": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.0.tgz", + "integrity": "sha512-zm6xx8UT/Xy2oSr2ZXD0pZo7Jx2XsCoID2IUh9YSTFRu7z+WdwYTRk6LhUftm1crwqbuoF6I8zAFeCMw0YjwDg==", + "dev": true, + "requires": { + "@typescript-eslint/types": "8.57.0", + "eslint-visitor-keys": "^5.0.0" + } + }, + "balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true + }, + "brace-expansion": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "dev": true, + "requires": { + "balanced-match": "^4.0.2" + } + }, + "eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true + }, + "minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "dev": true, + "requires": { + "brace-expansion": "^5.0.2" + } + } } }, "@typescript-eslint/project-service": { @@ -11053,6 +11340,21 @@ "@typescript-eslint/parser": "8.56.1", "@typescript-eslint/typescript-estree": "8.56.1", "@typescript-eslint/utils": "8.56.1" + }, + "dependencies": { + "@typescript-eslint/parser": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.1.tgz", + "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", + "dev": true, + "requires": { + "@typescript-eslint/scope-manager": "8.56.1", + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1", + "debug": "^4.4.3" + } + } } }, "uglify-js": { diff --git a/superset-websocket/package.json b/superset-websocket/package.json index 6c72a3b692a..6ed60ad10d4 100644 --- a/superset-websocket/package.json +++ b/superset-websocket/package.json @@ -34,7 +34,7 @@ "@types/node": "^25.3.3", "@types/ws": "^8.18.1", "@typescript-eslint/eslint-plugin": "^8.55.0", - "@typescript-eslint/parser": "^8.55.0", + "@typescript-eslint/parser": "^8.57.0", "eslint": "^10.0.2", "eslint-config-prettier": "^10.1.8", "eslint-plugin-lodash": "^8.0.0", From d42e9c4d1b7ee3e42c24837c99d45b4b0d1b8f8a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 11 Mar 2026 08:51:10 -0700 Subject: [PATCH 12/58] chore(deps): bump acorn from 8.9.0 to 8.16.0 in /superset-frontend (#38466) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Evan Rusackas --- superset-frontend/package-lock.json | 8 ++++---- .../plugins/plugin-chart-echarts/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index 4bf0e3b6a24..6f7e49a6dfb 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -52941,7 +52941,7 @@ "dependencies": { "@types/d3-array": "^3.2.2", "@types/react-redux": "^7.1.34", - "acorn": "^8.9.0", + "acorn": "^8.16.0", "d3-array": "^3.2.4", "lodash": "^4.17.23", "zod": "^4.3.6" @@ -52957,9 +52957,9 @@ } }, "plugins/plugin-chart-echarts/node_modules/acorn": { - "version": "8.9.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.9.0.tgz", - "integrity": "sha512-jaVNAFBHNLXspO543WnNNPZFRtavh3skAkITqD0/2aeMkKZTN+254PyhwxFYrk3vQ1xfY+2wbesJMs/JC8/PwQ==", + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", "bin": { "acorn": "bin/acorn" diff --git a/superset-frontend/plugins/plugin-chart-echarts/package.json b/superset-frontend/plugins/plugin-chart-echarts/package.json index c56ac505132..459e3ee7a9e 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/package.json +++ b/superset-frontend/plugins/plugin-chart-echarts/package.json @@ -26,7 +26,7 @@ "dependencies": { "@types/d3-array": "^3.2.2", "@types/react-redux": "^7.1.34", - "acorn": "^8.9.0", + "acorn": "^8.16.0", "d3-array": "^3.2.4", "lodash": "^4.17.23", "zod": "^4.3.6" From 3056c415075be8cba3630509007f4a69aafc202d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 11 Mar 2026 08:51:21 -0700 Subject: [PATCH 13/58] chore(deps): bump caniuse-lite from 1.0.30001775 to 1.0.30001777 in /docs (#38463) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Evan Rusackas --- docs/package.json | 2 +- docs/yarn.lock | 13 ++++--------- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/docs/package.json b/docs/package.json index e4a31bf1294..fcfbc81fc1c 100644 --- a/docs/package.json +++ b/docs/package.json @@ -70,7 +70,7 @@ "@swc/core": "^1.15.17", "antd": "^6.3.2", "baseline-browser-mapping": "^2.10.0", - "caniuse-lite": "^1.0.30001775", + "caniuse-lite": "^1.0.30001777", "docusaurus-plugin-openapi-docs": "^4.6.0", "docusaurus-theme-openapi-docs": "^4.6.0", "js-yaml": "^4.1.1", diff --git a/docs/yarn.lock b/docs/yarn.lock index ba6b1120e85..1dcebb8bf84 100644 --- a/docs/yarn.lock +++ b/docs/yarn.lock @@ -6209,15 +6209,10 @@ caniuse-api@^3.0.0: lodash.memoize "^4.1.2" lodash.uniq "^4.5.0" -caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001702, caniuse-lite@^1.0.30001759: - version "1.0.30001770" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001770.tgz#4dc47d3b263a50fbb243448034921e0a88591a84" - integrity sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw== - -caniuse-lite@^1.0.30001775: - version "1.0.30001775" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001775.tgz#9572266e3f7f77efee5deac1efeb4795879d1b7f" - integrity sha512-s3Qv7Lht9zbVKE9XoTyRG6wVDCKdtOFIjBGg3+Yhn6JaytuNKPIjBMTMIY1AnOH3seL5mvF+x33oGAyK3hVt3A== +caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001702, caniuse-lite@^1.0.30001759, caniuse-lite@^1.0.30001777: + version "1.0.30001777" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001777.tgz#028f21e4b2718d138b55e692583e6810ccf60691" + integrity sha512-tmN+fJxroPndC74efCdp12j+0rk0RHwV5Jwa1zWaFVyw2ZxAuPeG8ZgWC3Wz7uSjT3qMRQ5XHZ4COgQmsCMJAQ== ccount@^2.0.0: version "2.0.1" From d752be5f74ab3ae447b0af97d477397f0cd8b959 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 11 Mar 2026 08:51:40 -0700 Subject: [PATCH 14/58] chore(deps): bump dompurify from 3.3.1 to 3.3.2 in /superset-frontend (#38455) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Evan Rusackas --- superset-frontend/package-lock.json | 29 +++++++++++++++++-- .../packages/superset-ui-core/package.json | 2 +- .../legacy-preset-chart-nvd3/package.json | 2 +- 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index 6f7e49a6dfb..18132ae153a 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -23129,6 +23129,7 @@ "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz", "integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==", "license": "(MPL-2.0 OR Apache-2.0)", + "optional": true, "optionalDependencies": { "@types/trusted-types": "^2.0.7" } @@ -51491,7 +51492,7 @@ "d3-time": "^3.1.0", "d3-time-format": "^4.1.0", "dayjs": "^1.11.19", - "dompurify": "^3.3.1", + "dompurify": "^3.3.2", "fetch-retry": "^6.0.0", "handlebars": "^4.7.8", "jed": "^1.1.1", @@ -51593,6 +51594,18 @@ "node": ">=12" } }, + "packages/superset-ui-core/node_modules/dompurify": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.2.tgz", + "integrity": "sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==", + "license": "(MPL-2.0 OR Apache-2.0)", + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "packages/superset-ui-core/node_modules/escape-string-regexp": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", @@ -52849,7 +52862,7 @@ "dependencies": { "d3": "^3.5.17", "d3-tip": "^0.9.1", - "dompurify": "^3.3.1", + "dompurify": "^3.3.2", "fast-safe-stringify": "^2.1.1", "lodash": "^4.17.23", "nvd3-fork": "^2.0.5", @@ -52864,6 +52877,18 @@ "react": "^17.0.2" } }, + "plugins/legacy-preset-chart-nvd3/node_modules/dompurify": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.2.tgz", + "integrity": "sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==", + "license": "(MPL-2.0 OR Apache-2.0)", + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "plugins/plugin-chart-ag-grid-table": { "name": "@superset-ui/plugin-chart-ag-grid-table", "version": "0.20.3", diff --git a/superset-frontend/packages/superset-ui-core/package.json b/superset-frontend/packages/superset-ui-core/package.json index b667e4c4371..74bf4ad3807 100644 --- a/superset-frontend/packages/superset-ui-core/package.json +++ b/superset-frontend/packages/superset-ui-core/package.json @@ -41,7 +41,7 @@ "d3-scale": "^4.0.2", "d3-time": "^3.1.0", "d3-time-format": "^4.1.0", - "dompurify": "^3.3.1", + "dompurify": "^3.3.2", "fetch-retry": "^6.0.0", "handlebars": "^4.7.8", "jed": "^1.1.1", diff --git a/superset-frontend/plugins/legacy-preset-chart-nvd3/package.json b/superset-frontend/plugins/legacy-preset-chart-nvd3/package.json index 640a338a831..c9bcd0a4d41 100644 --- a/superset-frontend/plugins/legacy-preset-chart-nvd3/package.json +++ b/superset-frontend/plugins/legacy-preset-chart-nvd3/package.json @@ -34,7 +34,7 @@ "fast-safe-stringify": "^2.1.1", "lodash": "^4.17.23", "nvd3-fork": "^2.0.5", - "dompurify": "^3.3.1", + "dompurify": "^3.3.2", "prop-types": "^15.8.1", "urijs": "^1.19.11" }, From ffe60bd9602aab3e5e0fdda796674fed211cc7b8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 11 Mar 2026 23:41:41 +0700 Subject: [PATCH 15/58] chore(deps-dev): bump oxlint from 1.51.0 to 1.53.0 in /superset-frontend (#38571) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- superset-frontend/package-lock.json | 160 ++++++++++++++-------------- superset-frontend/package.json | 2 +- 2 files changed, 81 insertions(+), 81 deletions(-) diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index 18132ae153a..2a2ab4ced6f 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -264,7 +264,7 @@ "lightningcss": "^1.32.0", "mini-css-extract-plugin": "^2.10.1", "open-cli": "^8.0.0", - "oxlint": "^1.51.0", + "oxlint": "^1.53.0", "po2json": "^0.4.5", "prettier": "3.8.1", "prettier-plugin-packagejson": "^3.0.2", @@ -8563,9 +8563,9 @@ } }, "node_modules/@oxlint/binding-android-arm-eabi": { - "version": "1.51.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.51.0.tgz", - "integrity": "sha512-jJYIqbx4sX+suIxWstc4P7SzhEwb4ArWA2KVrmEuu9vH2i0qM6QIHz/ehmbGE4/2fZbpuMuBzTl7UkfNoqiSgw==", + "version": "1.53.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.53.0.tgz", + "integrity": "sha512-JC89/jAx4d2zhDIbK8MC4L659FN1WiMXMBkNg7b33KXSkYpUgcbf+0nz7+EPRg+VwWiZVfaoFkNHJ7RXYb5Neg==", "cpu": [ "arm" ], @@ -8580,9 +8580,9 @@ } }, "node_modules/@oxlint/binding-android-arm64": { - "version": "1.51.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.51.0.tgz", - "integrity": "sha512-GtXyBCcH4ti98YdiMNCrpBNGitx87EjEWxevnyhcBK12k/Vu4EzSB45rzSC4fGFUD6sQgeaxItRCEEWeVwPafw==", + "version": "1.53.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.53.0.tgz", + "integrity": "sha512-CY+pZfi+uyeU7AwFrEnjsNT+VfxYmKLMuk7bVxArd8f+09hQbJb8f7C7EpvTfNqrCK1J8zZlaYI4LltmEctgbQ==", "cpu": [ "arm64" ], @@ -8597,9 +8597,9 @@ } }, "node_modules/@oxlint/binding-darwin-arm64": { - "version": "1.51.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.51.0.tgz", - "integrity": "sha512-3QJbeYaMHn6Bh2XeBXuITSsbnIctyTjvHf5nRjKYrT9pPeErNIpp5VDEeAXC0CZSwSVTsc8WOSDwgrAI24JolQ==", + "version": "1.53.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.53.0.tgz", + "integrity": "sha512-0aqsC4HDQ94oI6kMz64iaOJ1f3bCVArxvaHJGOScBvFz6CcQedXi5b70Xg09CYjKNaHA56dW0QJfoZ/111kz1A==", "cpu": [ "arm64" ], @@ -8614,9 +8614,9 @@ } }, "node_modules/@oxlint/binding-darwin-x64": { - "version": "1.51.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.51.0.tgz", - "integrity": "sha512-NzErhMaTEN1cY0E8C5APy74lw5VwsNfJfVPBMWPVQLqAbO0k4FFLjvHURvkUL+Y18Wu+8Vs1kbqPh2hjXYA4pg==", + "version": "1.53.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.53.0.tgz", + "integrity": "sha512-e+KvuaWtnisyWojO/t5qKDbp2dvVpg+1dl4MGnTb21QpY4+4+9Y1XmZPaztcA2XNvy4BIaXFW+9JH9tMpSBqUg==", "cpu": [ "x64" ], @@ -8631,9 +8631,9 @@ } }, "node_modules/@oxlint/binding-freebsd-x64": { - "version": "1.51.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.51.0.tgz", - "integrity": "sha512-msAIh3vPAoKoHlOE/oe6Q5C/n9umypv/k81lED82ibrJotn+3YG2Qp1kiR8o/Dg5iOEU97c6tl0utxcyFenpFw==", + "version": "1.53.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.53.0.tgz", + "integrity": "sha512-hpU0ZHVeblFjmZDfgi9BxhhCpURh0KjoFy5V+Tvp9sg/fRcnMUEfaJrgz+jQfOX4jctlVWrAs1ANs91+5iV+zA==", "cpu": [ "x64" ], @@ -8648,9 +8648,9 @@ } }, "node_modules/@oxlint/binding-linux-arm-gnueabihf": { - "version": "1.51.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.51.0.tgz", - "integrity": "sha512-CqQPcvqYyMe9ZBot2stjGogEzk1z8gGAngIX7srSzrzexmXixwVxBdFZyxTVM0CjGfDeV+Ru0w25/WNjlMM2Hw==", + "version": "1.53.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.53.0.tgz", + "integrity": "sha512-ccKxOpw+X4xa2pO+qbTOpxQ2x1+Ag3ViRQMnWt3gHp1LcpNgS1xd6GYc3OvehmHtrXqEV3YGczZ0I1qpBB4/2A==", "cpu": [ "arm" ], @@ -8665,9 +8665,9 @@ } }, "node_modules/@oxlint/binding-linux-arm-musleabihf": { - "version": "1.51.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.51.0.tgz", - "integrity": "sha512-dstrlYQgZMnyOssxSbolGCge/sDbko12N/35RBNuqLpoPbft2aeBidBAb0dvQlyBd9RJ6u8D4o4Eh8Un6iTgyQ==", + "version": "1.53.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.53.0.tgz", + "integrity": "sha512-UBkBvmzSmlyH2ZObQMDKW/TuyTmUtP/XClPUyU2YLwj0qLopZTZxnDz4VG5d3wz1HQuZXO0o1QqsnQUW1v4a6Q==", "cpu": [ "arm" ], @@ -8682,9 +8682,9 @@ } }, "node_modules/@oxlint/binding-linux-arm64-gnu": { - "version": "1.51.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.51.0.tgz", - "integrity": "sha512-QEjUpXO7d35rP1/raLGGbAsBLLGZIzV3ZbeSjqWlD3oRnxpRIZ6iL4o51XQHkconn3uKssc+1VKdtHJ81BBhDA==", + "version": "1.53.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.53.0.tgz", + "integrity": "sha512-PQJJ1izoH9p61las6rZ0BWOznAhTDMmdUPL2IEBLuXFwhy2mSloYHvRkk39PSYJ1DyG+trqU5Z9ZbtHSGH6plg==", "cpu": [ "arm64" ], @@ -8699,9 +8699,9 @@ } }, "node_modules/@oxlint/binding-linux-arm64-musl": { - "version": "1.51.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.51.0.tgz", - "integrity": "sha512-YSJua5irtG4DoMAjUapDTPhkQLHhBIY0G9JqlZS6/SZPzqDkPku/1GdWs0D6h/wyx0Iz31lNCfIaWKBQhzP0wQ==", + "version": "1.53.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.53.0.tgz", + "integrity": "sha512-GXI1o4Thn/rtnRIL38BwrDMwVcUbIHKCsOixIWf/CkU3fCG3MXFzFTtDMt+34ik0Qk452d8kcpksL0w/hUkMZA==", "cpu": [ "arm64" ], @@ -8716,9 +8716,9 @@ } }, "node_modules/@oxlint/binding-linux-ppc64-gnu": { - "version": "1.51.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.51.0.tgz", - "integrity": "sha512-7L4Wj2IEUNDETKssB9IDYt16T6WlF+X2jgC/hBq3diGHda9vJLpAgb09+D3quFq7TdkFtI7hwz/jmuQmQFPc1Q==", + "version": "1.53.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.53.0.tgz", + "integrity": "sha512-Uahk7IVs2yBamCgeJ3XKpKT9Vh+de0pDKISFKnjEcI3c/w2CFHk1+W6Q6G3KI56HGwE9PWCp6ayhA9whXWkNIQ==", "cpu": [ "ppc64" ], @@ -8733,9 +8733,9 @@ } }, "node_modules/@oxlint/binding-linux-riscv64-gnu": { - "version": "1.51.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.51.0.tgz", - "integrity": "sha512-cBUHqtOXy76G41lOB401qpFoKx1xq17qYkhWrLSM7eEjiHM9sOtYqpr6ZdqCnN9s6ZpzudX4EkeHOFH2E9q0vA==", + "version": "1.53.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.53.0.tgz", + "integrity": "sha512-sWtcU9UkrKMWsGKdFy8R6jkm9Q0VVG1VCpxVuh0HzRQQi3ENI1Nh5CkpsdfUs2MKRcOoHKbXqTscunuXjhxoxQ==", "cpu": [ "riscv64" ], @@ -8750,9 +8750,9 @@ } }, "node_modules/@oxlint/binding-linux-riscv64-musl": { - "version": "1.51.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.51.0.tgz", - "integrity": "sha512-WKbg8CysgZcHfZX0ixQFBRSBvFZUHa3SBnEjHY2FVYt2nbNJEjzTxA3ZR5wMU0NOCNKIAFUFvAh5/XJKPRJuJg==", + "version": "1.53.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.53.0.tgz", + "integrity": "sha512-aXew1+HDvCdExijX/8NBVC854zJwxhKP3l9AHFSHQNo4EanlHtzDMIlIvP3raUkL0vXtFCkTFYezzU5HjstB8A==", "cpu": [ "riscv64" ], @@ -8767,9 +8767,9 @@ } }, "node_modules/@oxlint/binding-linux-s390x-gnu": { - "version": "1.51.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.51.0.tgz", - "integrity": "sha512-N1QRUvJTxqXNSu35YOufdjsAVmKVx5bkrggOWAhTWBc3J4qjcBwr1IfyLh/6YCg8sYRSR1GraldS9jUgJL/U4A==", + "version": "1.53.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.53.0.tgz", + "integrity": "sha512-rVpyBSqPGou9sITcsoXqUoGBUH74bxYLYOAGUqN599Zu6BQBlBU9hh3bJQ/20D1xrhhrsbiCpVPvXpLPM5nL1w==", "cpu": [ "s390x" ], @@ -8784,9 +8784,9 @@ } }, "node_modules/@oxlint/binding-linux-x64-gnu": { - "version": "1.51.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.51.0.tgz", - "integrity": "sha512-e0Mz0DizsCoqNIjeOg6OUKe8JKJWZ5zZlwsd05Bmr51Jo3AOL4UJnPvwKumr4BBtBrDZkCmOLhCvDGm95nJM2g==", + "version": "1.53.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.53.0.tgz", + "integrity": "sha512-eOyeQ8qFQ2geXmlWJuXAOaek0hFhbMLlYsU457NMLKDRoC43Xf+eDPZ9Yk0n9jDaGJ5zBl/3Dy8wo41cnIXuLA==", "cpu": [ "x64" ], @@ -8801,9 +8801,9 @@ } }, "node_modules/@oxlint/binding-linux-x64-musl": { - "version": "1.51.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.51.0.tgz", - "integrity": "sha512-wD8HGTWhYBKXvRDvoBVB1y+fEYV01samhWQSy1Zkxq2vpezvMnjaFKRuiP6tBNITLGuffbNDEXOwcAhJ3gI5Ug==", + "version": "1.53.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.53.0.tgz", + "integrity": "sha512-S6rBArW/zD1tob8M9PwKYrRmz+j1ss1+wjbRAJCWKd7TC3JB6noDiA95pIj9zOZVVp04MIzy5qymnYusrEyXzg==", "cpu": [ "x64" ], @@ -8818,9 +8818,9 @@ } }, "node_modules/@oxlint/binding-openharmony-arm64": { - "version": "1.51.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.51.0.tgz", - "integrity": "sha512-5NSwQ2hDEJ0GPXqikjWtwzgAQCsS7P9aLMNenjjKa+gknN3lTCwwwERsT6lKXSirfU3jLjexA2XQvQALh5h27w==", + "version": "1.53.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.53.0.tgz", + "integrity": "sha512-sd/A0Ny5sN0D/MJtlk7w2jGY4bJQou7gToa9WZF7Sj6HTyVzvlzKJWiOHfr4SulVk4ndiFQ8rKmF9rXP0EcF3A==", "cpu": [ "arm64" ], @@ -8835,9 +8835,9 @@ } }, "node_modules/@oxlint/binding-win32-arm64-msvc": { - "version": "1.51.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.51.0.tgz", - "integrity": "sha512-JEZyah1M0RHMw8d+jjSSJmSmO8sABA1J1RtrHYujGPeCkYg1NeH0TGuClpe2h5QtioRTaF57y/TZfn/2IFV6fA==", + "version": "1.53.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.53.0.tgz", + "integrity": "sha512-QC3q7b51Er/ZurEFcFzc7RpQ/YEoEBLJuCp3WoOzhSHHH/nkUKFy+igOxlj1z3LayhEZPDQQ7sXvv2PM2cdG3Q==", "cpu": [ "arm64" ], @@ -8852,9 +8852,9 @@ } }, "node_modules/@oxlint/binding-win32-ia32-msvc": { - "version": "1.51.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.51.0.tgz", - "integrity": "sha512-q3cEoKH6kwjz/WRyHwSf0nlD2F5Qw536kCXvmlSu+kaShzgrA0ojmh45CA81qL+7udfCaZL2SdKCZlLiGBVFlg==", + "version": "1.53.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.53.0.tgz", + "integrity": "sha512-3OvLgOqwd705hWHV2i8ni80pilvg6BUgpC2+xtVu++e/q28LKVohGh5J5QYJOrRMfWmxK0M/AUu43vUw62LAKQ==", "cpu": [ "ia32" ], @@ -8869,9 +8869,9 @@ } }, "node_modules/@oxlint/binding-win32-x64-msvc": { - "version": "1.51.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.51.0.tgz", - "integrity": "sha512-Q14+fOGb9T28nWF/0EUsYqERiRA7cl1oy4TJrGmLaqhm+aO2cV+JttboHI3CbdeMCAyDI1+NoSlrM7Melhp/cw==", + "version": "1.53.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.53.0.tgz", + "integrity": "sha512-xTiOkntexCdJytZ7ArIIgl3vGW5ujMM3sJNM7/+iqGAVJagCqjFFWn68HRWRLeyT66c95uR+CeFmQFI6mLQqDw==", "cpu": [ "x64" ], @@ -38782,9 +38782,9 @@ } }, "node_modules/oxlint": { - "version": "1.51.0", - "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.51.0.tgz", - "integrity": "sha512-g6DNPaV9/WI9MoX2XllafxQuxwY1TV++j7hP8fTJByVBuCoVtm3dy9f/2vtH/HU40JztcgWF4G7ua+gkainklQ==", + "version": "1.53.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.53.0.tgz", + "integrity": "sha512-TLW0PzGbpO1JxUnuy1pIqVPjQUGh4fNfxu5XJbdFIRFVaJ0UFzTjjk/hSFTMRxN6lZub53xL/IwJNEkrh7VtDg==", "dev": true, "license": "MIT", "bin": { @@ -38797,25 +38797,25 @@ "url": "https://github.com/sponsors/Boshen" }, "optionalDependencies": { - "@oxlint/binding-android-arm-eabi": "1.51.0", - "@oxlint/binding-android-arm64": "1.51.0", - "@oxlint/binding-darwin-arm64": "1.51.0", - "@oxlint/binding-darwin-x64": "1.51.0", - "@oxlint/binding-freebsd-x64": "1.51.0", - "@oxlint/binding-linux-arm-gnueabihf": "1.51.0", - "@oxlint/binding-linux-arm-musleabihf": "1.51.0", - "@oxlint/binding-linux-arm64-gnu": "1.51.0", - "@oxlint/binding-linux-arm64-musl": "1.51.0", - "@oxlint/binding-linux-ppc64-gnu": "1.51.0", - "@oxlint/binding-linux-riscv64-gnu": "1.51.0", - "@oxlint/binding-linux-riscv64-musl": "1.51.0", - "@oxlint/binding-linux-s390x-gnu": "1.51.0", - "@oxlint/binding-linux-x64-gnu": "1.51.0", - "@oxlint/binding-linux-x64-musl": "1.51.0", - "@oxlint/binding-openharmony-arm64": "1.51.0", - "@oxlint/binding-win32-arm64-msvc": "1.51.0", - "@oxlint/binding-win32-ia32-msvc": "1.51.0", - "@oxlint/binding-win32-x64-msvc": "1.51.0" + "@oxlint/binding-android-arm-eabi": "1.53.0", + "@oxlint/binding-android-arm64": "1.53.0", + "@oxlint/binding-darwin-arm64": "1.53.0", + "@oxlint/binding-darwin-x64": "1.53.0", + "@oxlint/binding-freebsd-x64": "1.53.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.53.0", + "@oxlint/binding-linux-arm-musleabihf": "1.53.0", + "@oxlint/binding-linux-arm64-gnu": "1.53.0", + "@oxlint/binding-linux-arm64-musl": "1.53.0", + "@oxlint/binding-linux-ppc64-gnu": "1.53.0", + "@oxlint/binding-linux-riscv64-gnu": "1.53.0", + "@oxlint/binding-linux-riscv64-musl": "1.53.0", + "@oxlint/binding-linux-s390x-gnu": "1.53.0", + "@oxlint/binding-linux-x64-gnu": "1.53.0", + "@oxlint/binding-linux-x64-musl": "1.53.0", + "@oxlint/binding-openharmony-arm64": "1.53.0", + "@oxlint/binding-win32-arm64-msvc": "1.53.0", + "@oxlint/binding-win32-ia32-msvc": "1.53.0", + "@oxlint/binding-win32-x64-msvc": "1.53.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.15.0" diff --git a/superset-frontend/package.json b/superset-frontend/package.json index f35228484c0..107ca45ec74 100644 --- a/superset-frontend/package.json +++ b/superset-frontend/package.json @@ -345,7 +345,7 @@ "lightningcss": "^1.32.0", "mini-css-extract-plugin": "^2.10.1", "open-cli": "^8.0.0", - "oxlint": "^1.51.0", + "oxlint": "^1.53.0", "po2json": "^0.4.5", "prettier": "3.8.1", "prettier-plugin-packagejson": "^3.0.2", From 27197faba97e7926008f3744e873c107f8e5f05e Mon Sep 17 00:00:00 2001 From: Alexandru Soare <37236580+alexandrusoare@users.noreply.github.com> Date: Thu, 12 Mar 2026 11:39:29 +0200 Subject: [PATCH 16/58] fix(matrixify): Matrixify to not override slice id (#38515) Co-authored-by: Evan Rusackas --- .../Matrixify/MatrixifyGridGenerator.test.ts | 20 +++++++++++++++++++ .../Matrixify/MatrixifyGridGenerator.ts | 2 +- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/superset-frontend/packages/superset-ui-core/src/chart/components/Matrixify/MatrixifyGridGenerator.test.ts b/superset-frontend/packages/superset-ui-core/src/chart/components/Matrixify/MatrixifyGridGenerator.test.ts index a2915fef438..8f660e1a776 100644 --- a/superset-frontend/packages/superset-ui-core/src/chart/components/Matrixify/MatrixifyGridGenerator.test.ts +++ b/superset-frontend/packages/superset-ui-core/src/chart/components/Matrixify/MatrixifyGridGenerator.test.ts @@ -438,3 +438,23 @@ test('should handle metrics without labels', () => { expect(grid!.rowHeaders).toEqual(['']); expect(grid!.colHeaders).toEqual(['count']); }); + +test('should preserve slice_id and dashboardId for embedded dashboard permissions', () => { + const formDataWithDashboardContext: TestFormData = { + ...baseFormData, + slice_id: 42, + dashboardId: 123, + }; + + const grid = generateMatrixifyGrid(formDataWithDashboardContext); + + expect(grid).not.toBeNull(); + const cell = grid!.cells[0][0]; + + // slice_id must be preserved for embedded dashboard permission checks + // The backend uses slice_id to verify the chart belongs to the dashboard + expect(cell!.formData.slice_id).toBe(42); + + // dashboardId must be preserved for embedded dashboard context + expect(cell!.formData.dashboardId).toBe(123); +}); diff --git a/superset-frontend/packages/superset-ui-core/src/chart/components/Matrixify/MatrixifyGridGenerator.ts b/superset-frontend/packages/superset-ui-core/src/chart/components/Matrixify/MatrixifyGridGenerator.ts index cc12d396b9e..59c5f9ed733 100644 --- a/superset-frontend/packages/superset-ui-core/src/chart/components/Matrixify/MatrixifyGridGenerator.ts +++ b/superset-frontend/packages/superset-ui-core/src/chart/components/Matrixify/MatrixifyGridGenerator.ts @@ -125,9 +125,9 @@ function generateCellFormData( }); // Override fields that could cause issues in grid cells + // Note: slice_id is intentionally preserved for embedded dashboard permission checks const overrides: Partial = { slice_name: undefined, - slice_id: undefined, header_font_size: undefined, subheader: undefined, show_title: undefined, From a9def2fc1524093761cce2e73d120ebe3d00ca55 Mon Sep 17 00:00:00 2001 From: Ville Brofeldt <33317356+villebro@users.noreply.github.com> Date: Thu, 12 Mar 2026 04:08:58 -0700 Subject: [PATCH 17/58] fix: support nested function calls in cache_key_wrapper (#38569) --- superset/jinja_context.py | 14 +++++++------- tests/unit_tests/jinja_context_test.py | 21 +++++++++++++++++++++ 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/superset/jinja_context.py b/superset/jinja_context.py index dc9411030e5..15b8d0495a1 100644 --- a/superset/jinja_context.py +++ b/superset/jinja_context.py @@ -122,13 +122,13 @@ class ExtraCache: # be added to the cache key. regex = re.compile( r"(\{\{|\{%)[^{}]*?(" - r"current_user_id\([^()]*\)|" - r"current_username\([^()]*\)|" - r"current_user_email\([^()]*\)|" - r"current_user_rls_rules\([^()]*\)|" - r"current_user_roles\([^()]*\)|" - r"cache_key_wrapper\([^()]*\)|" - r"url_param\([^()]*\)" + r"current_user_id\([^)]*\)|" + r"current_username\([^)]*\)|" + r"current_user_email\([^)]*\)|" + r"current_user_rls_rules\([^)]*\)|" + r"current_user_roles\([^)]*\)|" + r"cache_key_wrapper\([^)]*\)|" + r"url_param\([^)]*\)" r")" r"[^{}]*?(\}\}|\%\})" ) diff --git a/tests/unit_tests/jinja_context_test.py b/tests/unit_tests/jinja_context_test.py index 929c470b315..9ba84315fac 100644 --- a/tests/unit_tests/jinja_context_test.py +++ b/tests/unit_tests/jinja_context_test.py @@ -1693,3 +1693,24 @@ def test_undefined_template_variable_not_function(mocker: MockerFixture) -> None template = "SELECT {{ undefined_variable.some_method() }}" with pytest.raises(UndefinedError): processor.process_template(template) + + +@pytest.mark.parametrize( + ("sql", "expected"), + [ + ("SELECT {{ cache_key_wrapper(abc) }}", True), + ("SELECT {{ cache_key_wrapper(myfunc()) }}", True), + ("SELECT {{ url_param('foo') }}", True), + ("SELECT {{ url_param(get_param('foo')) }}", True), + ("SELECT {{ current_user_id() }}", True), + ("SELECT {{ current_username() }}", True), + ("SELECT {{ current_user_email() }}", True), + ("SELECT {{ current_user_roles() }}", True), + ("SELECT {{ current_user_rls_rules() }}", True), + ("SELECT 'cache_key_wrapper(abc)' AS false_positive", False), + ("SELECT 1", False), + ("SELECT '{{ 1 + 1 }}'", False), + ], +) +def test_extra_cache_regex(sql: str, expected: bool) -> None: + assert bool(ExtraCache.regex.search(sql)) is expected From 09e9c6a522ecf3cebb618fec3a965d2b55f298ad Mon Sep 17 00:00:00 2001 From: Yuriy Krasilnikov <30294585+YuriyKrasilnikov@users.noreply.github.com> Date: Thu, 12 Mar 2026 16:12:59 +0300 Subject: [PATCH 18/58] fix(embedded): prevent double RLS application in virtual datasets (#37395) --- superset/connectors/sqla/models.py | 12 +- superset/models/helpers.py | 1 + superset/utils/rls.py | 14 +- .../models/test_double_rls_virtual_dataset.py | 286 +++++++++++++++++ tests/unit_tests/security/guest_rls_test.py | 298 ++++++++++++++++++ tests/unit_tests/sql_lab_test.py | 3 + 6 files changed, 610 insertions(+), 4 deletions(-) create mode 100644 tests/unit_tests/models/test_double_rls_virtual_dataset.py create mode 100644 tests/unit_tests/security/guest_rls_test.py diff --git a/superset/connectors/sqla/models.py b/superset/connectors/sqla/models.py index 1514a944e98..b49e9ba4d33 100644 --- a/superset/connectors/sqla/models.py +++ b/superset/connectors/sqla/models.py @@ -740,15 +740,19 @@ class BaseDatasource( def get_sqla_row_level_filters( self, template_processor: Optional[BaseTemplateProcessor] = None, + include_global_guest_rls: bool = True, ) -> list[TextClause]: """ Return the appropriate row level security filters for this table and the - current user. A custom username can be passed when the user is not present in the - Flask global namespace. + current user. :param template_processor: The template processor to apply to the filters. + :param include_global_guest_rls: Whether to include global (unscoped) guest + RLS filters. Set to False for underlying tables in virtual datasets to + prevent double application of global guest rules. Dataset-scoped guest + rules are always included regardless of this parameter. :returns: A list of SQL clauses to be ANDed together. - """ # noqa: E501 + """ template_processor = template_processor or self.get_template_processor() all_filters: list[TextClause] = [] @@ -765,6 +769,8 @@ class BaseDatasource( if is_feature_enabled("EMBEDDED_SUPERSET"): for rule in security_manager.get_guest_rls_filters(self): + if not include_global_guest_rls and not rule.get("dataset"): + continue clause = self.text( f"({template_processor.process_template(rule['clause'])})" ) diff --git a/superset/models/helpers.py b/superset/models/helpers.py index f6f28f63850..704b0f4c56a 100644 --- a/superset/models/helpers.py +++ b/superset/models/helpers.py @@ -903,6 +903,7 @@ class ExploreMixin: # pylint: disable=too-many-public-methods def get_sqla_row_level_filters( self, template_processor: Optional[BaseTemplateProcessor] = None, # pylint: disable=unused-argument + include_global_guest_rls: bool = True, # pylint: disable=unused-argument ) -> list[TextClause]: # TODO: We should refactor this mixin and remove this method # as it exists in the BaseDatasource and is not applicable diff --git a/superset/utils/rls.py b/superset/utils/rls.py index 43f7da9ffe8..05f8ea9058c 100644 --- a/superset/utils/rls.py +++ b/superset/utils/rls.py @@ -98,6 +98,16 @@ def get_predicates_for_table( if not dataset: return [] + # Exclude global (unscoped) guest RLS to prevent double application in + # virtual datasets. Global guest rules will be applied to the outer query + # via get_sqla_row_level_filters() on the virtual dataset itself. + # Dataset-scoped guest rules are still included here because they target + # this specific physical dataset and won't match on the outer query. + # Note: this path is also used by SQL Lab (sql_lab.py, executor.py) via + # apply_rls(). Guest users with the default Public role cannot access SQL Lab + # (PUBLIC_EXCLUDED_VIEW_MENUS in security/manager.py). If the guest role is + # extended to include SQL Lab access, global guest RLS predicates for + # underlying tables would be skipped here. return [ str( predicate.compile( @@ -105,7 +115,9 @@ def get_predicates_for_table( compile_kwargs={"literal_binds": True}, ) ) - for predicate in dataset.get_sqla_row_level_filters() + for predicate in dataset.get_sqla_row_level_filters( + include_global_guest_rls=False + ) ] diff --git a/tests/unit_tests/models/test_double_rls_virtual_dataset.py b/tests/unit_tests/models/test_double_rls_virtual_dataset.py new file mode 100644 index 00000000000..161f1adf7cc --- /dev/null +++ b/tests/unit_tests/models/test_double_rls_virtual_dataset.py @@ -0,0 +1,286 @@ +# 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. +""" +Tests for double RLS application in virtual datasets (Issue #37359). + +This module tests that guest user RLS filters are applied only once +when querying virtual datasets, not both in the underlying table SQL +and the outer query. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest +from flask import Flask +from sqlalchemy.sql.elements import TextClause + +from superset.connectors.sqla.models import BaseDatasource + + +@pytest.fixture +def mock_datasource() -> MagicMock: + """Create a mock datasource for testing.""" + datasource = MagicMock(spec=BaseDatasource) + datasource.get_template_processor.return_value = MagicMock() + datasource.get_template_processor.return_value.process_template = lambda x: x + datasource.text = lambda x: TextClause(x) + return datasource + + +def test_rls_filters_include_guest_when_enabled( + mock_datasource: MagicMock, + app: Flask, +) -> None: + """ + Test that RLS filters include guest filters when enabled. + + When include_global_guest_rls=True and EMBEDDED_SUPERSET is enabled, + both regular and guest RLS filters should be returned. + """ + regular_filter = MagicMock() + regular_filter.clause = "col1 = 'value1'" + regular_filter.group_key = None + + guest_rule = {"clause": "col2 = 'value2'"} + + with ( + patch( + "superset.connectors.sqla.models.security_manager.get_rls_filters", + return_value=[regular_filter], + ), + patch( + "superset.connectors.sqla.models.security_manager.get_guest_rls_filters", + return_value=[guest_rule], + ), + patch( + "superset.connectors.sqla.models.is_feature_enabled", + return_value=True, + ), + ): + # Call with include_global_guest_rls=True + filters = BaseDatasource.get_sqla_row_level_filters( + mock_datasource, include_global_guest_rls=True + ) + + # Should include both regular and guest RLS + assert len(filters) == 2 + filter_strs = [str(f) for f in filters] + assert any("col1" in s for s in filter_strs) + assert any("col2" in s for s in filter_strs) + + +def test_rls_filters_exclude_guest_when_requested( + mock_datasource: MagicMock, + app: Flask, +) -> None: + """ + Test that RLS filters exclude guest filters when requested. + + Issue #37359: When analyzing underlying tables in virtual datasets, + guest RLS should be excluded to prevent double application. + """ + regular_filter = MagicMock() + regular_filter.clause = "col1 = 'value1'" + regular_filter.group_key = None + + guest_rule = {"clause": "col2 = 'value2'"} + + with ( + patch( + "superset.connectors.sqla.models.security_manager.get_rls_filters", + return_value=[regular_filter], + ), + patch( + "superset.connectors.sqla.models.security_manager.get_guest_rls_filters", + return_value=[guest_rule], + ), + patch( + "superset.connectors.sqla.models.is_feature_enabled", + return_value=True, + ), + ): + # Call internal API with include_global_guest_rls=False + filters = BaseDatasource.get_sqla_row_level_filters( + mock_datasource, include_global_guest_rls=False + ) + + # Should include only regular RLS, not guest RLS + assert len(filters) == 1 + filter_strs = [str(f) for f in filters] + assert any("col1" in s for s in filter_strs) + assert not any("col2" in s for s in filter_strs) + + +def test_rls_filters_include_guest_by_default( + mock_datasource: MagicMock, + app: Flask, +) -> None: + """ + Test that RLS filters include guest filters by default. + + The default behavior (include_global_guest_rls=True) ensures backwards + compatibility with existing code. + """ + regular_filter = MagicMock() + regular_filter.clause = "col1 = 'value1'" + regular_filter.group_key = None + + guest_rule = {"clause": "col2 = 'value2'"} + + with ( + patch( + "superset.connectors.sqla.models.security_manager.get_rls_filters", + return_value=[regular_filter], + ), + patch( + "superset.connectors.sqla.models.security_manager.get_guest_rls_filters", + return_value=[guest_rule], + ), + patch( + "superset.connectors.sqla.models.is_feature_enabled", + return_value=True, + ), + ): + # Call internal API with default include_global_guest_rls=True + filters = BaseDatasource.get_sqla_row_level_filters(mock_datasource) + + # Should include both regular and guest RLS + assert len(filters) == 2 + + +def test_regular_rls_always_included( + mock_datasource: MagicMock, + app: Flask, +) -> None: + """ + Test that regular (non-guest) RLS is always included. + + Even when include_global_guest_rls=False, regular RLS filters must still + be applied to underlying tables in virtual datasets. + """ + regular_filter = MagicMock() + regular_filter.clause = "tenant_id = 123" + regular_filter.group_key = None + + with ( + patch( + "superset.connectors.sqla.models.security_manager.get_rls_filters", + return_value=[regular_filter], + ), + patch( + "superset.connectors.sqla.models.security_manager.get_guest_rls_filters", + return_value=[], + ), + patch( + "superset.connectors.sqla.models.is_feature_enabled", + return_value=True, + ), + ): + # Call internal API with include_global_guest_rls=False + filters = BaseDatasource.get_sqla_row_level_filters( + mock_datasource, include_global_guest_rls=False + ) + + # Regular RLS should still be included + assert len(filters) == 1 + assert "tenant_id" in str(filters[0]) + + +def test_guest_rls_skipped_when_feature_disabled( + mock_datasource: MagicMock, + app: Flask, +) -> None: + """ + Test that guest RLS is skipped when EMBEDDED_SUPERSET is disabled. + + This verifies that the feature flag is respected regardless of + the include_global_guest_rls parameter. + """ + regular_filter = MagicMock() + regular_filter.clause = "col1 = 'value1'" + regular_filter.group_key = None + + guest_rule = {"clause": "col2 = 'value2'"} + + with ( + patch( + "superset.connectors.sqla.models.security_manager.get_rls_filters", + return_value=[regular_filter], + ), + patch( + "superset.connectors.sqla.models.security_manager.get_guest_rls_filters", + return_value=[guest_rule], + ), + patch( + "superset.connectors.sqla.models.is_feature_enabled", + return_value=False, # Feature disabled + ), + ): + # Even with include_global_guest_rls=True, feature flag takes precedence + filters = BaseDatasource.get_sqla_row_level_filters( + mock_datasource, include_global_guest_rls=True + ) + + # Should include only regular RLS + assert len(filters) == 1 + assert not any("col2" in str(f) for f in filters) + + +def test_filter_grouping_preserved( + mock_datasource: MagicMock, + app: Flask, +) -> None: + """ + Test that filter grouping logic is preserved in internal method. + + Filters with the same group_key should be ORed together, while + different groups are ANDed. + """ + filter1 = MagicMock() + filter1.clause = "region = 'US'" + filter1.group_key = "region_group" + + filter2 = MagicMock() + filter2.clause = "region = 'EU'" + filter2.group_key = "region_group" + + filter3 = MagicMock() + filter3.clause = "active = true" + filter3.group_key = None + + with ( + patch( + "superset.connectors.sqla.models.security_manager.get_rls_filters", + return_value=[filter1, filter2, filter3], + ), + patch( + "superset.connectors.sqla.models.security_manager.get_guest_rls_filters", + return_value=[], + ), + patch( + "superset.connectors.sqla.models.is_feature_enabled", + return_value=False, + ), + ): + filters = BaseDatasource.get_sqla_row_level_filters( + mock_datasource, include_global_guest_rls=False + ) + + # Should have 2 filters: one ungrouped, one grouped (ORed) + assert len(filters) == 2 diff --git a/tests/unit_tests/security/guest_rls_test.py b/tests/unit_tests/security/guest_rls_test.py new file mode 100644 index 00000000000..6210570d66f --- /dev/null +++ b/tests/unit_tests/security/guest_rls_test.py @@ -0,0 +1,298 @@ +# 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. +""" +Tests for guest RLS scoping in virtual dataset scenarios. + +Verifies that dataset-scoped guest RLS rules are correctly applied +when querying through virtual datasets, and that global (unscoped) +guest rules are not duplicated across inner and outer queries. +""" + +from __future__ import annotations + +from typing import Callable +from unittest.mock import MagicMock, patch + +from flask import Flask +from pytest_mock import MockerFixture +from sqlalchemy.sql.elements import TextClause + +from superset.connectors.sqla.models import BaseDatasource +from superset.security.guest_token import ( + GuestToken, + GuestTokenResourceType, + GuestTokenRlsRule, + GuestUser, +) +from superset.sql.parse import Table +from superset.utils.rls import get_predicates_for_table + + +def _make_datasource(dataset_id: int) -> MagicMock: + datasource = MagicMock(spec=BaseDatasource) + datasource.get_template_processor.return_value = MagicMock() + datasource.get_template_processor.return_value.process_template = lambda x: x + datasource.text = lambda x: TextClause(x) + datasource.data = {"id": dataset_id} + datasource.is_rls_supported = True + return datasource + + +def _make_guest_user(rules: list[GuestTokenRlsRule]) -> GuestUser: + token: GuestToken = { + "user": {}, + "resources": [ + {"type": GuestTokenResourceType.DASHBOARD, "id": "test-dashboard-uuid"} + ], + "rls_rules": rules, + "iat": 10, + "exp": 20, + } + return GuestUser(token=token, roles=[]) + + +def _guest_rls_filter( + guest_user: GuestUser, +) -> Callable[[MagicMock], list[GuestTokenRlsRule]]: + """Replicate real get_guest_rls_filters logic from manager.py:2709-2714.""" + + def _filter(dataset: MagicMock) -> list[GuestTokenRlsRule]: + return [ + rule + for rule in guest_user.rls + if not rule.get("dataset") + or str(rule.get("dataset")) == str(dataset.data["id"]) + ] + + return _filter + + +def test_scoped_guest_rule_preserved_in_virtual_dataset(app: Flask) -> None: + """ + Dataset-scoped guest RLS rule is preserved when querying through + a virtual dataset. + + Path A (inner SQL): include_global_guest_rls=False skips only global rules, + but dataset-scoped rules matching PD are kept. + Path B (outer query): get_guest_rls_filters(VD) doesn't match because + rule.dataset=42 != VD.id=99, but that's OK because + the rule was already applied in Path A. + """ + pd_id = 42 + vd_id = 99 + + scoped_rule = GuestTokenRlsRule(dataset=str(pd_id), clause="tenant_id = 5") + guest_user = _make_guest_user(rules=[scoped_rule]) + + mock_pd = _make_datasource(pd_id) + mock_vd = _make_datasource(vd_id) + + with ( + patch( + "superset.connectors.sqla.models.security_manager.get_rls_filters", + return_value=[], + ), + patch( + "superset.connectors.sqla.models.security_manager.get_guest_rls_filters", + wraps=_guest_rls_filter(guest_user), + ), + patch( + "superset.connectors.sqla.models.is_feature_enabled", + return_value=True, + ), + ): + # PATH A: what get_predicates_for_table() calls on PD + inner_filters = BaseDatasource.get_sqla_row_level_filters( + mock_pd, include_global_guest_rls=False + ) + + # PATH B: what get_sqla_query() calls on VD + outer_filters = BaseDatasource.get_sqla_row_level_filters( + mock_vd, include_global_guest_rls=True + ) + + inner_has_rule = any("tenant_id" in str(f) for f in inner_filters) + outer_has_rule = any("tenant_id" in str(f) for f in outer_filters) + + # Scoped rule must appear in inner SQL (PD matches rule.dataset) + assert inner_has_rule, ( + f"Dataset-scoped guest rule 'tenant_id = 5' (scoped to PD " + f"id={pd_id}) should be applied in inner SQL. " + f"inner_filters={[str(f) for f in inner_filters]}" + ) + # Scoped rule should NOT appear in outer SQL (VD.id != rule.dataset) + assert not outer_has_rule, ( + f"Dataset-scoped guest rule should not match VD id={vd_id}. " + f"outer_filters={[str(f) for f in outer_filters]}" + ) + + +def test_unscoped_guest_rule_duplicated_in_virtual_dataset(app: Flask) -> None: + """ + Unscoped (global) guest rules match ANY dataset — these are the ones + that get duplicated in virtual datasets (the original issue #37359). + + The fix should selectively filter only unscoped rules from inner SQL, + not all guest rules. + """ + pd_id = 42 + vd_id = 99 + + unscoped_rule = GuestTokenRlsRule(dataset=None, clause="org_id = 1") + guest_user = _make_guest_user(rules=[unscoped_rule]) + + mock_pd = _make_datasource(pd_id) + mock_vd = _make_datasource(vd_id) + + with ( + patch( + "superset.connectors.sqla.models.security_manager.get_rls_filters", + return_value=[], + ), + patch( + "superset.connectors.sqla.models.security_manager.get_guest_rls_filters", + wraps=_guest_rls_filter(guest_user), + ), + patch( + "superset.connectors.sqla.models.is_feature_enabled", + return_value=True, + ), + ): + # Both paths with guest RLS enabled (before the fix) + inner_filters = BaseDatasource.get_sqla_row_level_filters( + mock_pd, include_global_guest_rls=True + ) + outer_filters = BaseDatasource.get_sqla_row_level_filters( + mock_vd, include_global_guest_rls=True + ) + + inner_has_rule = any("org_id" in str(f) for f in inner_filters) + outer_has_rule = any("org_id" in str(f) for f in outer_filters) + + assert inner_has_rule, "Unscoped rule should match PD" + assert outer_has_rule, "Unscoped rule should match VD" + + +def _make_datasource_with_real_rls(dataset_id: int) -> MagicMock: + """Create a mock datasource with real get_sqla_row_level_filters.""" + datasource = _make_datasource(dataset_id) + # Bind real BaseDatasource method so RLS logic executes against mocked + # security_manager rather than returning MagicMock auto-stub + datasource.get_sqla_row_level_filters = ( + lambda **kwargs: BaseDatasource.get_sqla_row_level_filters(datasource, **kwargs) + ) + return datasource + + +def test_scoped_guest_rule_preserved_through_get_predicates_for_table( + app: Flask, + mocker: MockerFixture, +) -> None: + """ + Dataset-scoped guest RLS rules are preserved when + get_predicates_for_table() calls get_sqla_row_level_filters() + with include_global_guest_rls=False. + + Exercises the full path: get_predicates_for_table() → DB lookup → + get_sqla_row_level_filters(include_global_guest_rls=False) → + get_guest_rls_filters() returns scoped rule → predicate included. + """ + from sqlalchemy.dialects import sqlite + + pd_id = 42 + scoped_rule = GuestTokenRlsRule(dataset=str(pd_id), clause="tenant_id = 5") + guest_user = _make_guest_user(rules=[scoped_rule]) + + mock_pd = _make_datasource_with_real_rls(pd_id) + + database = mocker.MagicMock() + database.get_dialect.return_value = sqlite.dialect() + db = mocker.patch("superset.utils.rls.db") + db.session.query().filter().one_or_none.return_value = mock_pd + + with ( + patch( + "superset.connectors.sqla.models.security_manager.get_rls_filters", + return_value=[], + ), + patch( + "superset.connectors.sqla.models.security_manager.get_guest_rls_filters", + wraps=_guest_rls_filter(guest_user), + ), + patch( + "superset.connectors.sqla.models.is_feature_enabled", + return_value=True, + ), + ): + table = Table("physical_table", "public", "examples") + predicates = get_predicates_for_table(table, database, "examples") + + assert any("tenant_id" in p for p in predicates), ( + f"SECURITY BYPASS via get_predicates_for_table: " + f"dataset-scoped guest rule 'tenant_id = 5' (scoped to PD " + f"id={pd_id}) not found in predicates. Got: {predicates}" + ) + + +def test_global_guest_rule_excluded_through_get_predicates_for_table( + app: Flask, + mocker: MockerFixture, +) -> None: + """ + Global (unscoped) guest RLS rules are excluded when + get_predicates_for_table() calls get_sqla_row_level_filters() + with include_global_guest_rls=False. + + This prevents double application: global guest rules match any dataset, + so they would appear both in inner SQL (underlying table) and outer query + (virtual dataset). The fix excludes them from the inner SQL path. + """ + from sqlalchemy.dialects import sqlite + + pd_id = 42 + global_rule = GuestTokenRlsRule(dataset=None, clause="org_id = 1") + guest_user = _make_guest_user(rules=[global_rule]) + + mock_pd = _make_datasource_with_real_rls(pd_id) + + database = mocker.MagicMock() + database.get_dialect.return_value = sqlite.dialect() + db = mocker.patch("superset.utils.rls.db") + db.session.query().filter().one_or_none.return_value = mock_pd + + with ( + patch( + "superset.connectors.sqla.models.security_manager.get_rls_filters", + return_value=[], + ), + patch( + "superset.connectors.sqla.models.security_manager.get_guest_rls_filters", + wraps=_guest_rls_filter(guest_user), + ), + patch( + "superset.connectors.sqla.models.is_feature_enabled", + return_value=True, + ), + ): + table = Table("physical_table", "public", "examples") + predicates = get_predicates_for_table(table, database, "examples") + + assert not any("org_id" in p for p in predicates), ( + f"Global guest rule 'org_id = 1' should be excluded from " + f"get_predicates_for_table() to prevent double application " + f"in virtual datasets. Got: {predicates}" + ) diff --git a/tests/unit_tests/sql_lab_test.py b/tests/unit_tests/sql_lab_test.py index 9fd3a0ac0e2..f9e3f50cefe 100644 --- a/tests/unit_tests/sql_lab_test.py +++ b/tests/unit_tests/sql_lab_test.py @@ -326,3 +326,6 @@ def test_get_predicates_for_table(mocker: MockerFixture) -> None: table = Table("t1", "public", "examples") assert get_predicates_for_table(table, database, "examples") == ["c1 = 1"] + dataset.get_sqla_row_level_filters.assert_called_once_with( + include_global_guest_rls=False + ) From 7943af359c2ec8bcc1dbd215278571a89eac4a5c Mon Sep 17 00:00:00 2001 From: Amin Ghadersohi Date: Thu, 12 Mar 2026 17:35:07 +0100 Subject: [PATCH 19/58] feat(mcp): implement RBAC permission checking for MCP tools (#38407) Co-authored-by: Claude Opus 4.6 --- .../src/superset_core/mcp/decorators.py | 22 +- superset/core/mcp/core_mcp_injection.py | 24 +- superset/mcp_service/auth.py | 134 +++++++++-- .../mcp_service/chart/tool/generate_chart.py | 2 +- .../mcp_service/chart/tool/get_chart_data.py | 2 +- .../mcp_service/chart/tool/get_chart_info.py | 2 +- .../chart/tool/get_chart_preview.py | 2 +- .../mcp_service/chart/tool/list_charts.py | 2 +- .../mcp_service/chart/tool/update_chart.py | 2 +- .../chart/tool/update_chart_preview.py | 2 +- .../tool/add_chart_to_existing_dashboard.py | 2 +- .../dashboard/tool/generate_dashboard.py | 2 +- .../dashboard/tool/get_dashboard_info.py | 2 +- .../dashboard/tool/list_dashboards.py | 2 +- .../dataset/tool/get_dataset_info.py | 2 +- .../mcp_service/dataset/tool/list_datasets.py | 2 +- .../explore/tool/generate_explore_link.py | 2 +- superset/mcp_service/mcp_config.py | 5 + .../mcp_service/sql_lab/tool/execute_sql.py | 6 +- .../sql_lab/tool/open_sql_lab_with_context.py | 2 +- tests/unit_tests/mcp_service/conftest.py | 19 +- .../unit_tests/mcp_service/test_auth_rbac.py | 225 ++++++++++++++++++ 22 files changed, 430 insertions(+), 35 deletions(-) create mode 100644 tests/unit_tests/mcp_service/test_auth_rbac.py diff --git a/superset-core/src/superset_core/mcp/decorators.py b/superset-core/src/superset_core/mcp/decorators.py index d8c814a1acd..6ad29c9adc5 100644 --- a/superset-core/src/superset_core/mcp/decorators.py +++ b/superset-core/src/superset_core/mcp/decorators.py @@ -48,11 +48,14 @@ def tool( description: str | None = None, tags: list[str] | None = None, protect: bool = True, + class_permission_name: str | None = None, + method_permission_name: str | None = None, ) -> Any: # Use Any to avoid mypy issues with dependency injection """ Decorator to register an MCP tool with optional authentication. - This decorator combines FastMCP tool registration with optional authentication. + This decorator combines FastMCP tool registration with optional authentication + and RBAC permission checking. Can be used as: @tool @@ -69,6 +72,11 @@ def tool( description: Tool description (defaults to function docstring) tags: List of tags for categorizing the tool (defaults to empty list) protect: Whether to require Superset authentication (defaults to True) + class_permission_name: FAB view/resource name for RBAC checking + (e.g., "Chart", "Dashboard", "SQLLab"). When set, enables + permission checking via security_manager.can_access(). + method_permission_name: FAB action name (e.g., "read", "write"). + Defaults to "write" if tags includes "mutate", else "read". Returns: Decorator function that registers and wraps the tool, or the wrapped function @@ -90,6 +98,18 @@ def tool( def public_tool() -> str: '''Public tool accessible without auth''' return "Hello world" + + @tool(class_permission_name="Chart") # RBAC: requires can_read on Chart + def list_charts() -> list: + '''List charts the user can access''' + return [] + + @tool( # RBAC: can_write on Chart + tags=["mutate"], class_permission_name="Chart", + ) + def create_chart(name: str) -> dict: + '''Create a new chart''' + return {"name": name} """ raise NotImplementedError( "MCP tool decorator not initialized. " diff --git a/superset/core/mcp/core_mcp_injection.py b/superset/core/mcp/core_mcp_injection.py index ad4a7b1211d..fa2ec23738a 100644 --- a/superset/core/mcp/core_mcp_injection.py +++ b/superset/core/mcp/core_mcp_injection.py @@ -60,12 +60,14 @@ def create_tool_decorator( description: Optional[str] = None, tags: Optional[list[str]] = None, protect: bool = True, + class_permission_name: Optional[str] = None, + method_permission_name: Optional[str] = None, ) -> Callable[[F], F] | F: """ Create the concrete MCP tool decorator implementation. - This combines FastMCP tool registration with optional Superset authentication, - replacing the need for separate @mcp.tool and @mcp_auth_hook decorators. + This combines FastMCP tool registration with optional Superset authentication + and RBAC permission checking. Supports both @tool and @tool() syntax. @@ -76,6 +78,10 @@ def create_tool_decorator( description: Tool description (defaults to function docstring) tags: List of tags for categorization (defaults to empty list) protect: Whether to apply Superset authentication (defaults to True) + class_permission_name: FAB view/resource name for RBAC + (e.g., "Chart", "Dashboard", "SQLLab"). Enables permission checking. + method_permission_name: FAB action name (e.g., "read", "write"). + Defaults to "write" if tags has "mutate", else "read". Returns: Decorator that registers and wraps the tool with optional authentication, @@ -95,6 +101,20 @@ def create_tool_decorator( # Get prefixed ID based on ambient context tool_name, context_type = _get_prefixed_id_with_context(base_tool_name) + # Store RBAC permission metadata on the function so + # mcp_auth_hook can read them at call time. + if class_permission_name: + from superset.mcp_service.auth import ( + CLASS_PERMISSION_ATTR, + METHOD_PERMISSION_ATTR, + ) + + setattr(func, CLASS_PERMISSION_ATTR, class_permission_name) + actual_method = method_permission_name + if actual_method is None: + actual_method = "write" if "mutate" in tool_tags else "read" + setattr(func, METHOD_PERMISSION_ATTR, actual_method) + # Conditionally apply authentication wrapper if protect: from superset.mcp_service.auth import mcp_auth_hook diff --git a/superset/mcp_service/auth.py b/superset/mcp_service/auth.py index 8675947097f..77544f224cb 100644 --- a/superset/mcp_service/auth.py +++ b/superset/mcp_service/auth.py @@ -16,15 +16,16 @@ # under the License. """ -Minimal authentication hooks for MCP tools. -This is a placeholder implementation that provides basic user context. +Authentication and authorization hooks for MCP tools. -Future enhancements (to be added in separate PRs): -- JWT token authentication and validation -- User impersonation support -- Permission checking with scopes -- Comprehensive audit logging -- Field-level permissions +This module provides: +- User authentication from JWT or configured dev user +- RBAC permission checking aligned with Superset's REST API permissions +- Dataset access validation +- Session lifecycle management + +The RBAC enforcement mirrors Flask-AppBuilder's @protect() decorator, +ensuring MCP tools respect the same permission model as the REST API. """ import logging @@ -42,6 +43,90 @@ F = TypeVar("F", bound=Callable[..., Any]) logger = logging.getLogger(__name__) +# Constants for RBAC permission attributes (mirrors FAB conventions) +PERMISSION_PREFIX = "can_" +CLASS_PERMISSION_ATTR = "_class_permission_name" +METHOD_PERMISSION_ATTR = "_method_permission_name" + + +class MCPPermissionDeniedError(Exception): + """Raised when user lacks required RBAC permission for an MCP tool.""" + + def __init__( + self, + permission_name: str, + view_name: str, + user: str | None = None, + tool_name: str | None = None, + ): + self.permission_name = permission_name + self.view_name = view_name + self.user = user + self.tool_name = tool_name + message = ( + f"Permission denied: {permission_name} on {view_name}" + + (f" for user {user}" if user else "") + + (f" (tool: {tool_name})" if tool_name else "") + ) + super().__init__(message) + + +def check_tool_permission(func: Callable[..., Any]) -> bool: + """Check if the current user has RBAC permission for an MCP tool. + + Reads permission metadata stored on the function by the @tool decorator + and uses Superset's security_manager to verify access. + + Controlled by the ``MCP_RBAC_ENABLED`` config flag (default True). + Set to False in superset_config.py to disable RBAC checking. + + Args: + func: The tool function with optional permission attributes. + + Returns: + True if user has permission or no permission is required. + """ + try: + from flask import current_app + + if not current_app.config.get("MCP_RBAC_ENABLED", True): + return True + + from superset import security_manager + + if not hasattr(g, "user") or not g.user: + logger.warning( + "No user context for permission check on tool: %s", func.__name__ + ) + return False + + class_permission_name = getattr(func, CLASS_PERMISSION_ATTR, None) + if not class_permission_name: + # No RBAC configured for this tool; allow by default. + return True + + method_permission_name = getattr(func, METHOD_PERMISSION_ATTR, "read") + permission_str = f"{PERMISSION_PREFIX}{method_permission_name}" + + has_permission = security_manager.can_access( + permission_str, class_permission_name + ) + + if not has_permission: + logger.warning( + "Permission denied for user %s: %s on %s (tool: %s)", + g.user.username, + permission_str, + class_permission_name, + func.__name__, + ) + + return has_permission + + except (AttributeError, ValueError, RuntimeError) as e: + logger.warning("Error checking tool permission: %s", e) + return False + def load_user_with_relationships( username: str | None = None, email: str | None = None @@ -219,14 +304,15 @@ def mcp_auth_hook(tool_func: F) -> F: # noqa: C901 """ Authentication and authorization decorator for MCP tools. - This decorator pushes Flask application context and sets up g.user - for MCP tool execution. + This decorator pushes Flask application context, sets up g.user, + and enforces RBAC permission checks for MCP tool execution. + + Permission metadata (class_permission_name, method_permission_name) is + stored on tool_func by the @tool decorator in core_mcp_injection.py. + If present, check_tool_permission() verifies the user has the required + FAB permission before the tool function runs. Supports both sync and async tool functions. - - TODO (future PR): Add permission checking - TODO (future PR): Add JWT scope validation - TODO (future PR): Add comprehensive audit logging """ import contextlib import functools @@ -264,6 +350,16 @@ def mcp_auth_hook(tool_func: F) -> F: # noqa: C901 ) return await tool_func(*args, **kwargs) + # RBAC permission check + if not check_tool_permission(tool_func): + method_name = getattr(tool_func, METHOD_PERMISSION_ATTR, "read") + raise MCPPermissionDeniedError( + permission_name=f"{PERMISSION_PREFIX}{method_name}", + view_name=getattr(tool_func, CLASS_PERMISSION_ATTR, "unknown"), + user=user.username, + tool_name=tool_func.__name__, + ) + try: logger.debug( "MCP tool call: user=%s, tool=%s", @@ -294,6 +390,16 @@ def mcp_auth_hook(tool_func: F) -> F: # noqa: C901 ) return tool_func(*args, **kwargs) + # RBAC permission check + if not check_tool_permission(tool_func): + method_name = getattr(tool_func, METHOD_PERMISSION_ATTR, "read") + raise MCPPermissionDeniedError( + permission_name=f"{PERMISSION_PREFIX}{method_name}", + view_name=getattr(tool_func, CLASS_PERMISSION_ATTR, "unknown"), + user=user.username, + tool_name=tool_func.__name__, + ) + try: logger.debug( "MCP tool call: user=%s, tool=%s", diff --git a/superset/mcp_service/chart/tool/generate_chart.py b/superset/mcp_service/chart/tool/generate_chart.py index f09d8f89fdf..7cec6e9e71f 100644 --- a/superset/mcp_service/chart/tool/generate_chart.py +++ b/superset/mcp_service/chart/tool/generate_chart.py @@ -118,7 +118,7 @@ def _compile_chart( return CompileResult(success=False, error=str(exc)) -@tool(tags=["mutate"]) +@tool(tags=["mutate"], class_permission_name="Chart") @parse_request(GenerateChartRequest) async def generate_chart( # noqa: C901 request: GenerateChartRequest, ctx: Context diff --git a/superset/mcp_service/chart/tool/get_chart_data.py b/superset/mcp_service/chart/tool/get_chart_data.py index 9239a278d9e..96e7b1845ad 100644 --- a/superset/mcp_service/chart/tool/get_chart_data.py +++ b/superset/mcp_service/chart/tool/get_chart_data.py @@ -63,7 +63,7 @@ def _get_cached_form_data(form_data_key: str) -> str | None: return None -@tool(tags=["data"]) +@tool(tags=["data"], class_permission_name="Chart") @parse_request(GetChartDataRequest) async def get_chart_data( # noqa: C901 request: GetChartDataRequest, ctx: Context diff --git a/superset/mcp_service/chart/tool/get_chart_info.py b/superset/mcp_service/chart/tool/get_chart_info.py index 95d0af23ff3..6c60214f705 100644 --- a/superset/mcp_service/chart/tool/get_chart_info.py +++ b/superset/mcp_service/chart/tool/get_chart_info.py @@ -56,7 +56,7 @@ def _get_cached_form_data(form_data_key: str) -> str | None: return None -@tool(tags=["discovery"]) +@tool(tags=["discovery"], class_permission_name="Chart") @parse_request(GetChartInfoRequest) async def get_chart_info( request: GetChartInfoRequest, ctx: Context diff --git a/superset/mcp_service/chart/tool/get_chart_preview.py b/superset/mcp_service/chart/tool/get_chart_preview.py index 6ee3ece4df4..579a2d748f5 100644 --- a/superset/mcp_service/chart/tool/get_chart_preview.py +++ b/superset/mcp_service/chart/tool/get_chart_preview.py @@ -2065,7 +2065,7 @@ async def _get_chart_preview_internal( # noqa: C901 ) -@tool(tags=["data"]) +@tool(tags=["data"], class_permission_name="Chart") @parse_request(GetChartPreviewRequest) async def get_chart_preview( request: GetChartPreviewRequest, ctx: Context diff --git a/superset/mcp_service/chart/tool/list_charts.py b/superset/mcp_service/chart/tool/list_charts.py index a3a72ad8166..3dacb3a7ec8 100644 --- a/superset/mcp_service/chart/tool/list_charts.py +++ b/superset/mcp_service/chart/tool/list_charts.py @@ -61,7 +61,7 @@ SORTABLE_CHART_COLUMNS = [ ] -@tool(tags=["core"]) +@tool(tags=["core"], class_permission_name="Chart") @parse_request(ListChartsRequest) async def list_charts(request: ListChartsRequest, ctx: Context) -> ChartList: """List charts with filtering and search. diff --git a/superset/mcp_service/chart/tool/update_chart.py b/superset/mcp_service/chart/tool/update_chart.py index b0a099888e1..7abc1ef9da3 100644 --- a/superset/mcp_service/chart/tool/update_chart.py +++ b/superset/mcp_service/chart/tool/update_chart.py @@ -45,7 +45,7 @@ from superset.utils import json logger = logging.getLogger(__name__) -@tool(tags=["mutate"]) +@tool(tags=["mutate"], class_permission_name="Chart") @parse_request(UpdateChartRequest) async def update_chart( request: UpdateChartRequest, ctx: Context diff --git a/superset/mcp_service/chart/tool/update_chart_preview.py b/superset/mcp_service/chart/tool/update_chart_preview.py index 7ff3d2bc84a..117a92a6331 100644 --- a/superset/mcp_service/chart/tool/update_chart_preview.py +++ b/superset/mcp_service/chart/tool/update_chart_preview.py @@ -44,7 +44,7 @@ from superset.mcp_service.utils.schema_utils import parse_request logger = logging.getLogger(__name__) -@tool(tags=["mutate"]) +@tool(tags=["mutate"], class_permission_name="Chart", method_permission_name="write") @parse_request(UpdateChartPreviewRequest) def update_chart_preview( request: UpdateChartPreviewRequest, ctx: Context diff --git a/superset/mcp_service/dashboard/tool/add_chart_to_existing_dashboard.py b/superset/mcp_service/dashboard/tool/add_chart_to_existing_dashboard.py index 2ffec57f267..19f17c95a5e 100644 --- a/superset/mcp_service/dashboard/tool/add_chart_to_existing_dashboard.py +++ b/superset/mcp_service/dashboard/tool/add_chart_to_existing_dashboard.py @@ -304,7 +304,7 @@ def _ensure_layout_structure( layout["DASHBOARD_VERSION_KEY"] = "v2" -@tool(tags=["mutate"]) +@tool(tags=["mutate"], class_permission_name="Dashboard") @parse_request(AddChartToDashboardRequest) def add_chart_to_existing_dashboard( request: AddChartToDashboardRequest, ctx: Context diff --git a/superset/mcp_service/dashboard/tool/generate_dashboard.py b/superset/mcp_service/dashboard/tool/generate_dashboard.py index 8126ee387ca..61285c1a874 100644 --- a/superset/mcp_service/dashboard/tool/generate_dashboard.py +++ b/superset/mcp_service/dashboard/tool/generate_dashboard.py @@ -177,7 +177,7 @@ def _generate_title_from_charts(chart_objects: List[Any]) -> str: return title -@tool(tags=["mutate"]) +@tool(tags=["mutate"], class_permission_name="Dashboard") @parse_request(GenerateDashboardRequest) def generate_dashboard( request: GenerateDashboardRequest, ctx: Context diff --git a/superset/mcp_service/dashboard/tool/get_dashboard_info.py b/superset/mcp_service/dashboard/tool/get_dashboard_info.py index de688ee8d8d..fa0df2bee14 100644 --- a/superset/mcp_service/dashboard/tool/get_dashboard_info.py +++ b/superset/mcp_service/dashboard/tool/get_dashboard_info.py @@ -59,7 +59,7 @@ def _get_permalink_state(permalink_key: str) -> DashboardPermalinkValue | None: return None -@tool(tags=["discovery"]) +@tool(tags=["discovery"], class_permission_name="Dashboard") @parse_request(GetDashboardInfoRequest) async def get_dashboard_info( request: GetDashboardInfoRequest, ctx: Context diff --git a/superset/mcp_service/dashboard/tool/list_dashboards.py b/superset/mcp_service/dashboard/tool/list_dashboards.py index eaab375d6c8..78bfec2794a 100644 --- a/superset/mcp_service/dashboard/tool/list_dashboards.py +++ b/superset/mcp_service/dashboard/tool/list_dashboards.py @@ -62,7 +62,7 @@ SORTABLE_DASHBOARD_COLUMNS = [ ] -@tool(tags=["core"]) +@tool(tags=["core"], class_permission_name="Dashboard") @parse_request(ListDashboardsRequest) async def list_dashboards( request: ListDashboardsRequest, ctx: Context diff --git a/superset/mcp_service/dataset/tool/get_dataset_info.py b/superset/mcp_service/dataset/tool/get_dataset_info.py index 07333e837a5..59c3603a185 100644 --- a/superset/mcp_service/dataset/tool/get_dataset_info.py +++ b/superset/mcp_service/dataset/tool/get_dataset_info.py @@ -42,7 +42,7 @@ from superset.mcp_service.utils.schema_utils import parse_request logger = logging.getLogger(__name__) -@tool(tags=["discovery"]) +@tool(tags=["discovery"], class_permission_name="Dataset") @parse_request(GetDatasetInfoRequest) async def get_dataset_info( request: GetDatasetInfoRequest, ctx: Context diff --git a/superset/mcp_service/dataset/tool/list_datasets.py b/superset/mcp_service/dataset/tool/list_datasets.py index 9404b93179d..1bdbcb381fd 100644 --- a/superset/mcp_service/dataset/tool/list_datasets.py +++ b/superset/mcp_service/dataset/tool/list_datasets.py @@ -61,7 +61,7 @@ SORTABLE_DATASET_COLUMNS = [ ] -@tool(tags=["core"]) +@tool(tags=["core"], class_permission_name="Dataset") @parse_request(ListDatasetsRequest) async def list_datasets(request: ListDatasetsRequest, ctx: Context) -> DatasetList: """List datasets with filtering and search. diff --git a/superset/mcp_service/explore/tool/generate_explore_link.py b/superset/mcp_service/explore/tool/generate_explore_link.py index 81e12974824..d60c0fd4d42 100644 --- a/superset/mcp_service/explore/tool/generate_explore_link.py +++ b/superset/mcp_service/explore/tool/generate_explore_link.py @@ -39,7 +39,7 @@ from superset.mcp_service.chart.schemas import ( from superset.mcp_service.utils.schema_utils import parse_request -@tool(tags=["explore"]) +@tool(tags=["explore"], class_permission_name="Explore") @parse_request(GenerateExploreLinkRequest) async def generate_explore_link( request: GenerateExploreLinkRequest, ctx: Context diff --git a/superset/mcp_service/mcp_config.py b/superset/mcp_service/mcp_config.py index dab549b9b06..75221f854ef 100644 --- a/superset/mcp_service/mcp_config.py +++ b/superset/mcp_service/mcp_config.py @@ -45,6 +45,10 @@ MCP_SERVICE_PORT = 5008 # MCP Debug mode - shows suppressed initialization output in stdio mode MCP_DEBUG = False +# MCP RBAC - when True, tools with class_permission_name are checked +# against the FAB security_manager before execution. +MCP_RBAC_ENABLED = True + # MCP JWT Debug Errors - controls server-side JWT debug logging. # When False (default), uses the default JWTVerifier with minimal logging. # When True, uses DetailedJWTVerifier with tiered logging: @@ -319,6 +323,7 @@ def get_mcp_config(app_config: Dict[str, Any] | None = None) -> Dict[str, Any]: "MCP_SERVICE_HOST": MCP_SERVICE_HOST, "MCP_SERVICE_PORT": MCP_SERVICE_PORT, "MCP_DEBUG": MCP_DEBUG, + "MCP_RBAC_ENABLED": MCP_RBAC_ENABLED, **MCP_SESSION_CONFIG, **MCP_CSRF_CONFIG, } diff --git a/superset/mcp_service/sql_lab/tool/execute_sql.py b/superset/mcp_service/sql_lab/tool/execute_sql.py index b9a79b54b28..88fad7d4414 100644 --- a/superset/mcp_service/sql_lab/tool/execute_sql.py +++ b/superset/mcp_service/sql_lab/tool/execute_sql.py @@ -50,7 +50,11 @@ from superset.mcp_service.utils.schema_utils import parse_request logger = logging.getLogger(__name__) -@tool(tags=["mutate"]) +@tool( + tags=["mutate"], + class_permission_name="SQLLab", + method_permission_name="execute_sql_query", +) @parse_request(ExecuteSqlRequest) async def execute_sql(request: ExecuteSqlRequest, ctx: Context) -> ExecuteSqlResponse: """Execute SQL query against database using the unified Database.execute() API.""" diff --git a/superset/mcp_service/sql_lab/tool/open_sql_lab_with_context.py b/superset/mcp_service/sql_lab/tool/open_sql_lab_with_context.py index d27b970d609..d9dc6bcd564 100644 --- a/superset/mcp_service/sql_lab/tool/open_sql_lab_with_context.py +++ b/superset/mcp_service/sql_lab/tool/open_sql_lab_with_context.py @@ -38,7 +38,7 @@ from superset.mcp_service.utils.url_utils import get_superset_base_url logger = logging.getLogger(__name__) -@tool(tags=["explore"]) +@tool(tags=["explore"], class_permission_name="SQLLab", method_permission_name="read") @parse_request(OpenSqlLabRequest) def open_sql_lab_with_context( request: OpenSqlLabRequest, ctx: Context diff --git a/tests/unit_tests/mcp_service/conftest.py b/tests/unit_tests/mcp_service/conftest.py index 92868309552..fbb14f270df 100644 --- a/tests/unit_tests/mcp_service/conftest.py +++ b/tests/unit_tests/mcp_service/conftest.py @@ -18,6 +18,21 @@ """ MCP service test configuration. -Tool imports are handled by app.py, not here. -This conftest is empty to prevent test pollution. +Disables RBAC permission checks for integration tests. +RBAC logic is tested directly in test_auth_rbac.py. """ + +import pytest + + +@pytest.fixture(autouse=True) +def disable_mcp_rbac(app): + """Disable RBAC permission checks for MCP integration tests. + + The RBAC permission logic is tested directly in test_auth_rbac.py. + Integration tests use mock users that do not have real FAB roles, + so we disable RBAC to let them exercise tool logic. + """ + app.config["MCP_RBAC_ENABLED"] = False + yield + app.config.pop("MCP_RBAC_ENABLED", None) diff --git a/tests/unit_tests/mcp_service/test_auth_rbac.py b/tests/unit_tests/mcp_service/test_auth_rbac.py new file mode 100644 index 00000000000..3abc87be500 --- /dev/null +++ b/tests/unit_tests/mcp_service/test_auth_rbac.py @@ -0,0 +1,225 @@ +# 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. + +"""Tests for MCP RBAC permission checking (auth.py).""" + +from unittest.mock import MagicMock, patch + +import pytest +from flask import g + +from superset.mcp_service.auth import ( + check_tool_permission, + CLASS_PERMISSION_ATTR, + MCPPermissionDeniedError, + METHOD_PERMISSION_ATTR, + PERMISSION_PREFIX, +) + + +@pytest.fixture(autouse=True) +def enable_mcp_rbac(app): + """Re-enable RBAC for dedicated RBAC tests. + + The shared conftest disables RBAC for integration tests. This fixture + overrides that so we can test the actual permission checking logic. + """ + app.config["MCP_RBAC_ENABLED"] = True + yield + app.config.pop("MCP_RBAC_ENABLED", None) + + +class _ToolFunc: + """Minimal callable stand-in for a tool function in tests. + + Unlike MagicMock, this does NOT auto-create attributes on access, + so ``getattr(func, ATTR, None)`` properly returns None when the + attribute hasn't been set. + """ + + def __init__(self, name: str = "test_tool"): + self.__name__ = name + + def __call__(self, *args, **kwargs): + pass + + +def _make_tool_func( + class_perm: str | None = None, + method_perm: str | None = None, +) -> _ToolFunc: + """Create a tool function stub with optional permission attributes.""" + func = _ToolFunc() + if class_perm is not None: + setattr(func, CLASS_PERMISSION_ATTR, class_perm) + if method_perm is not None: + setattr(func, METHOD_PERMISSION_ATTR, method_perm) + return func + + +# -- MCPPermissionDeniedError -- + + +def test_permission_denied_error_message_basic() -> None: + err = MCPPermissionDeniedError( + permission_name="can_read", + view_name="Chart", + ) + assert "can_read" in str(err) + assert "Chart" in str(err) + assert err.permission_name == "can_read" + assert err.view_name == "Chart" + + +def test_permission_denied_error_message_with_user_and_tool() -> None: + err = MCPPermissionDeniedError( + permission_name="can_write", + view_name="Dashboard", + user="alice", + tool_name="generate_dashboard", + ) + assert "alice" in str(err) + assert "generate_dashboard" in str(err) + assert "Dashboard" in str(err) + + +# -- check_tool_permission -- + + +def test_check_tool_permission_no_class_permission_allows(app_context) -> None: + """Tools without class_permission_name should be allowed by default.""" + g.user = MagicMock(username="admin") + func = _make_tool_func() # no class_permission_name + assert check_tool_permission(func) is True + + +def test_check_tool_permission_no_user_denies(app_context) -> None: + """If no g.user, permission check should deny.""" + g.user = None + func = _make_tool_func(class_perm="Chart") + assert check_tool_permission(func) is False + + +def test_check_tool_permission_granted(app_context) -> None: + """When security_manager.can_access returns True, permission is granted.""" + g.user = MagicMock(username="admin") + func = _make_tool_func(class_perm="Chart", method_perm="read") + + mock_sm = MagicMock() + mock_sm.can_access = MagicMock(return_value=True) + with patch("superset.security_manager", mock_sm): + result = check_tool_permission(func) + + assert result is True + mock_sm.can_access.assert_called_once_with("can_read", "Chart") + + +def test_check_tool_permission_denied(app_context) -> None: + """When security_manager.can_access returns False, permission is denied.""" + g.user = MagicMock(username="viewer") + func = _make_tool_func(class_perm="Dashboard", method_perm="write") + + mock_sm = MagicMock() + mock_sm.can_access = MagicMock(return_value=False) + with patch("superset.security_manager", mock_sm): + result = check_tool_permission(func) + + assert result is False + mock_sm.can_access.assert_called_once_with("can_write", "Dashboard") + + +def test_check_tool_permission_default_method_is_read(app_context) -> None: + """When no method_permission_name is set, defaults to 'read'.""" + g.user = MagicMock(username="admin") + func = _make_tool_func(class_perm="Dataset") + # No method_perm set - should default to "read" + + mock_sm = MagicMock() + mock_sm.can_access = MagicMock(return_value=True) + with patch("superset.security_manager", mock_sm): + result = check_tool_permission(func) + + assert result is True + mock_sm.can_access.assert_called_once_with("can_read", "Dataset") + + +def test_check_tool_permission_disabled_via_config(app_context, app) -> None: + """When MCP_RBAC_ENABLED is False, permission checks are skipped.""" + g.user = MagicMock(username="viewer") + func = _make_tool_func(class_perm="Chart", method_perm="read") + + app.config["MCP_RBAC_ENABLED"] = False + try: + assert check_tool_permission(func) is True + finally: + app.config["MCP_RBAC_ENABLED"] = True + + +# -- Permission constants -- + + +def test_permission_prefix() -> None: + assert PERMISSION_PREFIX == "can_" + + +def test_class_permission_attr() -> None: + assert CLASS_PERMISSION_ATTR == "_class_permission_name" + + +def test_method_permission_attr() -> None: + assert METHOD_PERMISSION_ATTR == "_method_permission_name" + + +# -- create_tool_decorator permission metadata -- + + +def test_permission_attrs_read_tag() -> None: + """Tags with class_permission_name set method_permission to read.""" + func = _make_tool_func(class_perm="Chart", method_perm="read") + assert getattr(func, CLASS_PERMISSION_ATTR) == "Chart" + assert getattr(func, METHOD_PERMISSION_ATTR) == "read" + + +def test_permission_attrs_write_tag() -> None: + """mutate tag convention → method_permission = 'write'.""" + func = _make_tool_func(class_perm="Chart", method_perm="write") + assert getattr(func, CLASS_PERMISSION_ATTR) == "Chart" + assert getattr(func, METHOD_PERMISSION_ATTR) == "write" + + +def test_permission_attrs_custom_method() -> None: + """Explicit method_permission_name overrides tag-based default.""" + func = _make_tool_func(class_perm="SQLLab", method_perm="execute_sql") + assert getattr(func, CLASS_PERMISSION_ATTR) == "SQLLab" + assert getattr(func, METHOD_PERMISSION_ATTR) == "execute_sql" + + +def test_no_class_permission_means_no_attrs() -> None: + """Without class_permission_name, no permission attrs are set.""" + func = _make_tool_func() + assert not hasattr(func, CLASS_PERMISSION_ATTR) + assert not hasattr(func, METHOD_PERMISSION_ATTR) + + +# -- Fixture -- + + +@pytest.fixture +def app_context(app): + """Provide Flask app context for tests needing g.user.""" + with app.app_context(): + yield From 65e21cf13c4c9374512e9d75510616810fb7d5ba Mon Sep 17 00:00:00 2001 From: Amin Ghadersohi Date: Thu, 12 Mar 2026 18:30:51 +0100 Subject: [PATCH 20/58] docs: move MCP deployment guide to admin docs, add user-facing AI guide (#38585) --- .../configuration/mcp-server.mdx} | 15 +- docs/developer_docs/sidebars.js | 1 - .../using-superset/using-ai-with-superset.mdx | 245 ++++++++++++++++++ 3 files changed, 255 insertions(+), 6 deletions(-) rename docs/{developer_docs/extensions/mcp-server.md => admin_docs/configuration/mcp-server.mdx} (96%) create mode 100644 docs/docs/using-superset/using-ai-with-superset.mdx diff --git a/docs/developer_docs/extensions/mcp-server.md b/docs/admin_docs/configuration/mcp-server.mdx similarity index 96% rename from docs/developer_docs/extensions/mcp-server.md rename to docs/admin_docs/configuration/mcp-server.mdx index 769ada1bc0d..22a80d645b3 100644 --- a/docs/developer_docs/extensions/mcp-server.md +++ b/docs/admin_docs/configuration/mcp-server.mdx @@ -1,7 +1,7 @@ --- title: MCP Server Deployment & Authentication hide_title: true -sidebar_position: 9 +sidebar_position: 14 version: 1 --- @@ -30,6 +30,10 @@ Superset includes a built-in [Model Context Protocol (MCP)](https://modelcontext This guide covers how to run, secure, and deploy the MCP server. +:::tip Looking for user docs? +See **[Using AI with Superset](/user-docs/using-superset/using-ai-with-superset)** for a guide on what AI can do with Superset and how to connect your AI client. +::: + ```mermaid flowchart LR A["AI Client
(Claude, ChatGPT, etc.)"] -- "MCP protocol
(HTTP + JSON-RPC)" --> B["MCP Server
(:5008/mcp)"] @@ -668,12 +672,13 @@ MCP_CSRF_CONFIG = { - **Secrets management** -- Store `MCP_JWT_SECRET`, database credentials, and API keys in environment variables or a secrets manager, never in config files committed to version control - **Scoped tokens** -- Use `MCP_REQUIRED_SCOPES` to limit what operations a token can perform - **Network isolation** -- In Kubernetes, restrict MCP pod network policies to only allow traffic from your AI client endpoints -- Review the **[Security documentation](./security)** for additional extension security guidance +- Review the **[Security documentation](/developer-docs/extensions/security)** for additional extension security guidance --- ## Next Steps -- **[MCP Integration](./mcp)** -- Build custom MCP tools and prompts via Superset extensions -- **[Security](./security)** -- Security best practices for extensions -- **[Deployment](./deployment)** -- Package and deploy Superset extensions +- **[Using AI with Superset](/user-docs/using-superset/using-ai-with-superset)** -- What AI can do with Superset and how to get started +- **[MCP Integration](/developer-docs/extensions/mcp)** -- Build custom MCP tools and prompts via Superset extensions +- **[Security](/developer-docs/extensions/security)** -- Security best practices for extensions +- **[Deployment](/developer-docs/extensions/deployment)** -- Package and deploy Superset extensions diff --git a/docs/developer_docs/sidebars.js b/docs/developer_docs/sidebars.js index babdcd41025..7926d80cf61 100644 --- a/docs/developer_docs/sidebars.js +++ b/docs/developer_docs/sidebars.js @@ -52,7 +52,6 @@ module.exports = { 'extensions/development', 'extensions/deployment', 'extensions/mcp', - 'extensions/mcp-server', 'extensions/security', 'extensions/tasks', 'extensions/registry', diff --git a/docs/docs/using-superset/using-ai-with-superset.mdx b/docs/docs/using-superset/using-ai-with-superset.mdx new file mode 100644 index 00000000000..3969042bcd2 --- /dev/null +++ b/docs/docs/using-superset/using-ai-with-superset.mdx @@ -0,0 +1,245 @@ +--- +title: Using AI with Superset +hide_title: true +sidebar_position: 5 +version: 1 +--- + + + +# Using AI with Superset + +Superset supports AI assistants through the [Model Context Protocol (MCP)](https://modelcontextprotocol.io/). Connect Claude, ChatGPT, or other MCP-compatible clients to explore your data, build charts, create dashboards, and run SQL -- all through natural language. + +:::info +Requires Superset 5.0+. Your admin must enable and deploy the MCP server before you can connect. +See the **[MCP Server admin guide](/admin-docs/configuration/mcp-server)** for setup instructions. +::: + +--- + +## What Can AI Do with Superset? + +### Explore Your Data + +Ask your AI assistant to browse what's available in your Superset instance: + +- **List datasets** -- see all datasets you have access to, with filtering and search +- **Get dataset details** -- column names, types, available metrics, and filters +- **List charts and dashboards** -- find existing visualizations by name or keyword +- **Get chart and dashboard details** -- understand what a chart shows, its query, and configuration + +**Example prompts:** +> "What datasets are available?" +> "Show me the columns in the sales_orders dataset" +> "Find dashboards related to revenue" + +### Build Charts + +Describe the visualization you want and AI creates it for you: + +- **Create charts from natural language** -- describe what you want to see and AI picks the right chart type, metrics, and dimensions +- **Preview before saving** -- AI generates a preview so you can review before committing +- **Modify existing charts** -- update filters, change chart types, add metrics +- **Get Explore links** -- open any chart in Superset's Explore view for further refinement + +**Example prompts:** +> "Create a bar chart showing monthly revenue by region from the sales dataset" +> "Update chart 42 to use a line chart instead" +> "Give me a link to explore this chart further" + +### Create Dashboards + +Build dashboards from a collection of charts: + +- **Generate dashboards** -- create a new dashboard with a set of charts, automatically laid out +- **Add charts to existing dashboards** -- place a chart on an existing dashboard with automatic positioning + +**Example prompts:** +> "Create a dashboard called 'Q4 Sales Overview' with charts 10, 15, and 22" +> "Add the revenue trend chart to the executive dashboard" + +### Run SQL Queries + +Execute SQL directly through your AI assistant: + +- **Run queries** -- execute SQL with full Superset RBAC enforcement (you can only query data your roles allow) +- **Open SQL Lab** -- get a link to SQL Lab pre-populated with a query, ready to run and explore + +**Example prompts:** +> "Run this query: SELECT region, SUM(revenue) FROM sales GROUP BY region" +> "Open SQL Lab with a query to show the top 10 customers by order count" + +### Analyze Chart Data + +Pull the raw data behind any chart: + +- **Get chart data** -- retrieve the data a chart displays, with support for JSON, CSV, and Excel export formats +- **Inspect results** -- useful for verifying what a visualization shows or feeding data into other tools + +**Example prompts:** +> "Get the data behind chart 42" +> "Export chart 15 data as CSV" + +### Check Instance Status + +- **Health check** -- verify your Superset instance is up and the MCP connection is working +- **Instance info** -- get high-level statistics about your Superset instance (number of datasets, charts, dashboards) + +**Example prompts:** +> "Is Superset healthy?" +> "How many dashboards are in this instance?" + +--- + +## Connecting Your AI Client + +Once your admin has deployed the MCP server, connect your AI client using the instructions below. + +### Claude Desktop + +Edit your Claude Desktop config file: + +- **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json` +- **Windows**: `%APPDATA%\Claude\claude_desktop_config.json` +- **Linux**: `~/.config/Claude/claude_desktop_config.json` + +```json +{ + "mcpServers": { + "superset": { + "url": "http://localhost:5008/mcp" + } + } +} +``` + +Restart Claude Desktop. The hammer icon in the chat bar confirms the connection. + +If your admin has enabled JWT authentication, you may need to include a token: + +```json +{ + "mcpServers": { + "superset": { + "command": "npx", + "args": [ + "-y", + "mcp-remote@latest", + "http://your-superset-host:5008/mcp", + "--header", + "Authorization: Bearer YOUR_TOKEN" + ] + } + } +} +``` + +### Claude Code (CLI) + +Add to your project's `.mcp.json`: + +```json +{ + "mcpServers": { + "superset": { + "type": "url", + "url": "http://localhost:5008/mcp" + } + } +} +``` + +### ChatGPT + +1. Click your profile icon > **Settings** > **Apps and Connectors** +2. Enable **Developer Mode** in Advanced Settings +3. In the chat composer, press **+** > **Add sources** > **App** > **Connect more** > **Create app** +4. Enter a name and your MCP server URL +5. Click **I understand and continue** + +:::info +ChatGPT MCP connectors require a Pro, Team, Enterprise, or Edu plan. +::: + +Ask your admin for the MCP server URL and any authentication tokens you need. + +--- + +## Tips for Best Results + +- **Be specific** -- "Create a bar chart of monthly revenue by region from the sales dataset" works better than "Make me a chart" +- **Start with exploration** -- ask what datasets and charts exist before creating new ones +- **Review AI-generated content** -- always check chart configurations and SQL before saving or sharing +- **Use Explore for refinement** -- ask AI for an Explore link, then fine-tune interactively in the Superset UI +- **Check permissions if you get errors** -- AI respects Superset's RBAC, so you can only access data your roles allow + +--- + +## Available Tools Reference + +| Tool | Description | +|------|-------------| +| `health_check` | Verify the MCP server is running and connected | +| `get_instance_info` | Get instance statistics (dataset, chart, dashboard counts) | +| `get_schema` | Discover available charts, datasets, and dashboards with schema info | +| `list_datasets` | List datasets with filtering and search | +| `get_dataset_info` | Get dataset metadata (columns, metrics, filters) | +| `list_charts` | List charts with filtering and search | +| `get_chart_info` | Get chart metadata and configuration | +| `get_chart_data` | Retrieve chart data (JSON, CSV, or Excel) | +| `get_chart_preview` | Generate a chart preview (URL, ASCII, table, or Vega-Lite) | +| `generate_chart` | Create a new chart from a specification | +| `update_chart` | Modify an existing chart's configuration | +| `update_chart_preview` | Update a cached chart preview without saving | +| `list_dashboards` | List dashboards with filtering and search | +| `get_dashboard_info` | Get dashboard metadata and layout | +| `generate_dashboard` | Create a new dashboard with specified charts | +| `add_chart_to_existing_dashboard` | Add a chart to an existing dashboard | +| `execute_sql` | Run a SQL query with RBAC enforcement | +| `open_sql_lab_with_context` | Open SQL Lab with a pre-populated query | +| `generate_explore_link` | Generate an Explore URL for interactive visualization | + +--- + +## Troubleshooting + +### "Connection refused" or "Cannot connect" + +- Confirm the MCP server URL with your admin +- For Claude Desktop: fully quit the app (not just close the window) and restart after config changes +- Check that the URL path ends with `/mcp` (e.g., `http://localhost:5008/mcp`) + +### "Permission denied" or missing data + +- Superset's RBAC controls what you can access through AI, just like in the Superset UI +- Ask your admin to verify your roles and permissions +- Try accessing the same data through the Superset web UI to confirm your access + +### "Response too large" + +- Ask for smaller result sets: use filters, reduce `page_size`, or request specific columns +- Example: "Show me the top 10 rows from the sales dataset" instead of "Show me all sales data" + +### AI doesn't see Superset tools + +- Verify the connection in your AI client (e.g., the hammer icon in Claude Desktop) +- Ask the AI "What Superset tools are available?" to confirm the connection +- Restart your AI client if you recently changed the configuration From 7f476a79b3e7bea47830cf29368bc017c89f1436 Mon Sep 17 00:00:00 2001 From: Mehmet Salih Yavuz Date: Thu, 12 Mar 2026 21:20:14 +0300 Subject: [PATCH 21/58] fix: add embedded box sizing rule for layout (#38351) Co-authored-by: Joe Li --- superset-frontend/src/embedded/index.tsx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/superset-frontend/src/embedded/index.tsx b/superset-frontend/src/embedded/index.tsx index 5609877f77c..6eeb1009c98 100644 --- a/superset-frontend/src/embedded/index.tsx +++ b/superset-frontend/src/embedded/index.tsx @@ -21,12 +21,14 @@ import 'src/public-path'; import { lazy, Suspense } from 'react'; import ReactDOM from 'react-dom'; import { BrowserRouter as Router, Route } from 'react-router-dom'; +import { Global } from '@emotion/react'; import { t } from '@apache-superset/core/translation'; import { makeApi } from '@superset-ui/core'; import { logging } from '@apache-superset/core/utils'; import { type SupersetThemeConfig, ThemeMode, + css, } from '@apache-superset/core/theme'; import Switchboard from '@superset-ui/switchboard'; import getBootstrapData, { applicationRoot } from 'src/utils/getBootstrapData'; @@ -93,6 +95,16 @@ const EmbededLazyDashboardPage = () => { const EmbeddedRoute = () => ( + }> From 95f61bd223ee7f72893197c77ec73a06d225443b Mon Sep 17 00:00:00 2001 From: Mehmet Salih Yavuz Date: Thu, 12 Mar 2026 21:21:43 +0300 Subject: [PATCH 22/58] fix: add parent_slice_id for multilayer charts to embed (#38243) --- .../src/Multi/Multi.test.tsx | 137 ++++++++++++++++++ .../src/Multi/Multi.tsx | 2 + .../components/Chart/chartReducers.test.ts | 47 +++--- superset/security/manager.py | 58 +++++++- tests/unit_tests/security/manager_test.py | 114 +++++++++++++++ 5 files changed, 329 insertions(+), 29 deletions(-) diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/Multi/Multi.test.tsx b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/Multi/Multi.test.tsx index f1e50c5b589..abce3761ad3 100644 --- a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/Multi/Multi.test.tsx +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/Multi/Multi.test.tsx @@ -605,3 +605,140 @@ describe('DeckMulti Component Rendering', () => { }); }); }); + +test('includes parent_slice_id in child slice requests when parent has slice_id', async () => { + jest.clearAllMocks(); + const mockGet = jest.fn().mockResolvedValue({ + json: { + result: { + form_data: { + viz_type: 'deck_scatter', + datasource: '1__table', + }, + }, + data: { + features: [], + }, + }, + }); + (SupersetClient.get as jest.Mock) = mockGet; + const parentSliceId = 99; + const dashboardId = 5; + + const props = { + ...baseMockProps, + formData: { + ...baseMockProps.formData, + slice_id: parentSliceId, + dashboardId, + }, + }; + + renderWithProviders(); + + await waitFor(() => { + expect(mockGet).toHaveBeenCalled(); + }); + + // Check that the child slice requests include parent_slice_id + const { calls } = mockGet.mock; + calls.forEach(call => { + const { endpoint } = call[0]; + if (endpoint.includes('api/v1/explore/form_data')) { + const body = JSON.parse(call[0].body); + expect(body.form_data).toMatchObject({ + dashboardId, + parent_slice_id: parentSliceId, + }); + } + }); +}); + +test('includes parent_slice_id in embedded mode', async () => { + jest.clearAllMocks(); + const mockGet = jest.fn().mockResolvedValue({ + json: { + result: { + form_data: { + viz_type: 'deck_scatter', + datasource: '1__table', + }, + }, + data: { + features: [], + }, + }, + }); + (SupersetClient.get as jest.Mock) = mockGet; + const parentSliceId = 200; + const dashboardId = 10; + + const props = { + ...baseMockProps, + formData: { + ...baseMockProps.formData, + slice_id: parentSliceId, + dashboardId, + embedded: true, + }, + }; + + renderWithProviders(); + + await waitFor(() => { + expect(mockGet).toHaveBeenCalled(); + }); + + // Verify parent_slice_id is included in embedded mode + const { calls } = mockGet.mock; + calls.forEach(call => { + const { endpoint } = call[0]; + if (endpoint.includes('api/v1/explore/form_data')) { + const body = JSON.parse(call[0].body); + expect(body.form_data.parent_slice_id).toBe(parentSliceId); + } + }); +}); + +test('does not include parent_slice_id when parent has no slice_id', async () => { + jest.clearAllMocks(); + const mockGet = jest.fn().mockResolvedValue({ + json: { + result: { + form_data: { + viz_type: 'deck_scatter', + datasource: '1__table', + }, + }, + data: { + features: [], + }, + }, + }); + (SupersetClient.get as jest.Mock) = mockGet; + + const props = { + ...baseMockProps, + formData: { + ...baseMockProps.formData, + // No slice_id in parent + dashboardId: 5, + }, + }; + + renderWithProviders(); + + await waitFor(() => { + expect(mockGet).toHaveBeenCalled(); + }); + + // Verify parent_slice_id is not included when parent has no slice_id + const { calls } = mockGet.mock; + calls.forEach(call => { + const { endpoint } = call[0]; + if (endpoint.includes('api/v1/explore/form_data')) { + const body = JSON.parse(call[0].body); + expect(body.form_data.parent_slice_id).toBeUndefined(); + } + }); +}); diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/Multi/Multi.tsx b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/Multi/Multi.tsx index c361eb755c8..3c52464a247 100644 --- a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/Multi/Multi.tsx +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/Multi/Multi.tsx @@ -289,6 +289,8 @@ const DeckMulti = (props: DeckMultiProps) => { adhoc_filters: adhocFilters, // Preserve dashboard context for embedded mode permissions ...(formData.dashboardId && { dashboardId: formData.dashboardId }), + // Include parent multilayer chart ID for security checks + ...(formData.slice_id && { parent_slice_id: formData.slice_id }), }, } as any as JsonObject & { slice_id: number }; diff --git a/superset-frontend/src/components/Chart/chartReducers.test.ts b/superset-frontend/src/components/Chart/chartReducers.test.ts index ee5010358cb..d5b660dd810 100644 --- a/superset-frontend/src/components/Chart/chartReducers.test.ts +++ b/superset-frontend/src/components/Chart/chartReducers.test.ts @@ -35,36 +35,35 @@ describe('chart reducers', () => { }); test('should update endtime on fail', () => { - const controller = new AbortController(); - charts[chartKey] = { - ...charts[chartKey], - queryController: controller, - }; - const newState = chartReducer( - charts, - actions.chartUpdateStopped(chartKey, controller), - ); + const newState = chartReducer(charts, actions.chartUpdateStopped(chartKey)); expect(newState[chartKey].chartUpdateEndTime).toBeGreaterThan(0); expect(newState[chartKey].chartStatus).toEqual('stopped'); }); - test('should ignore stopped updates from stale controllers', () => { - const controller = new AbortController(); - const staleController = new AbortController(); - charts[chartKey] = { - ...charts[chartKey], - chartStatus: 'loading', - queryController: controller, - }; - - const newState = chartReducer( - charts, - actions.chartUpdateStopped(chartKey, staleController), + test('should handle chartUpdateStopped without queryController', () => { + const newState = chartReducer(charts, actions.chartUpdateStopped(chartKey)); + expect(newState[chartKey].chartStatus).toEqual('stopped'); + expect(newState[chartKey].chartAlert).toContain( + 'Updating chart was stopped', ); + expect(newState[chartKey].chartUpdateEndTime).toBeGreaterThan(0); + }); - expect(newState[chartKey].chartStatus).toEqual('loading'); - expect(newState[chartKey].chartUpdateEndTime).toEqual( - charts[chartKey].chartUpdateEndTime, + test('chartUpdateStopped sets state correctly', () => { + const chartsWithController = { + [chartKey]: { + ...testChart, + queryController: new AbortController(), + }, + }; + const newState = chartReducer( + chartsWithController, + actions.chartUpdateStopped(chartKey), + ); + // Verify the chart status and alert are set + expect(newState[chartKey].chartStatus).toEqual('stopped'); + expect(newState[chartKey].chartAlert).toContain( + 'Updating chart was stopped', ); }); diff --git a/superset/security/manager.py b/superset/security/manager.py index 2e2ca65017c..32bc75e079e 100644 --- a/superset/security/manager.py +++ b/superset/security/manager.py @@ -695,6 +695,32 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods return False + def _validate_child_in_parent_multilayer( + self, child_slice_id: int, parent_slice: "Slice" + ) -> bool: + """ + Validate that a child slice ID is actually configured in the parent + multi-layer chart's deck_slices configuration. + + This prevents attackers from forging a parent_slice_id to gain + unauthorized access to arbitrary charts. + """ + try: + # Parse the parent chart's configuration + parent_form_data = json.loads(parent_slice.params or "{}") + + # Check if this is actually a multi-layer deck.gl chart + if parent_form_data.get("viz_type") != "deck_multi": + return False + + # Get the configured child slices + deck_slices = parent_form_data.get("deck_slices", []) + + # Validate the child is in the parent's configuration + return child_slice_id in deck_slices + except (json.JSONDecodeError, TypeError): + return False + def has_drill_by_access( self, form_data: dict[str, Any], @@ -2604,12 +2630,34 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods form_data.get("type") != "NATIVE_FILTER" and (slice_id := form_data.get("slice_id")) and ( - slc := self.session.query(Slice) - .filter(Slice.id == slice_id) - .one_or_none() + # Direct chart access (no parent) + ( + form_data.get("parent_slice_id") is None + and ( + slc := self.session.query(Slice) + .filter(Slice.id == slice_id) + .one_or_none() + ) + and slc in dashboard_.slices + and slc.datasource == datasource + ) + or + # Multi-layer chart child access (has parent) + ( + (parent_id := form_data.get("parent_slice_id")) + and ( + parent_slc := self.session.query(Slice) + .filter(Slice.id == parent_id) + .one_or_none() + ) + and parent_slc in dashboard_.slices + # Validate child is actually part of parent's config + and self._validate_child_in_parent_multilayer( + child_slice_id=slice_id, + parent_slice=parent_slc, + ) + ) ) - and slc in dashboard_.slices - and slc.datasource == datasource ) or self.has_drill_by_access(form_data, dashboard_, datasource) ) diff --git a/tests/unit_tests/security/manager_test.py b/tests/unit_tests/security/manager_test.py index a59b969f1e8..ca4b0b8df8b 100644 --- a/tests/unit_tests/security/manager_test.py +++ b/tests/unit_tests/security/manager_test.py @@ -1433,3 +1433,117 @@ def test_prefetch_rls_filters_works_for_guest_user( # Cache should be populated with (username, table_id) keys and empty lists assert mock_g._rls_filter_cache[("guest_user", 10)] == [] assert mock_g._rls_filter_cache[("guest_user", 20)] == [] + + +def test_validate_child_in_parent_multilayer_valid( + app_context: None, mocker: MockerFixture +) -> None: + """Test validation succeeds for valid multi-layer child""" + sm = SupersetSecurityManager(appbuilder) + + parent_slice = mocker.MagicMock(spec=Slice) + parent_slice.params = json.dumps( + {"viz_type": "deck_multi", "deck_slices": [1, 2, 3]} + ) + + # Child 2 is in parent's deck_slices + assert sm._validate_child_in_parent_multilayer( + child_slice_id=2, parent_slice=parent_slice + ) + + +def test_validate_child_in_parent_multilayer_invalid_child( + app_context: None, mocker: MockerFixture +) -> None: + """Test validation fails for child not in parent config""" + sm = SupersetSecurityManager(appbuilder) + + parent_slice = mocker.MagicMock(spec=Slice) + parent_slice.params = json.dumps( + {"viz_type": "deck_multi", "deck_slices": [1, 2, 3]} + ) + + # Child 5 is NOT in parent's deck_slices + assert not sm._validate_child_in_parent_multilayer( + child_slice_id=5, parent_slice=parent_slice + ) + + +def test_validate_child_in_parent_multilayer_wrong_viz_type( + app_context: None, mocker: MockerFixture +) -> None: + """Test validation fails for non-multilayer charts""" + sm = SupersetSecurityManager(appbuilder) + + parent_slice = mocker.MagicMock(spec=Slice) + parent_slice.params = json.dumps( + { + "viz_type": "line", # Not deck_multi + "deck_slices": [1, 2, 3], + } + ) + + assert not sm._validate_child_in_parent_multilayer( + child_slice_id=2, parent_slice=parent_slice + ) + + +def test_validate_child_in_parent_multilayer_empty_deck_slices( + app_context: None, mocker: MockerFixture +) -> None: + """Test validation fails when deck_slices is empty""" + sm = SupersetSecurityManager(appbuilder) + + parent_slice = mocker.MagicMock(spec=Slice) + parent_slice.params = json.dumps({"viz_type": "deck_multi", "deck_slices": []}) + + assert not sm._validate_child_in_parent_multilayer( + child_slice_id=1, parent_slice=parent_slice + ) + + +def test_validate_child_in_parent_multilayer_no_deck_slices( + app_context: None, mocker: MockerFixture +) -> None: + """Test validation fails when deck_slices is missing""" + sm = SupersetSecurityManager(appbuilder) + + parent_slice = mocker.MagicMock(spec=Slice) + parent_slice.params = json.dumps( + { + "viz_type": "deck_multi" + # No deck_slices key + } + ) + + assert not sm._validate_child_in_parent_multilayer( + child_slice_id=1, parent_slice=parent_slice + ) + + +def test_validate_child_in_parent_multilayer_malformed_json( + app_context: None, mocker: MockerFixture +) -> None: + """Test validation fails gracefully with malformed JSON""" + sm = SupersetSecurityManager(appbuilder) + + parent_slice = mocker.MagicMock(spec=Slice) + parent_slice.params = "not valid json {{" + + assert not sm._validate_child_in_parent_multilayer( + child_slice_id=1, parent_slice=parent_slice + ) + + +def test_validate_child_in_parent_multilayer_null_params( + app_context: None, mocker: MockerFixture +) -> None: + """Test validation fails gracefully with null params""" + sm = SupersetSecurityManager(appbuilder) + + parent_slice = mocker.MagicMock(spec=Slice) + parent_slice.params = None + + assert not sm._validate_child_in_parent_multilayer( + child_slice_id=1, parent_slice=parent_slice + ) From d4f1f8db00151209524898976450b77877e3f4f6 Mon Sep 17 00:00:00 2001 From: Amin Ghadersohi Date: Fri, 13 Mar 2026 00:20:53 +0100 Subject: [PATCH 23/58] fix(mcp): extract role names as strings in UserInfo serialization (#38612) --- superset/mcp_service/chart/schemas.py | 4 +- superset/mcp_service/dashboard/schemas.py | 19 +- superset/mcp_service/dataset/schemas.py | 4 +- superset/mcp_service/system/schemas.py | 25 ++- .../system/test_serialize_user_object.py | 167 ++++++++++++++++++ 5 files changed, 202 insertions(+), 17 deletions(-) create mode 100644 tests/unit_tests/mcp_service/system/test_serialize_user_object.py diff --git a/superset/mcp_service/chart/schemas.py b/superset/mcp_service/chart/schemas.py index 28fdf1b0d88..c5a2098442f 100644 --- a/superset/mcp_service/chart/schemas.py +++ b/superset/mcp_service/chart/schemas.py @@ -45,6 +45,7 @@ from superset.mcp_service.common.cache_schemas import ( from superset.mcp_service.common.error_schemas import ChartGenerationError from superset.mcp_service.system.schemas import ( PaginationInfo, + serialize_user_object, TagInfo, UserInfo, ) @@ -278,8 +279,9 @@ def serialize_chart_object(chart: ChartLike | None) -> ChartInfo | None: if getattr(chart, "tags", None) else [], owners=[ - UserInfo.model_validate(owner, from_attributes=True) + info for owner in getattr(chart, "owners", []) + if (info := serialize_user_object(owner)) is not None ] if getattr(chart, "owners", None) else [], diff --git a/superset/mcp_service/dashboard/schemas.py b/superset/mcp_service/dashboard/schemas.py index 7b002677c69..e09b3871ce6 100644 --- a/superset/mcp_service/dashboard/schemas.py +++ b/superset/mcp_service/dashboard/schemas.py @@ -87,6 +87,7 @@ from superset.mcp_service.common.cache_schemas import MetadataCacheControl from superset.mcp_service.system.schemas import ( PaginationInfo, RoleInfo, + serialize_user_object, TagInfo, UserInfo, ) @@ -109,19 +110,8 @@ class DashboardError(BaseModel): return cls(error=error, error_type=error_type, timestamp=datetime.now()) -def serialize_user_object(user: Any) -> UserInfo | None: - """Serialize a user object to UserInfo""" - if not user: - return None - - return UserInfo( - id=getattr(user, "id", None), - username=getattr(user, "username", None), - first_name=getattr(user, "first_name", None), - last_name=getattr(user, "last_name", None), - email=getattr(user, "email", None), - active=getattr(user, "active", None), - ) +# serialize_user_object is imported from system.schemas and re-exported here +# for backward compatibility with dashboard tool modules. def serialize_tag_object(tag: Any) -> TagInfo | None: @@ -502,8 +492,9 @@ def dashboard_serializer(dashboard: "Dashboard") -> DashboardInfo: changed_on_humanized=dashboard.changed_on_humanized, chart_count=len(dashboard.slices) if dashboard.slices else 0, owners=[ - UserInfo.model_validate(owner, from_attributes=True) + info for owner in dashboard.owners + if (info := serialize_user_object(owner)) is not None ] if dashboard.owners else [], diff --git a/superset/mcp_service/dataset/schemas.py b/superset/mcp_service/dataset/schemas.py index b0dad96b592..14dd329e8f7 100644 --- a/superset/mcp_service/dataset/schemas.py +++ b/superset/mcp_service/dataset/schemas.py @@ -37,6 +37,7 @@ from superset.daos.base import ColumnOperator, ColumnOperatorEnum from superset.mcp_service.common.cache_schemas import MetadataCacheControl from superset.mcp_service.system.schemas import ( PaginationInfo, + serialize_user_object, TagInfo, UserInfo, ) @@ -338,8 +339,9 @@ def serialize_dataset_object(dataset: Any) -> DatasetInfo | None: if getattr(dataset, "tags", None) else [], owners=[ - UserInfo.model_validate(owner, from_attributes=True) + info for owner in getattr(dataset, "owners", []) + if (info := serialize_user_object(owner)) is not None ] if getattr(dataset, "owners", None) else [], diff --git a/superset/mcp_service/system/schemas.py b/superset/mcp_service/system/schemas.py index f1667471693..9810cc4d3b3 100644 --- a/superset/mcp_service/system/schemas.py +++ b/superset/mcp_service/system/schemas.py @@ -25,7 +25,7 @@ system-level info. from __future__ import annotations from datetime import datetime -from typing import Dict, List +from typing import Any, Dict, List from pydantic import BaseModel, ConfigDict, Field @@ -170,6 +170,29 @@ class UserInfo(BaseModel): ) +def serialize_user_object(user: Any) -> UserInfo | None: + """Serialize a user ORM object to UserInfo, extracting role names as strings.""" + if not user: + return None + + user_roles: list[str] = [] + if (raw_roles := getattr(user, "roles", None)) is not None: + try: + user_roles = [role.name for role in raw_roles if hasattr(role, "name")] + except TypeError: + user_roles = [] + + return UserInfo( + id=getattr(user, "id", None), + username=getattr(user, "username", None), + first_name=getattr(user, "first_name", None), + last_name=getattr(user, "last_name", None), + email=getattr(user, "email", None), + active=getattr(user, "active", None), + roles=user_roles, + ) + + class TagInfo(BaseModel): id: int | None = None name: str | None = None diff --git a/tests/unit_tests/mcp_service/system/test_serialize_user_object.py b/tests/unit_tests/mcp_service/system/test_serialize_user_object.py new file mode 100644 index 00000000000..adca76fdfde --- /dev/null +++ b/tests/unit_tests/mcp_service/system/test_serialize_user_object.py @@ -0,0 +1,167 @@ +# 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 unittest.mock import MagicMock + +import pytest + +from superset.mcp_service.system.schemas import serialize_user_object + + +def test_returns_none_for_none_user() -> None: + assert serialize_user_object(None) is None + + +def test_returns_none_for_falsy_user() -> None: + assert serialize_user_object(0) is None + assert serialize_user_object("") is None + + +def test_extracts_basic_fields() -> None: + user = MagicMock() + user.id = 1 + user.username = "admin" + user.first_name = "Ad" + user.last_name = "Min" + user.email = "admin@example.com" + user.active = True + user.roles = [] + + result = serialize_user_object(user) + + assert result is not None + assert result.id == 1 + assert result.username == "admin" + assert result.first_name == "Ad" + assert result.last_name == "Min" + assert result.email == "admin@example.com" + assert result.active is True + assert result.roles == [] + + +def test_extracts_role_names_from_orm_objects() -> None: + """The original bug: SQLAlchemy Role objects must be converted to strings.""" + role_admin = MagicMock() + role_admin.name = "Admin" + role_alpha = MagicMock() + role_alpha.name = "Alpha" + + user = MagicMock() + user.id = 1 + user.username = "admin" + user.first_name = "Ad" + user.last_name = "Min" + user.email = "admin@example.com" + user.active = True + user.roles = [role_admin, role_alpha] + + result = serialize_user_object(user) + + assert result is not None + assert result.roles == ["Admin", "Alpha"] + + +def test_handles_user_without_roles_attribute() -> None: + user = MagicMock( + spec=["id", "username", "first_name", "last_name", "email", "active"] + ) + user.id = 1 + user.username = "noroles" + user.first_name = "No" + user.last_name = "Roles" + user.email = "no@roles.com" + user.active = True + + result = serialize_user_object(user) + + assert result is not None + assert result.roles == [] + + +def _make_user(**overrides: object) -> MagicMock: + """Helper to create a fully-populated mock user.""" + defaults = { + "id": 1, + "username": "testuser", + "first_name": "Test", + "last_name": "User", + "email": "test@example.com", + "active": True, + "roles": [], + } + defaults.update(overrides) + user = MagicMock() + for k, v in defaults.items(): + setattr(user, k, v) + return user + + +def test_handles_non_iterable_roles() -> None: + user = _make_user(roles=42) + + result = serialize_user_object(user) + + assert result is not None + assert result.roles == [] + + +def test_skips_roles_without_name_attribute() -> None: + role_good = MagicMock() + role_good.name = "Admin" + + user = _make_user(roles=[role_good]) + + result = serialize_user_object(user) + + assert result is not None + assert result.roles == ["Admin"] + + +def test_handles_none_roles() -> None: + user = _make_user(roles=None) + + result = serialize_user_object(user) + + assert result is not None + assert result.roles == [] + + +@pytest.mark.parametrize( + "missing_field", + ["id", "username", "first_name", "last_name", "email", "active"], +) +def test_missing_fields_default_to_none(missing_field: str) -> None: + """Fields not present on the user object should default to None.""" + attrs = { + "id": 1, + "username": "test", + "first_name": "T", + "last_name": "U", + "email": "t@e.com", + "active": True, + "roles": [], + } + # Remove the field under test + del attrs[missing_field] + user = MagicMock(spec=list(attrs.keys())) + for k, v in attrs.items(): + setattr(user, k, v) + + result = serialize_user_object(user) + + assert result is not None + assert getattr(result, missing_field) is None From 4a9db243a170f35d602c36a338281a8b994697b4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 13 Mar 2026 09:35:13 +0700 Subject: [PATCH 24/58] chore(deps): bump caniuse-lite from 1.0.30001777 to 1.0.30001778 in /docs (#38593) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- docs/package.json | 2 +- docs/yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/package.json b/docs/package.json index fcfbc81fc1c..342ab607ed7 100644 --- a/docs/package.json +++ b/docs/package.json @@ -70,7 +70,7 @@ "@swc/core": "^1.15.17", "antd": "^6.3.2", "baseline-browser-mapping": "^2.10.0", - "caniuse-lite": "^1.0.30001777", + "caniuse-lite": "^1.0.30001778", "docusaurus-plugin-openapi-docs": "^4.6.0", "docusaurus-theme-openapi-docs": "^4.6.0", "js-yaml": "^4.1.1", diff --git a/docs/yarn.lock b/docs/yarn.lock index 1dcebb8bf84..8e90a7b93f4 100644 --- a/docs/yarn.lock +++ b/docs/yarn.lock @@ -6209,10 +6209,10 @@ caniuse-api@^3.0.0: lodash.memoize "^4.1.2" lodash.uniq "^4.5.0" -caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001702, caniuse-lite@^1.0.30001759, caniuse-lite@^1.0.30001777: - version "1.0.30001777" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001777.tgz#028f21e4b2718d138b55e692583e6810ccf60691" - integrity sha512-tmN+fJxroPndC74efCdp12j+0rk0RHwV5Jwa1zWaFVyw2ZxAuPeG8ZgWC3Wz7uSjT3qMRQ5XHZ4COgQmsCMJAQ== +caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001702, caniuse-lite@^1.0.30001759, caniuse-lite@^1.0.30001778: + version "1.0.30001778" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001778.tgz#79a8a124e3473a20b70616497b987c30d06570a0" + integrity sha512-PN7uxFL+ExFJO61aVmP1aIEG4i9whQd4eoSCebav62UwDyp5OHh06zN4jqKSMePVgxHifCw1QJxdRkA1Pisekg== ccount@^2.0.0: version "2.0.1" From 83955e87acc5a878b0c6bdc1307ad34005906e42 Mon Sep 17 00:00:00 2001 From: Amin Ghadersohi Date: Fri, 13 Mar 2026 08:59:21 +0100 Subject: [PATCH 25/58] refactor(mcp): use serialize_user_object in get_instance_info (#38613) --- .../system/tool/get_instance_info.py | 21 ++----------------- .../system/tool/test_get_current_user.py | 1 + 2 files changed, 3 insertions(+), 19 deletions(-) diff --git a/superset/mcp_service/system/tool/get_instance_info.py b/superset/mcp_service/system/tool/get_instance_info.py index 4ffa7b8bfc7..3f13ba874e4 100644 --- a/superset/mcp_service/system/tool/get_instance_info.py +++ b/superset/mcp_service/system/tool/get_instance_info.py @@ -30,7 +30,7 @@ from superset.mcp_service.mcp_core import InstanceInfoCore from superset.mcp_service.system.schemas import ( GetSupersetInstanceInfoRequest, InstanceInfo, - UserInfo, + serialize_user_object, ) from superset.mcp_service.system.system_utils import ( calculate_dashboard_breakdown, @@ -110,24 +110,7 @@ def get_instance_info( # Attach the authenticated user's identity to the response user = getattr(g, "user", None) if user is not None: - raw_roles = getattr(user, "roles", None) - user_roles = [] - if raw_roles is not None: - try: - user_roles = [ - role.name for role in raw_roles if hasattr(role, "name") - ] - except TypeError: - logger.debug("Could not iterate user.roles: %s", type(raw_roles)) - user_roles = [] - result.current_user = UserInfo( - id=getattr(user, "id", None), - username=getattr(user, "username", None), - first_name=getattr(user, "first_name", None), - last_name=getattr(user, "last_name", None), - email=getattr(user, "email", None), - roles=user_roles, - ) + result.current_user = serialize_user_object(user) return result diff --git a/tests/unit_tests/mcp_service/system/tool/test_get_current_user.py b/tests/unit_tests/mcp_service/system/tool/test_get_current_user.py index 0a0dddfa5dc..e8fb24674e2 100644 --- a/tests/unit_tests/mcp_service/system/tool/test_get_current_user.py +++ b/tests/unit_tests/mcp_service/system/tool/test_get_current_user.py @@ -221,6 +221,7 @@ class TestGetInstanceInfoCurrentUserViaMCP: mock_g_user.first_name = "Sophie" mock_g_user.last_name = "Beaumont" mock_g_user.email = "sophie@preset.io" + mock_g_user.active = True mock_g_user.roles = [mock_role] with ( From 0b8df8d3f2fdad033fa4637db14894cae10f514d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C4=90=E1=BB=97=20Tr=E1=BB=8Dng=20H=E1=BA=A3i?= <41283691+hainenber@users.noreply.github.com> Date: Fri, 13 Mar 2026 15:18:59 +0700 Subject: [PATCH 26/58] build(deps): update `geostyler-*` deps to latest major versions (#38151) Signed-off-by: hainenber --- superset-frontend/jest.config.js | 2 +- superset-frontend/package-lock.json | 613 ++++++------------ superset-frontend/package.json | 8 +- .../plugin-chart-cartodiagram/package.json | 8 +- .../test/util/layerUtil.test.ts | 7 +- .../LayerConfigsControl/GeoStylerWrapper.tsx | 1 + superset-frontend/webpack.config.js | 2 +- 7 files changed, 203 insertions(+), 438 deletions(-) diff --git a/superset-frontend/jest.config.js b/superset-frontend/jest.config.js index e0b12184eaf..d13cd1d03b0 100644 --- a/superset-frontend/jest.config.js +++ b/superset-frontend/jest.config.js @@ -69,7 +69,7 @@ module.exports = { ], coverageReporters: ['lcov', 'json-summary', 'html', 'text'], transformIgnorePatterns: [ - 'node_modules/(?!d3-(array|interpolate|color|time|scale|time-format|format)|internmap|@mapbox/tiny-sdf|remark-gfm|(?!@ngrx|(?!deck.gl)|d3-scale)|markdown-table|micromark-*.|decode-named-character-reference|character-entities|mdast-util-*.|unist-util-*.|ccount|escape-string-regexp|nanoid|uuid|@rjsf/*.|echarts|zrender|fetch-mock|pretty-ms|parse-ms|ol|@babel/runtime|@emotion|cheerio|cheerio/lib|parse5|dom-serializer|entities|htmlparser2|rehype-sanitize|hast-util-sanitize|unified|unist-.*|hast-.*|rehype-.*|remark-.*|mdast-.*|micromark-.*|parse-entities|property-information|space-separated-tokens|comma-separated-tokens|bail|devlop|zwitch|longest-streak|geostyler|geostyler-.*|react-error-boundary|react-json-tree|react-base16-styling|lodash-es|rbush|quickselect|react-diff-viewer-continued)', + 'node_modules/(?!d3-(array|interpolate|color|time|scale|time-format|format)|internmap|@mapbox/tiny-sdf|remark-gfm|(?!@ngrx|(?!deck.gl)|d3-scale)|markdown-table|micromark-*.|decode-named-character-reference|character-entities|mdast-util-*.|unist-util-*.|ccount|escape-string-regexp|nanoid|uuid|@rjsf/*.|echarts|zrender|fetch-mock|pretty-ms|parse-ms|ol|@babel/runtime|@emotion|cheerio|cheerio/lib|parse5|dom-serializer|entities|htmlparser2|rehype-sanitize|hast-util-sanitize|unified|unist-.*|hast-.*|rehype-.*|remark-.*|mdast-.*|micromark-.*|parse-entities|property-information|space-separated-tokens|comma-separated-tokens|bail|devlop|zwitch|longest-streak|geostyler|geostyler-.*|(?!geostyler)lodash|react-error-boundary|react-json-tree|react-base16-styling|lodash-es|rbush|quickselect|react-diff-viewer-continued)', ], preset: 'ts-jest', transform: { diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index 2a2ab4ced6f..cced9035249 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -89,11 +89,11 @@ "fs-extra": "^11.3.3", "fuse.js": "^7.1.0", "geolib": "^3.3.4", - "geostyler": "^14.1.3", + "geostyler": "^18.3.1", "geostyler-data": "^1.1.0", - "geostyler-openlayers-parser": "^4.3.0", - "geostyler-style": "7.5.0", - "geostyler-wfs-parser": "^2.0.3", + "geostyler-openlayers-parser": "^5.4.0", + "geostyler-style": "11.0.2", + "geostyler-wfs-parser": "^3.0.1", "google-auth-library": "^10.6.1", "immer": "^11.1.4", "interweave": "^13.1.1", @@ -2391,23 +2391,6 @@ "@babel/core": "^7.0.0" } }, - "node_modules/@babel/polyfill": { - "version": "7.12.1", - "resolved": "https://registry.npmjs.org/@babel/polyfill/-/polyfill-7.12.1.tgz", - "integrity": "sha512-X0pi0V6gxLi6lFZpGmeNa4zxtwEmCs42isWLNjZZDE0Y8yVfgu0T2OAHlzBbdYlqbW/YXVvoBHpATEM+goCj8g==", - "deprecated": "🚨 This package has been deprecated in favor of separate inclusion of a polyfill and regenerator-runtime (when needed). See the @babel/polyfill docs (https://babeljs.io/docs/en/babel-polyfill) for more information.", - "license": "MIT", - "dependencies": { - "core-js": "^2.6.5", - "regenerator-runtime": "^0.13.4" - } - }, - "node_modules/@babel/polyfill/node_modules/regenerator-runtime": { - "version": "0.13.11", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", - "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", - "license": "MIT" - }, "node_modules/@babel/preset-env": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.29.0.tgz", @@ -7471,29 +7454,26 @@ "license": "MIT" }, "node_modules/@monaco-editor/loader": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.4.0.tgz", - "integrity": "sha512-00ioBig0x642hytVspPl7DbQyaSWRaolYie/UFNjoTdvoKPzo6xrXLhTk9ixgIKcLH5b5vDOjVNiGyY+uDCUlg==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz", + "integrity": "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==", "license": "MIT", "dependencies": { "state-local": "^1.0.6" - }, - "peerDependencies": { - "monaco-editor": ">= 0.21.0 < 1" } }, "node_modules/@monaco-editor/react": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.6.0.tgz", - "integrity": "sha512-RFkU9/i7cN2bsq/iTkurMWOEErmYcY6JiQI3Jn+WeR/FGISH8JbHERjpS9oRuSOPvDMJI0Z8nJeKkbOs9sBYQw==", + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.7.0.tgz", + "integrity": "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==", "license": "MIT", "dependencies": { - "@monaco-editor/loader": "^1.4.0" + "@monaco-editor/loader": "^1.5.0" }, "peerDependencies": { "monaco-editor": ">= 0.25.0 < 1", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "node_modules/@napi-rs/wasm-runtime": { @@ -14395,12 +14375,14 @@ "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, "license": "MIT" }, "node_modules/@types/istanbul-lib-report": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "dev": true, "license": "MIT", "dependencies": { "@types/istanbul-lib-coverage": "*" @@ -15304,6 +15286,7 @@ "version": "21.0.3", "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "dev": true, "license": "MIT" }, "node_modules/@types/yauzl": { @@ -18587,15 +18570,6 @@ "readable-stream": "^3.4.0" } }, - "node_modules/blob": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/blob/-/blob-0.1.0.tgz", - "integrity": "sha512-k+GwK+4Rj+MPNT4qu+y6+kHp+mPmmNd+28zdrIo69QM9UvypK5Vhcw7jnRiY4KaOMAiOdn0NtPQGTb+Ox1Dtng==", - "license": "MIT", - "dependencies": { - "esm": "^3.2.25" - } - }, "node_modules/blob-util": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/blob-util/-/blob-util-2.0.2.tgz", @@ -19532,9 +19506,9 @@ } }, "node_modules/chroma-js": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/chroma-js/-/chroma-js-2.6.0.tgz", - "integrity": "sha512-BLHvCB9s8Z1EV4ethr6xnkl/P2YRFOGqfgvuMG/MyCbZPrTA+NeiByY6XvgF0zP4/2deU2CXnWyMa3zu1LqQ3A==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/chroma-js/-/chroma-js-3.2.0.tgz", + "integrity": "sha512-os/OippSlX1RlWWr+QDPcGUZs0uoqr32urfxESG9U93lhUfbnlyckte84Q8P1UQY/qth983AS1JONKmLS4T0nw==", "license": "(BSD-3-Clause AND Apache-2.0)" }, "node_modules/chrome-trace-event": { @@ -24824,15 +24798,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/esm": { - "version": "3.2.25", - "resolved": "https://registry.npmjs.org/esm/-/esm-3.2.25.tgz", - "integrity": "sha512-U1suiZ2oDVWv4zPO56S0NcR5QriEahGtdN2OR6FiOG4WJvcjBVFB0qI4+eKoWFH483PKGuLuu6V8Z4T5g63UVA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/espree": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", @@ -25334,6 +25299,18 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fast-xml-builder": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.0.0.tgz", + "integrity": "sha512-fpZuDogrAgnyt9oDDz+5DBz0zgPdPZz6D4IR7iESxRXElrlGTRkHJ9eEt+SACRJwT0FNFrt71DFQIUFBJfX/uQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/fast-xml-parser": { "version": "4.5.3", "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.3.tgz", @@ -26496,45 +26473,43 @@ "license": "MIT" }, "node_modules/geostyler": { - "version": "14.2.0", - "resolved": "https://registry.npmjs.org/geostyler/-/geostyler-14.2.0.tgz", - "integrity": "sha512-mcHNeds2OBvyCs+VdLHkq4vX1PrR/HKN7hnmySUVpNiZCZ4b0kDnPKe3gUOsh2wEs9pOC1soS9AbNlVC6XpH7w==", + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/geostyler/-/geostyler-18.3.1.tgz", + "integrity": "sha512-ZEZDwdAtP6Z4ANtlMfYtMwymdFMrr6n0kD4q8dMANA54taXgbeJKU2quC93MQlXlSo8Ka8Y1xI3qAliRasvWcw==", "license": "BSD-2-Clause", "dependencies": { - "@ant-design/icons": "^5.3.7", - "@babel/polyfill": "^7.12.1", + "@ant-design/icons": "^5.5.1", "@dnd-kit/core": "^6.1.0", "@dnd-kit/sortable": "^8.0.0", "@dnd-kit/utilities": "^3.2.2", - "@monaco-editor/react": "^4.6.0", + "@monaco-editor/react": "^4.7.0", "@types/chroma-js": "^2.4.4", "@types/color": "^3.0.6", "@types/file-saver": "^2.0.7", "@types/geojson": "^7946.0.14", - "@types/lodash": "^4.17.4", + "@types/lodash": "^4.17.5", "@ungap/url-search-params": "^0.2.2", - "antd": "^5.17.3", - "blob": "^0.1.0", - "chroma-js": "^2.4.2", + "antd": "^5.25.4", + "chroma-js": "3.2.0", "color": "^4.2.3", "csstype": "^3.1.3", "file-saver": "^2.0.5", - "geostyler-cql-parser": "^3.0.2", - "geostyler-data": "^1.0.0", - "geostyler-geojson-parser": "^1.0.1", - "geostyler-mapbox-parser": "^5.0.1", - "geostyler-openlayers-parser": "^4.3.0", - "geostyler-qgis-parser": "^2.0.0", - "geostyler-sld-parser": "^5.3.1", - "geostyler-style": "^8.1.0", - "geostyler-wfs-parser": "^2.0.0", - "lodash": "^4.17.21", - "monaco-editor": "^0.49.0", + "geostyler-cql-parser": "^4.1.0", + "geostyler-data": "^1.1.0", + "geostyler-geojson-parser": "^2.0.0", + "geostyler-mapbox-parser": "^6.1.1", + "geostyler-openlayers-parser": "^5.4.0", + "geostyler-qgis-parser": "^4.0.2", + "geostyler-sld-parser": "^8.3.0", + "geostyler-style": "^11.0.2", + "geostyler-wfs-parser": "^3.0.1", + "lodash-es": "^4.17.21", + "monaco-editor": "^0.52.0", "proj4": "^2.11.0", - "typescript-json-schema": "^0.64.0" + "typescript-json-schema": "^0.65.0" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.6.0" }, "funding": { "url": "https://opencollective.com/geostyler" @@ -26548,13 +26523,32 @@ } }, "node_modules/geostyler-cql-parser": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/geostyler-cql-parser/-/geostyler-cql-parser-3.0.2.tgz", - "integrity": "sha512-nfQOnWwFSrWtgl7DRmhwoa3r0BnpsMvEv7rb5D8ihpCUQS/YP09C2C1j6zKQVSnYRMl0545VnhtYtqejMgPaBg==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/geostyler-cql-parser/-/geostyler-cql-parser-4.1.1.tgz", + "integrity": "sha512-k594MfqCtQfdaNdK3jyLCiZz5bLeg4pjfAk3qgzfM9dwLmI277BI01Ti68Q+Q72dybUV1VIyGU0ky8teUi5Cmg==", "license": "BSD-2-Clause", "dependencies": { - "geostyler-style": "^7.2.0", - "lodash": "^4.17.21" + "geostyler-style": "^10.0.0" + }, + "engines": { + "node": ">=20.6.0", + "npm": ">=10.0.0" + }, + "funding": { + "url": "https://opencollective.com/geostyler" + } + }, + "node_modules/geostyler-cql-parser/node_modules/geostyler-style": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/geostyler-style/-/geostyler-style-10.5.0.tgz", + "integrity": "sha512-XnKrjm5XY2hBc98h5uKaAdYAgy+R9YuBzf8aGo4SPmYoPtAlnNaGWkRZB8j/vnuzQg8/kpGVOJ94o+42WUhuhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.6.0", + "npm": ">=10.0.0" + }, + "funding": { + "url": "https://opencollective.com/geostyler" } }, "node_modules/geostyler-data": { @@ -26571,209 +26565,28 @@ } }, "node_modules/geostyler-geojson-parser": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/geostyler-geojson-parser/-/geostyler-geojson-parser-1.0.1.tgz", - "integrity": "sha512-b7eJ2sCrYCC7fIDItxfbDH9r55dE58OXTQjPb/kIlXgH+7A2o2xp7pQlRXu5xCqM5lucQAAM9A7IfLLhbflznw==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/geostyler-geojson-parser/-/geostyler-geojson-parser-2.0.0.tgz", + "integrity": "sha512-FBBoVfaa9musK/L679I+2l8y+wS6EJ5C73RE9qJ4drHMmHE/JwliLS0cMFnrgrp+NlQVyu/dbv6CQs6PLFKnWA==", "license": "BSD-2-Clause", "dependencies": { - "@babel/polyfill": "^7.4.4", - "@types/geojson": "^7946.0.7", - "@types/jest": "^24.0.18", - "@types/json-schema": "^7.0.3", - "@types/node": "^12.7.3", "geostyler-data": "^1.0.0" - } - }, - "node_modules/geostyler-geojson-parser/node_modules/@jest/types": { - "version": "24.9.0", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-24.9.0.tgz", - "integrity": "sha512-XKK7ze1apu5JWQ5eZjHITP66AX+QsLlbaJRBGYr8pNzwcAE2JVkwnf0yqjHTsDRcjR0mujy/NmZMXw5kl+kGBw==", - "license": "MIT", - "dependencies": { - "@types/istanbul-lib-coverage": "^2.0.0", - "@types/istanbul-reports": "^1.1.1", - "@types/yargs": "^13.0.0" }, "engines": { - "node": ">= 6" - } - }, - "node_modules/geostyler-geojson-parser/node_modules/@types/istanbul-reports": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-1.1.2.tgz", - "integrity": "sha512-P/W9yOX/3oPZSpaYOCQzGqgCQRXn0FFO/V8bWrCQs+wLmvVVxk6CRBXALEvNs9OHIatlnlFokfhuDo2ug01ciw==", - "license": "MIT", - "dependencies": { - "@types/istanbul-lib-coverage": "*", - "@types/istanbul-lib-report": "*" - } - }, - "node_modules/geostyler-geojson-parser/node_modules/@types/jest": { - "version": "24.9.1", - "resolved": "https://registry.npmjs.org/@types/jest/-/jest-24.9.1.tgz", - "integrity": "sha512-Fb38HkXSVA4L8fGKEZ6le5bB8r6MRWlOCZbVuWZcmOMSCd2wCYOwN1ibj8daIoV9naq7aaOZjrLCoCMptKU/4Q==", - "license": "MIT", - "dependencies": { - "jest-diff": "^24.3.0" - } - }, - "node_modules/geostyler-geojson-parser/node_modules/@types/node": { - "version": "12.20.55", - "resolved": "https://registry.npmjs.org/@types/node/-/node-12.20.55.tgz", - "integrity": "sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==", - "license": "MIT" - }, - "node_modules/geostyler-geojson-parser/node_modules/@types/yargs": { - "version": "13.0.12", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-13.0.12.tgz", - "integrity": "sha512-qCxJE1qgz2y0hA4pIxjBR+PelCH0U5CK1XJXFwCNqfmliatKp47UCXXE9Dyk1OXBDLvsCF57TqQEJaeLfDYEOQ==", - "license": "MIT", - "dependencies": { - "@types/yargs-parser": "*" - } - }, - "node_modules/geostyler-geojson-parser/node_modules/ansi-regex": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz", - "integrity": "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/geostyler-geojson-parser/node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "license": "MIT", - "dependencies": { - "color-convert": "^1.9.0" + "node": ">=20.6.0" }, - "engines": { - "node": ">=4" - } - }, - "node_modules/geostyler-geojson-parser/node_modules/chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/geostyler-geojson-parser/node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "license": "MIT", - "dependencies": { - "color-name": "1.1.3" - } - }, - "node_modules/geostyler-geojson-parser/node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "license": "MIT" - }, - "node_modules/geostyler-geojson-parser/node_modules/diff-sequences": { - "version": "24.9.0", - "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-24.9.0.tgz", - "integrity": "sha512-Dj6Wk3tWyTE+Fo1rW8v0Xhwk80um6yFYKbuAxc9c3EZxIHFDYwbi34Uk42u1CdnIiVorvt4RmlSDjIPyzGC2ew==", - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/geostyler-geojson-parser/node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "license": "MIT", - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/geostyler-geojson-parser/node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/geostyler-geojson-parser/node_modules/jest-diff": { - "version": "24.9.0", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-24.9.0.tgz", - "integrity": "sha512-qMfrTs8AdJE2iqrTp0hzh7kTd2PQWrsFyj9tORoKmu32xjPjeE4NyjVRDz8ybYwqS2ik8N4hsIpiVTyFeo2lBQ==", - "license": "MIT", - "dependencies": { - "chalk": "^2.0.1", - "diff-sequences": "^24.9.0", - "jest-get-type": "^24.9.0", - "pretty-format": "^24.9.0" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/geostyler-geojson-parser/node_modules/jest-get-type": { - "version": "24.9.0", - "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-24.9.0.tgz", - "integrity": "sha512-lUseMzAley4LhIcpSP9Jf+fTrQ4a1yHQwLNeeVa2cEmbCGeoZAtYPOIv8JaxLD/sUpKxetKGP+gsHl8f8TSj8Q==", - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/geostyler-geojson-parser/node_modules/pretty-format": { - "version": "24.9.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-24.9.0.tgz", - "integrity": "sha512-00ZMZUiHaJrNfk33guavqgvfJS30sLYf0f8+Srklv0AMPodGGHcoHgksZ3OThYnIvOd+8yMCn0YiEOogjlgsnA==", - "license": "MIT", - "dependencies": { - "@jest/types": "^24.9.0", - "ansi-regex": "^4.0.0", - "ansi-styles": "^3.2.0", - "react-is": "^16.8.4" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/geostyler-geojson-parser/node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT" - }, - "node_modules/geostyler-geojson-parser/node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "license": "MIT", - "dependencies": { - "has-flag": "^3.0.0" - }, - "engines": { - "node": ">=4" + "funding": { + "url": "https://opencollective.com/geostyler" } }, "node_modules/geostyler-mapbox-parser": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/geostyler-mapbox-parser/-/geostyler-mapbox-parser-5.0.1.tgz", - "integrity": "sha512-uL79jfXuULPcg/Yxj9EGDU5IX0xIQmhawUL239aCwbFv4FiM9KTuTdXG1M7P5r3RV6EZsbE21ETahx4ZzojVkw==", + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/geostyler-mapbox-parser/-/geostyler-mapbox-parser-6.2.0.tgz", + "integrity": "sha512-aG7znml0nD62HAo+/H2MQz8uQIFnBRLiEcaZR2rf+ve/6MNGKidihCdXY231hAK/hw67BLeDYs22lxWub8wS9g==", "license": "BSD-2-Clause", "dependencies": { - "@types/mapbox-gl": "^2.7.18", - "geostyler-style": "^8.0.0" + "@types/mapbox-gl": "^3.4.1", + "geostyler-style": "^10.5.0" }, "engines": { "node": ">=18", @@ -26783,34 +26596,40 @@ "url": "https://opencollective.com/geostyler" } }, - "node_modules/geostyler-mapbox-parser/node_modules/geostyler-style": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/geostyler-style/-/geostyler-style-8.1.0.tgz", - "integrity": "sha512-8NgtzRc63bxC+1Vgqj/mMj77GX38CXXXWQ93PeZBdoMTkY9C/H0Anz38OrrlKdUgNVFZ/GJTNYwnX4wdaO5j6A==", - "license": "BSD-2-Clause", + "node_modules/geostyler-mapbox-parser/node_modules/@types/mapbox-gl": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@types/mapbox-gl/-/mapbox-gl-3.4.1.tgz", + "integrity": "sha512-NsGKKtgW93B+UaLPti6B7NwlxYlES5DpV5Gzj9F75rK5ALKsqSk15CiEHbOnTr09RGbr6ZYiCdI+59NNNcAImg==", + "license": "MIT", "dependencies": { - "@types/lodash": "^4.14.201", - "lodash": "^4.17.21" - }, + "@types/geojson": "*" + } + }, + "node_modules/geostyler-mapbox-parser/node_modules/geostyler-style": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/geostyler-style/-/geostyler-style-10.5.0.tgz", + "integrity": "sha512-XnKrjm5XY2hBc98h5uKaAdYAgy+R9YuBzf8aGo4SPmYoPtAlnNaGWkRZB8j/vnuzQg8/kpGVOJ94o+42WUhuhw==", + "license": "BSD-2-Clause", "engines": { - "node": ">=16.0.0", - "npm": ">=6.0.0" + "node": ">=20.6.0", + "npm": ">=10.0.0" }, "funding": { "url": "https://opencollective.com/geostyler" } }, "node_modules/geostyler-openlayers-parser": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/geostyler-openlayers-parser/-/geostyler-openlayers-parser-4.3.0.tgz", - "integrity": "sha512-9LoctzoxorZogOpp6nXT6SZGnISRpNR9M+MLyhYVTFlOtDi4Gb4fWbUnpqVzDxqMNl8anMmv7cpMsmw1bjWsLw==", + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/geostyler-openlayers-parser/-/geostyler-openlayers-parser-5.4.0.tgz", + "integrity": "sha512-BdlG10/wvok7KCOdtzM/NKfiUfkx+uqLDgesC3lNZnviz02S+zIQRtbAkb/Abin6/2boMFRL3i7YIt4D98xMNw==", "license": "BSD-2-Clause", "dependencies": { "css-font-parser": "^2.0.0", - "geostyler-style": "^8.1.0" + "geostyler-style": "^11.0.1", + "lodash": "^4.17.21" }, "engines": { - "node": ">=18" + "node": ">=20.6.0" }, "funding": { "url": "https://opencollective.com/geostyler" @@ -26819,34 +26638,17 @@ "ol": ">=7.4" } }, - "node_modules/geostyler-openlayers-parser/node_modules/geostyler-style": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/geostyler-style/-/geostyler-style-8.1.0.tgz", - "integrity": "sha512-8NgtzRc63bxC+1Vgqj/mMj77GX38CXXXWQ93PeZBdoMTkY9C/H0Anz38OrrlKdUgNVFZ/GJTNYwnX4wdaO5j6A==", - "license": "BSD-2-Clause", - "dependencies": { - "@types/lodash": "^4.14.201", - "lodash": "^4.17.21" - }, - "engines": { - "node": ">=16.0.0", - "npm": ">=6.0.0" - }, - "funding": { - "url": "https://opencollective.com/geostyler" - } - }, "node_modules/geostyler-qgis-parser": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/geostyler-qgis-parser/-/geostyler-qgis-parser-2.1.0.tgz", - "integrity": "sha512-0Xu3Dd1hBKSS7Qv8//ciHavVJI3Ez03JxrYfzF0HNZq32tNL/DITxxbD8nYb/uWawIsBWnJMFbmykxsqC1EEXA==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/geostyler-qgis-parser/-/geostyler-qgis-parser-4.1.0.tgz", + "integrity": "sha512-LuATAdIrL2+6D3sCXSqjbztOJdbuDdDuAF+FJpIoMlcITSpXIYe1aBjoE/roQzNM/DwWxqSMb3yHTaozkytvnA==", "license": "BSD-2-Clause", "dependencies": { "buffer": "^6.0.3", "color": "^4.2.3", "core-js": "^3.26.1", - "geostyler-cql-parser": "4.0.0-beta.4", - "geostyler-style": "9.0.0-next.5", + "geostyler-cql-parser": "^4.0.0", + "geostyler-style": "^10.0.0", "string_decoder": "^1.3.0", "timers": "^0.1.1", "xml2js": "^0.6.0" @@ -26879,26 +26681,10 @@ "ieee754": "^1.2.1" } }, - "node_modules/geostyler-qgis-parser/node_modules/geostyler-cql-parser": { - "version": "4.0.0-beta.4", - "resolved": "https://registry.npmjs.org/geostyler-cql-parser/-/geostyler-cql-parser-4.0.0-beta.4.tgz", - "integrity": "sha512-aBR1oAx6gkmz6PZVbd8zmCQ0gwdeh/g7bkDL9v/YURZCInLAqH5o4oGP72ljt3Qjc6/zYwm5TYuJ3p+wwe2nkA==", - "license": "BSD-2-Clause", - "dependencies": { - "geostyler-style": "9.0.0-next.5" - }, - "engines": { - "node": ">=20.6.0", - "npm": ">=10.0.0" - }, - "funding": { - "url": "https://opencollective.com/geostyler" - } - }, "node_modules/geostyler-qgis-parser/node_modules/geostyler-style": { - "version": "9.0.0-next.5", - "resolved": "https://registry.npmjs.org/geostyler-style/-/geostyler-style-9.0.0-next.5.tgz", - "integrity": "sha512-nbTyL5i4zAB303RUXpVRKhrXceSKu2sOMWqjXjitjII31eOIKybS0iKV5C4f1qktE3TweJY/xPtC6aP6rTsQAw==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/geostyler-style/-/geostyler-style-10.5.0.tgz", + "integrity": "sha512-XnKrjm5XY2hBc98h5uKaAdYAgy+R9YuBzf8aGo4SPmYoPtAlnNaGWkRZB8j/vnuzQg8/kpGVOJ94o+42WUhuhw==", "license": "BSD-2-Clause", "engines": { "node": ">=20.6.0", @@ -26909,66 +26695,74 @@ } }, "node_modules/geostyler-sld-parser": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/geostyler-sld-parser/-/geostyler-sld-parser-5.4.0.tgz", - "integrity": "sha512-TY/gwMoE/M8Xv8ykviC1NqygykrojvrFSCRIc/+CNww5VS0Qf9pu9JxiSQONGfR6QWDPtQrfFIoazyH67c0jTg==", + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/geostyler-sld-parser/-/geostyler-sld-parser-8.4.0.tgz", + "integrity": "sha512-XR7o5L1n/+xBk/W1WVobX7ftoeoRy486o7qfI1D24PYcRFK3EBr5Dh1TZgGI2U7VXDWx2/S4ipl0hztqbjYnxw==", "license": "BSD-2-Clause", "dependencies": { - "fast-xml-parser": "^4.2.2", - "geostyler-style": "^8.1.0", - "i18next": "^23.11.5", + "fast-xml-parser": "^5.2.3", + "geostyler-style": "^11.0.2", "lodash": "^4.17.21" }, + "engines": { + "node": ">=20.6.0" + }, "funding": { "url": "https://opencollective.com/geostyler" } }, - "node_modules/geostyler-sld-parser/node_modules/geostyler-style": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/geostyler-style/-/geostyler-style-8.1.0.tgz", - "integrity": "sha512-8NgtzRc63bxC+1Vgqj/mMj77GX38CXXXWQ93PeZBdoMTkY9C/H0Anz38OrrlKdUgNVFZ/GJTNYwnX4wdaO5j6A==", - "license": "BSD-2-Clause", + "node_modules/geostyler-sld-parser/node_modules/fast-xml-parser": { + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.4.2.tgz", + "integrity": "sha512-pw/6pIl4k0CSpElPEJhDppLzaixDEuWui2CUQQBH/ECDf7+y6YwA4Gf7Tyb0Rfe4DIMuZipYj4AEL0nACKglvQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", "dependencies": { - "@types/lodash": "^4.14.201", - "lodash": "^4.17.21" + "fast-xml-builder": "^1.0.0", + "strnum": "^2.1.2" }, - "engines": { - "node": ">=16.0.0", - "npm": ">=6.0.0" - }, - "funding": { - "url": "https://opencollective.com/geostyler" + "bin": { + "fxparser": "src/cli/cli.js" } }, + "node_modules/geostyler-sld-parser/node_modules/strnum": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.0.tgz", + "integrity": "sha512-Y7Bj8XyJxnPAORMZj/xltsfo55uOiyHcU2tnAVzHUnSJR/KsEX+9RoDeXEnsXtl/CX4fAcrt64gZ13aGaWPeBg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/geostyler-style": { - "version": "7.5.0", - "resolved": "https://registry.npmjs.org/geostyler-style/-/geostyler-style-7.5.0.tgz", - "integrity": "sha512-0qlFOwDl9cdiiNUk6ijRSpBq+W3Kpry1aZS++BE8EPBxvbFgoTsKU6JIriKLmCqPqBmJIVnMFMUEk6Sf3n0ZIA==", + "version": "11.0.2", + "resolved": "https://registry.npmjs.org/geostyler-style/-/geostyler-style-11.0.2.tgz", + "integrity": "sha512-HDCjVf7qFIX1FRcp1+8uKD+Iqt/6rWzmwsZdzcOCBcdOoh5vFlLcAchjMP0eDgdVspPpwK6eZSSSYDabUGTZzA==", "license": "BSD-2-Clause", - "dependencies": { - "@types/lodash": "^4.14.201", - "lodash": "^4.17.21" - }, "engines": { - "node": ">=16.0.0", - "npm": ">=6.0.0" + "node": ">=20.6.0", + "npm": ">=10.0.0" }, "funding": { "url": "https://opencollective.com/geostyler" } }, "node_modules/geostyler-wfs-parser": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/geostyler-wfs-parser/-/geostyler-wfs-parser-2.0.3.tgz", - "integrity": "sha512-23gmufyveYB/jhVzxPj/eb/zkI+xWSzTKqGO7gcAh0NYUFPI+UG3WTJhzT4Dj9G+ZTpSUnXOSmkHr4M7jZsakA==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/geostyler-wfs-parser/-/geostyler-wfs-parser-3.0.1.tgz", + "integrity": "sha512-U4Y9F6yTG0wRatkHnFLYBubcjXd1FwHs12zhFxLhZXzddOc4Gc25ffqe1o7x6ypmlbg0Tkb3aYsIXa3gfcNRfw==", "license": "BSD-2-Clause", "dependencies": { - "@types/geojson": "^7946.0.14", - "@types/json-schema": "^7.0.15", - "@types/lodash": "^4.17.4", "fast-xml-parser": "^4.4.0", - "geostyler-data": "^1.0.0", - "lodash": "^4.17.21" + "geostyler-data": "^1.0.0" }, "funding": { "url": "https://opencollective.com/geostyler" @@ -26988,23 +26782,6 @@ "react": ">=16.8.0" } }, - "node_modules/geostyler/node_modules/geostyler-style": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/geostyler-style/-/geostyler-style-8.1.0.tgz", - "integrity": "sha512-8NgtzRc63bxC+1Vgqj/mMj77GX38CXXXWQ93PeZBdoMTkY9C/H0Anz38OrrlKdUgNVFZ/GJTNYwnX4wdaO5j6A==", - "license": "BSD-2-Clause", - "dependencies": { - "@types/lodash": "^4.14.201", - "lodash": "^4.17.21" - }, - "engines": { - "node": ">=16.0.0", - "npm": ">=6.0.0" - }, - "funding": { - "url": "https://opencollective.com/geostyler" - } - }, "node_modules/geotiff": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/geotiff/-/geotiff-3.0.3.tgz", @@ -28757,29 +28534,6 @@ "node": ">=10.18" } }, - "node_modules/i18next": { - "version": "23.16.8", - "resolved": "https://registry.npmjs.org/i18next/-/i18next-23.16.8.tgz", - "integrity": "sha512-06r/TitrM88Mg5FdUXAKL96dJMzgqLE5dv3ryBAra4KCwD9mJ4ndOTS95ZuymIGoE+2hzfdaMak2X11/es7ZWg==", - "funding": [ - { - "type": "individual", - "url": "https://locize.com" - }, - { - "type": "individual", - "url": "https://locize.com/i18next.html" - }, - { - "type": "individual", - "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" - } - ], - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.23.2" - } - }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -37247,9 +37001,9 @@ } }, "node_modules/monaco-editor": { - "version": "0.49.0", - "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.49.0.tgz", - "integrity": "sha512-2I8/T3X/hLxB2oPHgqcNYUVdA/ZEFShT7IAujifIPMfKkNbLOqY8XCoyHCXrsdjb36dW9MwoTwBCFpXKMwNwaQ==", + "version": "0.52.2", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.52.2.tgz", + "integrity": "sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==", "license": "MIT" }, "node_modules/mousetrap": { @@ -48117,18 +47871,18 @@ } }, "node_modules/typescript-json-schema": { - "version": "0.64.0", - "resolved": "https://registry.npmjs.org/typescript-json-schema/-/typescript-json-schema-0.64.0.tgz", - "integrity": "sha512-Sew8llkYSzpxaMoGjpjD6NMFCr6DoWFHLs7Bz1LU48pzzi8ok8W+GZs9cG87IMBpC0UI7qwBMUI2um0LGxxLOg==", + "version": "0.65.1", + "resolved": "https://registry.npmjs.org/typescript-json-schema/-/typescript-json-schema-0.65.1.tgz", + "integrity": "sha512-tuGH7ff2jPaUYi6as3lHyHcKpSmXIqN7/mu50x3HlYn0EHzLpmt3nplZ7EuhUkO0eqDRc9GqWNkfjgBPIS9kxg==", "license": "BSD-3-Clause", "dependencies": { "@types/json-schema": "^7.0.9", - "@types/node": "^16.9.2", + "@types/node": "^18.11.9", "glob": "^7.1.7", "path-equal": "^1.2.5", "safe-stable-stringify": "^2.2.0", "ts-node": "^10.9.1", - "typescript": "~5.1.0", + "typescript": "~5.5.0", "yargs": "^17.1.1" }, "bin": { @@ -48136,15 +47890,18 @@ } }, "node_modules/typescript-json-schema/node_modules/@types/node": { - "version": "16.18.126", - "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.126.tgz", - "integrity": "sha512-OTcgaiwfGFBKacvfwuHzzn1KLxH/er8mluiy8/uM3sGXHaRe73RrSIj01jow9t4kJEW633Ov+cOexXeiApTyAw==", - "license": "MIT" + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } }, "node_modules/typescript-json-schema/node_modules/typescript": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.1.6.tgz", - "integrity": "sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==", + "version": "5.5.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", + "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -48154,6 +47911,12 @@ "node": ">=14.17" } }, + "node_modules/typescript-json-schema/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "license": "MIT" + }, "node_modules/uglify-js": { "version": "3.19.3", "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", @@ -52948,11 +52711,11 @@ "@superset-ui/chart-controls": "*", "@superset-ui/core": "*", "@types/react-redux": "*", - "geostyler": "^14.1.3", + "geostyler": "^18.3.1", "geostyler-data": "^1.0.0", - "geostyler-openlayers-parser": "^4.0.0", - "geostyler-style": "^7.2.0", - "geostyler-wfs-parser": "^2.0.0", + "geostyler-openlayers-parser": "^5.4.0", + "geostyler-style": "^11.0.2", + "geostyler-wfs-parser": "^3.0.1", "ol": "^10.8.0", "polished": "*", "react": "^17.0.2", diff --git a/superset-frontend/package.json b/superset-frontend/package.json index 107ca45ec74..7006f45b582 100644 --- a/superset-frontend/package.json +++ b/superset-frontend/package.json @@ -170,11 +170,11 @@ "fs-extra": "^11.3.3", "fuse.js": "^7.1.0", "geolib": "^3.3.4", - "geostyler": "^14.1.3", + "geostyler": "^18.3.1", "geostyler-data": "^1.1.0", - "geostyler-openlayers-parser": "^4.3.0", - "geostyler-style": "7.5.0", - "geostyler-wfs-parser": "^2.0.3", + "geostyler-openlayers-parser": "^5.4.0", + "geostyler-style": "11.0.2", + "geostyler-wfs-parser": "^3.0.1", "google-auth-library": "^10.6.1", "immer": "^11.1.4", "interweave": "^13.1.1", diff --git a/superset-frontend/plugins/plugin-chart-cartodiagram/package.json b/superset-frontend/plugins/plugin-chart-cartodiagram/package.json index 505d11bb3bf..e467c2103db 100644 --- a/superset-frontend/plugins/plugin-chart-cartodiagram/package.json +++ b/superset-frontend/plugins/plugin-chart-cartodiagram/package.json @@ -40,11 +40,11 @@ "@superset-ui/core": "*", "@apache-superset/core": "*", "@types/react-redux": "*", - "geostyler": "^14.1.3", + "geostyler": "^18.3.1", "geostyler-data": "^1.0.0", - "geostyler-openlayers-parser": "^4.0.0", - "geostyler-style": "^7.2.0", - "geostyler-wfs-parser": "^2.0.0", + "geostyler-openlayers-parser": "^5.4.0", + "geostyler-style": "^11.0.2", + "geostyler-wfs-parser": "^3.0.1", "ol": "^10.8.0", "polished": "*", "react": "^17.0.2", diff --git a/superset-frontend/plugins/plugin-chart-cartodiagram/test/util/layerUtil.test.ts b/superset-frontend/plugins/plugin-chart-cartodiagram/test/util/layerUtil.test.ts index 05f63e9b8a5..a4141d89543 100644 --- a/superset-frontend/plugins/plugin-chart-cartodiagram/test/util/layerUtil.test.ts +++ b/superset-frontend/plugins/plugin-chart-cartodiagram/test/util/layerUtil.test.ts @@ -36,6 +36,7 @@ describe('layerUtil', () => { describe('createWfsLayer', () => { test('properly applies style', async () => { const colorToExpect = '#123456'; + const fillColor = '#ff0000'; const wfsLayerConf: WfsLayerConf = { title: 'osm:osm-fuel', @@ -61,7 +62,7 @@ describe('layerUtil', () => { }, { kind: 'Fill', - color: '#000000', + color: fillColor, }, ], }, @@ -76,8 +77,8 @@ describe('layerUtil', () => { expect(style!.length).toEqual(3); // @ts-expect-error upgrade `ol` package for better type of StyleLike type. - const colorAtLayer = style![1].getImage().getFill().getColor(); - expect(colorToExpect).toEqual(colorAtLayer); + const colorAtLayer = style![2].getFill().getColor(); + expect(colorAtLayer).toEqual(fillColor); }); }); diff --git a/superset-frontend/src/explore/components/controls/LayerConfigsControl/GeoStylerWrapper.tsx b/superset-frontend/src/explore/components/controls/LayerConfigsControl/GeoStylerWrapper.tsx index d43da761ef8..bed0f9ef427 100644 --- a/superset-frontend/src/explore/components/controls/LayerConfigsControl/GeoStylerWrapper.tsx +++ b/superset-frontend/src/explore/components/controls/LayerConfigsControl/GeoStylerWrapper.tsx @@ -25,6 +25,7 @@ import { CardStyle } from 'geostyler'; import { FC } from 'react'; import { GeoStylerWrapperProps } from './types'; +import 'geostyler/dist/index.css'; export const GeoStylerWrapper: FC = ({ className, diff --git a/superset-frontend/webpack.config.js b/superset-frontend/webpack.config.js index 7dffa33dd1c..0963b3454be 100644 --- a/superset-frontend/webpack.config.js +++ b/superset-frontend/webpack.config.js @@ -490,7 +490,7 @@ const config = { }, }, { - test: /node_modules\/(geostyler-style|geostyler-qgis-parser)\/.*\.js$/, + test: /node_modules\/(geostyler|geostyler-openlayers-parser|geostyler-mapbox-parser|geostyler-sld-parser)\/.*\.js$/, resolve: { fullySpecified: false, }, From fc156d0014b032bbd10548e8167426d5b32e5f8e Mon Sep 17 00:00:00 2001 From: Amin Ghadersohi Date: Fri, 13 Mar 2026 09:53:30 +0100 Subject: [PATCH 27/58] fix(mcp): replace uuid with url and changed_on_humanized in default list columns (#38566) Co-authored-by: Claude Opus 4.6 --- .../mcp_service/chart/tool/list_charts.py | 6 ++- .../mcp_service/common/schema_discovery.py | 22 +++++---- .../dashboard/tool/list_dashboards.py | 6 ++- .../mcp_service/dataset/tool/list_datasets.py | 5 +- .../chart/tool/test_list_charts.py | 12 ++++- .../dashboard/tool/test_dashboard_tools.py | 19 ++++---- .../dataset/tool/test_dataset_tools.py | 25 ++++++---- .../system/tool/test_get_schema.py | 47 ++++++++++++++++--- 8 files changed, 102 insertions(+), 40 deletions(-) diff --git a/superset/mcp_service/chart/tool/list_charts.py b/superset/mcp_service/chart/tool/list_charts.py index 3dacb3a7ec8..8ef2daf91f0 100644 --- a/superset/mcp_service/chart/tool/list_charts.py +++ b/superset/mcp_service/chart/tool/list_charts.py @@ -47,7 +47,8 @@ DEFAULT_CHART_COLUMNS = [ "id", "slice_name", "viz_type", - "uuid", + "url", + "changed_on_humanized", ] SORTABLE_CHART_COLUMNS = [ @@ -66,7 +67,8 @@ SORTABLE_CHART_COLUMNS = [ async def list_charts(request: ListChartsRequest, ctx: Context) -> ChartList: """List charts with filtering and search. - Returns chart metadata including id, name, and viz_type. + Returns chart metadata including id, name, viz_type, URL, and last + modified time. Sortable columns for order_column: id, slice_name, viz_type, datasource_name, description, changed_on, created_on diff --git a/superset/mcp_service/common/schema_discovery.py b/superset/mcp_service/common/schema_discovery.py index 530d26c3875..dda1a0d5412 100644 --- a/superset/mcp_service/common/schema_discovery.py +++ b/superset/mcp_service/common/schema_discovery.py @@ -234,7 +234,7 @@ def get_columns_from_model( # - Extra columns (computed/relationship fields not on the model) # Chart configuration -CHART_DEFAULT_COLUMNS = ["id", "slice_name", "viz_type", "uuid"] +CHART_DEFAULT_COLUMNS = ["id", "slice_name", "viz_type", "url", "changed_on_humanized"] CHART_SORTABLE_COLUMNS = [ "id", "slice_name", @@ -258,7 +258,7 @@ CHART_EXTRA_COLUMNS: dict[str, ColumnMetadata] = { is_default=False, ), "url": ColumnMetadata( - name="url", description="Chart URL", type="str", is_default=False + name="url", description="Chart URL", type="str", is_default=True ), "form_data": ColumnMetadata( name="form_data", @@ -282,7 +282,7 @@ CHART_EXTRA_COLUMNS: dict[str, ColumnMetadata] = { name="changed_on_humanized", description="Humanized modification time", type="str", - is_default=False, + is_default=True, ), "created_by": ColumnMetadata( name="created_by", @@ -311,7 +311,7 @@ CHART_EXTRA_COLUMNS: dict[str, ColumnMetadata] = { } # Dataset configuration -DATASET_DEFAULT_COLUMNS = ["id", "table_name", "schema", "uuid"] +DATASET_DEFAULT_COLUMNS = ["id", "table_name", "schema", "changed_on_humanized"] DATASET_SORTABLE_COLUMNS = [ "id", "table_name", @@ -343,7 +343,7 @@ DATASET_EXTRA_COLUMNS: dict[str, ColumnMetadata] = { name="changed_on_humanized", description="Humanized modification time", type="str", - is_default=False, + is_default=True, ), "created_by": ColumnMetadata( name="created_by", @@ -384,7 +384,13 @@ DATASET_EXTRA_COLUMNS: dict[str, ColumnMetadata] = { } # Dashboard configuration -DASHBOARD_DEFAULT_COLUMNS = ["id", "dashboard_title", "slug", "uuid"] +DASHBOARD_DEFAULT_COLUMNS = [ + "id", + "dashboard_title", + "slug", + "url", + "changed_on_humanized", +] DASHBOARD_SORTABLE_COLUMNS = [ "id", "dashboard_title", @@ -396,7 +402,7 @@ DASHBOARD_SORTABLE_COLUMNS = [ DASHBOARD_SEARCH_COLUMNS = ["dashboard_title", "slug"] DASHBOARD_EXTRA_COLUMNS: dict[str, ColumnMetadata] = { "url": ColumnMetadata( - name="url", description="Dashboard URL", type="str", is_default=False + name="url", description="Dashboard URL", type="str", is_default=True ), "changed_by": ColumnMetadata( name="changed_by", @@ -414,7 +420,7 @@ DASHBOARD_EXTRA_COLUMNS: dict[str, ColumnMetadata] = { name="changed_on_humanized", description="Humanized modification time", type="str", - is_default=False, + is_default=True, ), "created_by": ColumnMetadata( name="created_by", diff --git a/superset/mcp_service/dashboard/tool/list_dashboards.py b/superset/mcp_service/dashboard/tool/list_dashboards.py index 78bfec2794a..823533a9086 100644 --- a/superset/mcp_service/dashboard/tool/list_dashboards.py +++ b/superset/mcp_service/dashboard/tool/list_dashboards.py @@ -49,7 +49,8 @@ DEFAULT_DASHBOARD_COLUMNS = [ "id", "dashboard_title", "slug", - "uuid", + "url", + "changed_on_humanized", ] SORTABLE_DASHBOARD_COLUMNS = [ @@ -68,7 +69,8 @@ async def list_dashboards( request: ListDashboardsRequest, ctx: Context ) -> DashboardList: """List dashboards with filtering and search. Returns dashboard metadata - including title, slug, and UUID. Use select_columns to request additional fields. + including title, slug, URL, and last modified time. Use select_columns to + request additional fields. Sortable columns for order_column: id, dashboard_title, slug, published, changed_on, created_on diff --git a/superset/mcp_service/dataset/tool/list_datasets.py b/superset/mcp_service/dataset/tool/list_datasets.py index 1bdbcb381fd..4810350df1a 100644 --- a/superset/mcp_service/dataset/tool/list_datasets.py +++ b/superset/mcp_service/dataset/tool/list_datasets.py @@ -49,7 +49,7 @@ DEFAULT_DATASET_COLUMNS = [ "id", "table_name", "schema", - "uuid", + "changed_on_humanized", ] SORTABLE_DATASET_COLUMNS = [ @@ -66,7 +66,8 @@ SORTABLE_DATASET_COLUMNS = [ async def list_datasets(request: ListDatasetsRequest, ctx: Context) -> DatasetList: """List datasets with filtering and search. - Returns dataset metadata including columns and metrics. + Returns dataset metadata including table name, schema, and last modified + time. Sortable columns for order_column: id, table_name, schema, changed_on, created_on diff --git a/tests/unit_tests/mcp_service/chart/tool/test_list_charts.py b/tests/unit_tests/mcp_service/chart/tool/test_list_charts.py index a379e5309ee..c7fa505ac93 100644 --- a/tests/unit_tests/mcp_service/chart/tool/test_list_charts.py +++ b/tests/unit_tests/mcp_service/chart/tool/test_list_charts.py @@ -68,7 +68,8 @@ class TestListChartsRequestSchema: """Test creating request with all defaults. Note: select_columns defaults to empty list, which triggers - minimal default columns (id, slice_name, viz_type, uuid) in the tool. + minimal default columns (id, slice_name, viz_type, url, + changed_on_humanized) in the tool. """ request = ListChartsRequest() @@ -183,7 +184,13 @@ class TestChartDefaultColumnFiltering: from superset.mcp_service.common.schema_discovery import CHART_DEFAULT_COLUMNS # Required minimal columns must be present - required_columns = {"id", "slice_name", "viz_type", "uuid"} + required_columns = { + "id", + "slice_name", + "viz_type", + "url", + "changed_on_humanized", + } assert required_columns.issubset(set(CHART_DEFAULT_COLUMNS)) # Heavy columns should NOT be in defaults @@ -191,6 +198,7 @@ class TestChartDefaultColumnFiltering: assert "query_context" not in CHART_DEFAULT_COLUMNS assert "description" not in CHART_DEFAULT_COLUMNS assert "datasource_name" not in CHART_DEFAULT_COLUMNS + assert "uuid" not in CHART_DEFAULT_COLUMNS def test_empty_select_columns_default(self): """Test that select_columns defaults to empty list which triggers 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 9defa4a5a02..fea9a05e3f1 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 @@ -108,14 +108,14 @@ async def test_list_dashboards_basic(mock_list, mcp_server): dashboards = data["dashboards"] assert len(dashboards) == 1 assert dashboards[0]["dashboard_title"] == "Test Dashboard" - assert dashboards[0]["uuid"] == "test-dashboard-uuid-1" assert dashboards[0]["slug"] == "test-dashboard" # Note: published is not in minimal default columns (id, dashboard_title, - # slug, uuid) - use select_columns to include it if needed + # slug, url, changed_on_humanized) - use select_columns to include it - assert "uuid" in data["columns_requested"] + assert "url" in data["columns_requested"] assert "slug" in data["columns_requested"] - assert "uuid" in data["columns_loaded"] + assert "changed_on_humanized" in data["columns_requested"] + assert "url" in data["columns_loaded"] assert "slug" in data["columns_loaded"] @@ -529,13 +529,14 @@ class TestDashboardDefaultColumnFiltering: DASHBOARD_DEFAULT_COLUMNS, ) - # Should have exactly 4 minimal columns - assert len(DASHBOARD_DEFAULT_COLUMNS) == 4 + # Should have exactly 5 minimal columns + assert len(DASHBOARD_DEFAULT_COLUMNS) == 5 assert set(DASHBOARD_DEFAULT_COLUMNS) == { "id", "dashboard_title", "slug", - "uuid", + "url", + "changed_on_humanized", } # Heavy columns should NOT be in defaults @@ -543,6 +544,7 @@ class TestDashboardDefaultColumnFiltering: assert "published" not in DASHBOARD_DEFAULT_COLUMNS assert "json_metadata" not in DASHBOARD_DEFAULT_COLUMNS assert "position_json" not in DASHBOARD_DEFAULT_COLUMNS + assert "uuid" not in DASHBOARD_DEFAULT_COLUMNS def test_empty_select_columns_default(self): """Test that select_columns defaults to empty list which triggers @@ -604,7 +606,8 @@ class TestDashboardDefaultColumnFiltering: assert "id" in data["columns_requested"] assert "dashboard_title" in data["columns_requested"] assert "slug" in data["columns_requested"] - assert "uuid" in data["columns_requested"] + assert "url" in data["columns_requested"] + assert "changed_on_humanized" in data["columns_requested"] # Verify heavy columns are NOT in columns_loaded by default assert "json_metadata" not in data["columns_loaded"] diff --git a/tests/unit_tests/mcp_service/dataset/tool/test_dataset_tools.py b/tests/unit_tests/mcp_service/dataset/tool/test_dataset_tools.py index e1ec4dd99eb..6ea8342be13 100644 --- a/tests/unit_tests/mcp_service/dataset/tool/test_dataset_tools.py +++ b/tests/unit_tests/mcp_service/dataset/tool/test_dataset_tools.py @@ -194,13 +194,13 @@ async def test_list_datasets_basic(mock_list, mcp_server): assert len(data["datasets"]) == 1 assert data["datasets"][0]["id"] == 1 assert data["datasets"][0]["table_name"] == "Test DatasetInfo" - assert data["datasets"][0]["uuid"] == "test-dataset-uuid-1" # Note: columns and metrics are not in minimal default columns - # (id, table_name, schema, uuid). Use select_columns to include them. + # (id, table_name, schema, changed_on_humanized). Use select_columns + # to include them. - # Verify UUID is in default columns (datasets don't have slugs) - assert "uuid" in data["columns_requested"] - assert "uuid" in data["columns_loaded"] + # Verify changed_on_humanized is in default columns + assert "changed_on_humanized" in data["columns_requested"] + assert "changed_on_humanized" in data["columns_loaded"] @patch("superset.daos.dataset.DatasetDAO.list") @@ -1069,7 +1069,8 @@ async def test_list_datasets_includes_columns_and_metrics(mock_list, mcp_server) """Test that columns and metrics are included when explicitly requested. Note: columns and metrics are not in minimal default columns - (id, table_name, schema, uuid). Use select_columns to include them. + (id, table_name, schema, changed_on_humanized). Use select_columns to + include them. """ dataset = MagicMock() dataset.id = 11 @@ -1198,7 +1199,13 @@ class TestDatasetDefaultColumnFiltering: # Should have exactly 4 minimal columns assert len(DATASET_DEFAULT_COLUMNS) == 4 - assert set(DATASET_DEFAULT_COLUMNS) == {"id", "table_name", "schema", "uuid"} + assert set(DATASET_DEFAULT_COLUMNS) == { + "id", + "table_name", + "schema", + "changed_on_humanized", + } + assert "uuid" not in DATASET_DEFAULT_COLUMNS # Heavy columns should NOT be in defaults assert "columns" not in DATASET_DEFAULT_COLUMNS @@ -1229,7 +1236,7 @@ class TestDatasetDefaultColumnFiltering: "id", "table_name", "schema", - "uuid", + "changed_on_humanized", } # Verify heavy columns are NOT in columns_loaded @@ -1312,7 +1319,7 @@ class TestDatasetDefaultColumnFiltering: dataset_item = data["datasets"][0] # Verify ONLY default columns are present in the response item - expected_keys = {"id", "table_name", "schema_name", "uuid"} + expected_keys = {"id", "table_name", "schema_name", "changed_on_humanized"} actual_keys = set(dataset_item.keys()) # The response should only contain the default columns, NOT all columns diff --git a/tests/unit_tests/mcp_service/system/tool/test_get_schema.py b/tests/unit_tests/mcp_service/system/tool/test_get_schema.py index 38c30a3e47d..1d4e857be33 100644 --- a/tests/unit_tests/mcp_service/system/tool/test_get_schema.py +++ b/tests/unit_tests/mcp_service/system/tool/test_get_schema.py @@ -116,34 +116,55 @@ class TestModelSchemaInfo: assert info.model_type == "chart" assert len(info.select_columns) > 0 # Check default columns include required minimal set - required_columns = {"id", "slice_name", "viz_type", "uuid"} + required_columns = { + "id", + "slice_name", + "viz_type", + "url", + "changed_on_humanized", + } assert required_columns.issubset(set(info.default_select)) assert "slice_name" in info.filter_columns assert info.default_sort == "changed_on" def test_dataset_default_columns(self): """Test dataset default columns include required minimal set.""" - required_columns = {"id", "table_name", "schema", "uuid"} + required_columns = {"id", "table_name", "schema", "changed_on_humanized"} assert required_columns.issubset(set(DATASET_DEFAULT_COLUMNS)) # These should NOT be in defaults assert "columns" not in DATASET_DEFAULT_COLUMNS assert "metrics" not in DATASET_DEFAULT_COLUMNS + assert "uuid" not in DATASET_DEFAULT_COLUMNS def test_dashboard_default_columns(self): """Test dashboard default columns include required minimal set.""" - required_columns = {"id", "dashboard_title", "slug", "uuid"} + required_columns = { + "id", + "dashboard_title", + "slug", + "url", + "changed_on_humanized", + } assert required_columns.issubset(set(DASHBOARD_DEFAULT_COLUMNS)) # These should NOT be in defaults assert "published" not in DASHBOARD_DEFAULT_COLUMNS assert "charts" not in DASHBOARD_DEFAULT_COLUMNS + assert "uuid" not in DASHBOARD_DEFAULT_COLUMNS def test_chart_default_columns(self): """Test chart default columns include required minimal set.""" - required_columns = {"id", "slice_name", "viz_type", "uuid"} + required_columns = { + "id", + "slice_name", + "viz_type", + "url", + "changed_on_humanized", + } assert required_columns.issubset(set(CHART_DEFAULT_COLUMNS)) # These should NOT be in defaults assert "description" not in CHART_DEFAULT_COLUMNS assert "form_data" not in CHART_DEFAULT_COLUMNS + assert "uuid" not in CHART_DEFAULT_COLUMNS class TestGetSchemaToolViaClient: @@ -177,7 +198,13 @@ class TestGetSchemaToolViaClient: assert "search_columns" in info # Check default columns include required minimal set - required_columns = {"id", "slice_name", "viz_type", "uuid"} + required_columns = { + "id", + "slice_name", + "viz_type", + "url", + "changed_on_humanized", + } assert required_columns.issubset(set(info["default_select"])) @patch("superset.daos.dataset.DatasetDAO.get_filterable_columns_and_operators") @@ -201,7 +228,7 @@ class TestGetSchemaToolViaClient: assert info["model_type"] == "dataset" # Check default columns include required minimal set - required_columns = {"id", "table_name", "schema", "uuid"} + required_columns = {"id", "table_name", "schema", "changed_on_humanized"} assert required_columns.issubset(set(info["default_select"])) # Check search columns @@ -229,7 +256,13 @@ class TestGetSchemaToolViaClient: assert info["model_type"] == "dashboard" # Check default columns include required minimal set - required_columns = {"id", "dashboard_title", "slug", "uuid"} + required_columns = { + "id", + "dashboard_title", + "slug", + "url", + "changed_on_humanized", + } assert required_columns.issubset(set(info["default_select"])) # Check sortable columns include expected values From 56d6bb19136742d84b6382f479ed68885355469c Mon Sep 17 00:00:00 2001 From: Daniel Vaz Gaspar Date: Fri, 13 Mar 2026 09:00:07 +0000 Subject: [PATCH 28/58] feat(auth): add SAML login support to frontend (#38606) --- requirements/base.txt | 2 +- requirements/development.txt | 2 +- .../src/pages/Login/Login.test.tsx | 46 ++++-- superset-frontend/src/pages/Login/index.tsx | 4 +- superset/config.py | 1 + superset/views/base.py | 16 ++- tests/unit_tests/views/test_bootstrap_auth.py | 135 ++++++++++++++++++ 7 files changed, 192 insertions(+), 14 deletions(-) create mode 100644 tests/unit_tests/views/test_bootstrap_auth.py diff --git a/requirements/base.txt b/requirements/base.txt index c37afda03f8..240cf7d4eb1 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -120,7 +120,7 @@ flask==2.3.3 # flask-session # flask-sqlalchemy # flask-wtf -flask-appbuilder==5.0.2 +flask-appbuilder==5.1.0 # via # apache-superset (pyproject.toml) # apache-superset-core diff --git a/requirements/development.txt b/requirements/development.txt index e0a2b801182..993bbfdc3b4 100644 --- a/requirements/development.txt +++ b/requirements/development.txt @@ -262,7 +262,7 @@ flask==2.3.3 # flask-sqlalchemy # flask-testing # flask-wtf -flask-appbuilder==5.0.2 +flask-appbuilder==5.1.0 # via # -c requirements/base-constraint.txt # apache-superset diff --git a/superset-frontend/src/pages/Login/Login.test.tsx b/superset-frontend/src/pages/Login/Login.test.tsx index 6673dfdd99c..03e9ad72671 100644 --- a/superset-frontend/src/pages/Login/Login.test.tsx +++ b/superset-frontend/src/pages/Login/Login.test.tsx @@ -17,21 +17,31 @@ * under the License. */ import { render, screen } from 'spec/helpers/testing-library'; +import getBootstrapData from 'src/utils/getBootstrapData'; import Login from './index'; +const defaultBootstrapData = { + common: { + conf: { + AUTH_TYPE: 1, + AUTH_PROVIDERS: [], + AUTH_USER_REGISTRATION: false, + }, + feature_flags: {}, + }, +}; + jest.mock('src/utils/getBootstrapData', () => ({ __esModule: true, - default: () => ({ - common: { - conf: { - AUTH_TYPE: 1, - AUTH_PROVIDERS: [], - AUTH_USER_REGISTRATION: false, - }, - }, - }), + default: jest.fn(() => defaultBootstrapData), })); +const mockGetBootstrapData = getBootstrapData as jest.Mock; + +beforeEach(() => { + mockGetBootstrapData.mockReturnValue(defaultBootstrapData); +}); + test('should render login form elements', () => { render(, { useRedux: true }); expect(screen.getByTestId('login-form')).toBeInTheDocument(); @@ -53,3 +63,21 @@ test('should render form instruction text', () => { screen.getByText('Enter your login and password below:'), ).toBeInTheDocument(); }); + +test('should render SAML provider buttons', () => { + mockGetBootstrapData.mockReturnValue({ + common: { + conf: { + AUTH_TYPE: 5, + AUTH_PROVIDERS: [ + { name: 'okta', icon: 'okta' }, + { name: 'onelogin', icon: 'onelogin' }, + ], + AUTH_USER_REGISTRATION: false, + }, + }, + }); + render(, { useRedux: true }); + expect(screen.getByText('Sign in with Okta')).toBeInTheDocument(); + expect(screen.getByText('Sign in with Onelogin')).toBeInTheDocument(); +}); diff --git a/superset-frontend/src/pages/Login/index.tsx b/superset-frontend/src/pages/Login/index.tsx index 47cb3eba22d..e585bf08f54 100644 --- a/superset-frontend/src/pages/Login/index.tsx +++ b/superset-frontend/src/pages/Login/index.tsx @@ -57,6 +57,7 @@ enum AuthType { AuthDB = 1, AuthLDAP = 2, AuthOauth = 4, + AuthSAML = 5, } const StyledCard = styled(Card)` @@ -180,7 +181,8 @@ export default function Login() { )} - {authType === AuthType.AuthOauth && ( + {(authType === AuthType.AuthOauth || + authType === AuthType.AuthSAML) && (
{providers.map((provider: OAuthProvider) => ( diff --git a/superset/config.py b/superset/config.py index 2216c325b37..4e79aff9395 100644 --- a/superset/config.py +++ b/superset/config.py @@ -311,6 +311,7 @@ WTF_CSRF_EXEMPT_LIST = [ "superset.views.core.explore_json", "superset.views.core.log", "superset.views.datasource.views.samples", + "flask_appbuilder.security.views.acs", ] # Whether to run the web server in debug mode or not diff --git a/superset/views/base.py b/superset/views/base.py index 6e3f44c9aa7..4351ef01743 100644 --- a/superset/views/base.py +++ b/superset/views/base.py @@ -36,7 +36,7 @@ from flask import ( ) from flask_appbuilder import BaseView, Model, ModelView from flask_appbuilder.actions import action -from flask_appbuilder.const import AUTH_OAUTH +from flask_appbuilder.const import AUTH_OAUTH, AUTH_SAML from flask_appbuilder.forms import DynamicForm from flask_appbuilder.models.sqla.filters import BaseFilter from flask_appbuilder.security.sqla.models import User @@ -485,7 +485,9 @@ def cached_common_bootstrap_data( # pylint: disable=unused-argument auth_type = app.config["AUTH_TYPE"] auth_user_registration = app.config["AUTH_USER_REGISTRATION"] frontend_config["AUTH_USER_REGISTRATION"] = auth_user_registration - should_show_recaptcha = auth_user_registration and (auth_type != AUTH_OAUTH) + should_show_recaptcha = auth_user_registration and ( + auth_type not in (AUTH_OAUTH, AUTH_SAML) + ) if auth_user_registration: frontend_config["AUTH_USER_REGISTRATION_ROLE"] = app.config[ @@ -505,6 +507,16 @@ def cached_common_bootstrap_data( # pylint: disable=unused-argument } ) frontend_config["AUTH_PROVIDERS"] = oauth_providers + elif auth_type == AUTH_SAML: + saml_providers = [] + for provider in appbuilder.sm.saml_providers: + saml_providers.append( + { + "name": provider["name"], + "icon": provider.get("icon", "fa-sign-in"), + } + ) + frontend_config["AUTH_PROVIDERS"] = saml_providers bootstrap_data = { "application_root": app.config["APPLICATION_ROOT"], diff --git a/tests/unit_tests/views/test_bootstrap_auth.py b/tests/unit_tests/views/test_bootstrap_auth.py new file mode 100644 index 00000000000..5ea05af993d --- /dev/null +++ b/tests/unit_tests/views/test_bootstrap_auth.py @@ -0,0 +1,135 @@ +# 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 typing import Any +from unittest.mock import MagicMock, patch + +import pytest +from flask import g +from flask_appbuilder.const import ( + AUTH_DB, + AUTH_LDAP, + AUTH_OAUTH, + AUTH_SAML, +) + +from superset.views.base import cached_common_bootstrap_data + + +@pytest.fixture(autouse=True) +def mock_user() -> None: + g.user = MagicMock() + + +def _get_bootstrap(user_id: int = 1) -> dict[str, Any]: + with patch("superset.views.base.menu_data", return_value={}): + return cached_common_bootstrap_data(user_id=user_id, locale=None) + + +def test_bootstrap_saml_providers(app_context: None) -> None: + """SAML providers are included in bootstrap data.""" + from flask import current_app + + current_app.config["AUTH_TYPE"] = AUTH_SAML + current_app.config["AUTH_USER_REGISTRATION"] = False + current_app.config["SAML_PROVIDERS"] = [ + {"name": "okta", "icon": "fa-okta"}, + {"name": "entra_id", "icon": "fa-microsoft"}, + ] + + payload = _get_bootstrap() + + assert payload["conf"]["AUTH_TYPE"] == AUTH_SAML + providers = payload["conf"]["AUTH_PROVIDERS"] + assert len(providers) == 2 + assert providers[0] == {"name": "okta", "icon": "fa-okta"} + assert providers[1] == {"name": "entra_id", "icon": "fa-microsoft"} + + +def test_bootstrap_saml_provider_default_icon(app_context: None) -> None: + """SAML providers without an icon get a default icon.""" + from flask import current_app + + current_app.config["AUTH_TYPE"] = AUTH_SAML + current_app.config["AUTH_USER_REGISTRATION"] = False + current_app.config["SAML_PROVIDERS"] = [ + {"name": "onelogin"}, + ] + + payload = _get_bootstrap() + + providers = payload["conf"]["AUTH_PROVIDERS"] + assert providers[0] == {"name": "onelogin", "icon": "fa-sign-in"} + + +def test_bootstrap_oauth_providers(app_context: None) -> None: + """OAuth providers are included in bootstrap data.""" + from flask import current_app + + current_app.config["AUTH_TYPE"] = AUTH_OAUTH + current_app.config["AUTH_USER_REGISTRATION"] = False + current_app.config["OAUTH_PROVIDERS"] = [ + {"name": "github", "icon": "fa-github"}, + ] + + payload = _get_bootstrap() + + assert payload["conf"]["AUTH_TYPE"] == AUTH_OAUTH + providers = payload["conf"]["AUTH_PROVIDERS"] + assert len(providers) == 1 + assert providers[0] == {"name": "github", "icon": "fa-github"} + + +@pytest.mark.parametrize( + "auth_type", + [AUTH_OAUTH, AUTH_SAML], +) +def test_recaptcha_not_shown_for_federated_auth( + app_context: None, + auth_type: int, +) -> None: + """Recaptcha should not be shown for OAuth or SAML auth types.""" + from flask import current_app + + current_app.config["AUTH_TYPE"] = auth_type + current_app.config["AUTH_USER_REGISTRATION"] = True + current_app.config["AUTH_USER_REGISTRATION_ROLE"] = "Public" + current_app.config.pop("RECAPTCHA_PUBLIC_KEY", None) + + payload = _get_bootstrap() + + assert "RECAPTCHA_PUBLIC_KEY" not in payload["conf"] + + +@pytest.mark.parametrize( + "auth_type", + [AUTH_DB, AUTH_LDAP], +) +def test_recaptcha_shown_for_non_federated_auth( + app_context: None, + auth_type: int, +) -> None: + """Recaptcha should be shown for DB and LDAP auth types when registration is on.""" + from flask import current_app + + current_app.config["AUTH_TYPE"] = auth_type + current_app.config["AUTH_USER_REGISTRATION"] = True + current_app.config["AUTH_USER_REGISTRATION_ROLE"] = "Public" + current_app.config["RECAPTCHA_PUBLIC_KEY"] = "test-key" + + payload = _get_bootstrap() + + assert payload["conf"]["RECAPTCHA_PUBLIC_KEY"] == "test-key" From d91b96814e0bae5bbd908b26ba7a352bccfee24f Mon Sep 17 00:00:00 2001 From: Kamil Gabryjelski Date: Fri, 13 Mar 2026 11:16:50 +0100 Subject: [PATCH 29/58] fix(mcp): Improve validation errors and field aliases to reduce failed LLM tool calls (#38625) --- superset/mcp_service/app.py | 4 ++-- superset/mcp_service/sql_lab/schemas.py | 12 ++++++++--- .../mcp_service/sql_lab/tool/execute_sql.py | 13 ++++++++++++ .../sql_lab/tool/open_sql_lab_with_context.py | 6 +++--- superset/mcp_service/utils/schema_utils.py | 20 ++++++++++++++++++- .../mcp_service/utils/test_schema_utils.py | 16 +++++++++------ 6 files changed, 56 insertions(+), 15 deletions(-) diff --git a/superset/mcp_service/app.py b/superset/mcp_service/app.py index 43d4723fb94..409c7bb2e24 100644 --- a/superset/mcp_service/app.py +++ b/superset/mcp_service/app.py @@ -70,7 +70,7 @@ Chart Management: SQL Lab Integration: - execute_sql: Execute SQL queries and get results (requires database_id) -- open_sql_lab_with_context: Generate SQL Lab URL with pre-filled query +- open_sql_lab_with_context: Generate SQL Lab URL with pre-filled sql Schema Discovery: - get_schema: Get schema metadata for chart/dataset/dashboard (columns, filters) @@ -103,7 +103,7 @@ To find your own charts/dashboards: "opr": "eq", "value": current_user.id}}]) To explore data with SQL: -1. get_instance_info -> find database_id +1. list_datasets -> find a dataset and note its database_id 2. execute_sql(database_id, sql) -> run query 3. open_sql_lab_with_context(database_id) -> open SQL Lab UI diff --git a/superset/mcp_service/sql_lab/schemas.py b/superset/mcp_service/sql_lab/schemas.py index 571abd2a238..36f76980704 100644 --- a/superset/mcp_service/sql_lab/schemas.py +++ b/superset/mcp_service/sql_lab/schemas.py @@ -19,7 +19,7 @@ from typing import Any -from pydantic import BaseModel, Field, field_validator +from pydantic import AliasChoices, BaseModel, Field, field_validator class ExecuteSqlRequest(BaseModel): @@ -119,7 +119,9 @@ class OpenSqlLabRequest(BaseModel): """Request schema for opening SQL Lab with context.""" database_connection_id: int = Field( - ..., description="Database connection ID to use in SQL Lab" + ..., + description="Database connection ID to use in SQL Lab", + validation_alias=AliasChoices("database_connection_id", "database_id"), ) schema_name: str | None = Field( None, description="Default schema to select in SQL Lab", alias="schema" @@ -127,7 +129,11 @@ class OpenSqlLabRequest(BaseModel): dataset_in_context: str | None = Field( None, description="Dataset name/table to provide as context" ) - sql: str | None = Field(None, description="SQL query to pre-populate in the editor") + sql: str | None = Field( + None, + description="SQL to pre-populate in the editor", + validation_alias=AliasChoices("sql", "query"), + ) title: str | None = Field(None, description="Title for the SQL Lab tab/query") diff --git a/superset/mcp_service/sql_lab/tool/execute_sql.py b/superset/mcp_service/sql_lab/tool/execute_sql.py index 88fad7d4414..42739ff75fa 100644 --- a/superset/mcp_service/sql_lab/tool/execute_sql.py +++ b/superset/mcp_service/sql_lab/tool/execute_sql.py @@ -190,7 +190,20 @@ def _convert_to_response(result: QueryResult) -> ExecuteSqlResponse: if data_stmt is not None and data_stmt.data is not None: # SELECT query - convert DataFrame + import pandas as pd + df = data_stmt.data + if not isinstance(df, pd.DataFrame): + logger.error( + "Expected DataFrame but got %s for statement data", + type(df).__name__, + ) + return ExecuteSqlResponse( + success=False, + error=f"Internal error: unexpected data type ({type(df).__name__})", + error_type="data_conversion_error", + statements=statements, + ) rows = df.to_dict(orient="records") columns = [ColumnInfo(name=col, type=str(df[col].dtype)) for col in df.columns] row_count = len(df) diff --git a/superset/mcp_service/sql_lab/tool/open_sql_lab_with_context.py b/superset/mcp_service/sql_lab/tool/open_sql_lab_with_context.py index d9dc6bcd564..15a1919ffbf 100644 --- a/superset/mcp_service/sql_lab/tool/open_sql_lab_with_context.py +++ b/superset/mcp_service/sql_lab/tool/open_sql_lab_with_context.py @@ -18,7 +18,7 @@ """ Open SQL Lab with Context MCP Tool -Tool for generating SQL Lab URLs with pre-populated query and context. +Tool for generating SQL Lab URLs with pre-populated sql and context. """ import logging @@ -43,9 +43,9 @@ logger = logging.getLogger(__name__) def open_sql_lab_with_context( request: OpenSqlLabRequest, ctx: Context ) -> SqlLabResponse: - """Generate SQL Lab URL with pre-populated query and context. + """Generate SQL Lab URL with pre-populated sql and context. - Returns URL for direct navigation. + Pass the sql parameter to pre-fill the editor. Returns URL for direct navigation. """ try: from superset.daos.database import DatabaseDAO diff --git a/superset/mcp_service/utils/schema_utils.py b/superset/mcp_service/utils/schema_utils.py index 62992b8594d..f75e122b6ff 100644 --- a/superset/mcp_service/utils/schema_utils.py +++ b/superset/mcp_service/utils/schema_utils.py @@ -448,7 +448,25 @@ def parse_request( def _maybe_parse(request: Any) -> Any: if _is_parse_request_enabled(): - return parse_json_or_model(request, request_class, "request") + try: + return parse_json_or_model(request, request_class, "request") + except ValidationError as e: + from fastmcp.exceptions import ToolError + + details = [] + for err in e.errors(): + field = " -> ".join(str(loc) for loc in err["loc"]) + details.append(f"{field}: {err['msg']}") + required_fields = [ + f.alias or name + for name, f in request_class.model_fields.items() + if f.is_required() + ] + raise ToolError( + f"Invalid request parameters: {'; '.join(details)}. " + f"Required fields for {request_class.__name__}: " + f"{', '.join(required_fields)}" + ) from None return request if asyncio.iscoroutinefunction(func): diff --git a/tests/unit_tests/mcp_service/utils/test_schema_utils.py b/tests/unit_tests/mcp_service/utils/test_schema_utils.py index 95b4c2bbbe4..ab3450e6182 100644 --- a/tests/unit_tests/mcp_service/utils/test_schema_utils.py +++ b/tests/unit_tests/mcp_service/utils/test_schema_utils.py @@ -481,10 +481,12 @@ class TestParseRequestDecorator: result = sync_tool('{"name": "test", "count": 5}', extra="data") assert result == "test:5:data" - def test_decorator_raises_validation_error_async(self): - """Should raise ValidationError for invalid data in async function.""" + def test_decorator_raises_tool_error_for_invalid_data_async(self): + """Should raise ToolError with field details for invalid data.""" from unittest.mock import MagicMock, patch + from fastmcp.exceptions import ToolError + @parse_request(self.RequestModel) async def async_tool(request, ctx=None): return f"{request.name}:{request.count}" @@ -493,20 +495,22 @@ class TestParseRequestDecorator: mock_ctx = MagicMock() with patch("fastmcp.server.dependencies.get_context", return_value=mock_ctx): - with pytest.raises(ValidationError): + with pytest.raises(ToolError, match="Required fields for RequestModel"): asyncio.run(async_tool('{"name": "test"}')) # Missing count - def test_decorator_raises_validation_error_sync(self): - """Should raise ValidationError for invalid data in sync function.""" + def test_decorator_raises_tool_error_for_invalid_data_sync(self): + """Should raise ToolError with field details for invalid data.""" from unittest.mock import MagicMock, patch + from fastmcp.exceptions import ToolError + @parse_request(self.RequestModel) def sync_tool(request, ctx=None): return f"{request.name}:{request.count}" mock_ctx = MagicMock() with patch("fastmcp.server.dependencies.get_context", return_value=mock_ctx): - with pytest.raises(ValidationError): + with pytest.raises(ToolError, match="Required fields for RequestModel"): sync_tool('{"name": "test"}') # Missing count def test_decorator_with_complex_model_async(self): From 9516d1a306df58e3949bb2a85927ff1c20c7d163 Mon Sep 17 00:00:00 2001 From: Enzo Martellucci <52219496+EnxDev@users.noreply.github.com> Date: Fri, 13 Mar 2026 11:49:34 +0100 Subject: [PATCH 30/58] fix(explore/dashboard): fix CSV/Excel downloads for legacy chart types (#38513) Co-authored-by: Diego Pucci Co-authored-by: Claude Opus 4.6 Co-authored-by: codeant-ai-for-open-source[bot] <244253245+codeant-ai-for-open-source[bot]@users.noreply.github.com> --- .../useExploreAdditionalActionsMenu/index.tsx | 24 ++-- .../explore/exploreUtils/exportChart.test.ts | 104 +++++++++++++++++- .../exploreUtils/getExploreUrl.test.ts | 31 ++++++ .../getLegacyEndpointType.test.ts | 6 + .../src/explore/exploreUtils/index.ts | 32 ++++-- superset/views/core.py | 44 ++++++-- 6 files changed, 211 insertions(+), 30 deletions(-) diff --git a/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.tsx b/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.tsx index cb728be3c7b..94eeec9a90b 100644 --- a/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.tsx +++ b/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.tsx @@ -223,15 +223,14 @@ export const useExploreAdditionalActionsMenu = ( state.common?.conf?.CSV_STREAMING_ROW_THRESHOLD || DEFAULT_CSV_STREAMING_ROW_THRESHOLD, ); - const exploreChartState = useSelector< - ExploreState, - JsonObject | undefined - >(state => { - const chartKey = state.explore ? getChartKey(state.explore) : undefined; - return chartKey != null - ? state.explore?.chartStates?.[chartKey] - : undefined; - }); + const exploreChartState = useSelector( + state => { + const chartKey = state.explore ? getChartKey(state.explore) : undefined; + return chartKey != null + ? state.explore?.chartStates?.[chartKey] + : undefined; + }, + ); // Streaming export state and handlers const [isStreamingModalVisible, setIsStreamingModalVisible] = useState(false); @@ -439,7 +438,12 @@ export const useExploreAdditionalActionsMenu = ( } catch (error) { addDangerToast(t('Sorry, something went wrong. Try again later.')); } - }, [addDangerToast, addSuccessToast, latestQueryFormData, permalinkChartState]); + }, [ + addDangerToast, + addSuccessToast, + latestQueryFormData, + permalinkChartState, + ]); // Minimal client-side CSV builder used for "Current View" when pagination is disabled const downloadClientCSV = ( diff --git a/superset-frontend/src/explore/exploreUtils/exportChart.test.ts b/superset-frontend/src/explore/exploreUtils/exportChart.test.ts index 661e1248b3e..74c905f347a 100644 --- a/superset-frontend/src/explore/exploreUtils/exportChart.test.ts +++ b/superset-frontend/src/explore/exploreUtils/exportChart.test.ts @@ -144,11 +144,9 @@ test('exportChart passes xlsx exportType for Excel exports', async () => { }); test('exportChart legacy API (useLegacyApi=true) passes prefixed URL with app root configured', async () => { - // Legacy API uses getExploreUrl() -> getURIDirectory() -> ensureAppRoot() const appRoot = '/superset'; ensureAppRoot.mockImplementation((path: string) => `${appRoot}${path}`); - // Configure mock to return useLegacyApi: true getChartMetadataRegistry.mockReturnValue({ get: jest.fn().mockReturnValue({ useLegacyApi: true, parseMethod: 'json' }), }); @@ -167,7 +165,105 @@ test('exportChart legacy API (useLegacyApi=true) passes prefixed URL with app ro expect(onStartStreamingExport).toHaveBeenCalledTimes(1); const callArgs = onStartStreamingExport.mock.calls[0][0]; - // Legacy path uses getURIDirectory which calls ensureAppRoot - expect(callArgs.url).toContain(appRoot); + expect(callArgs.url).toBe('/superset/superset/explore_json/?csv=true'); expect(callArgs.exportType).toBe('csv'); }); + +test('exportChart legacy API builds relative URL for CSV export without app root', async () => { + ensureAppRoot.mockImplementation((path: string) => path); + + getChartMetadataRegistry.mockReturnValue({ + get: jest.fn().mockReturnValue({ useLegacyApi: true, parseMethod: 'json' }), + }); + + const onStartStreamingExport = jest.fn(); + const legacyFormData = { + datasource: '1__table', + viz_type: 'world_map', + }; + + await exportChart({ + formData: legacyFormData, + resultFormat: 'csv', + onStartStreamingExport: onStartStreamingExport as unknown as null, + }); + + expect(onStartStreamingExport).toHaveBeenCalledTimes(1); + const callArgs = onStartStreamingExport.mock.calls[0][0]; + expect(callArgs.url).toBe('/superset/explore_json/?csv=true'); +}); + +test('exportChart legacy API builds relative URL for xlsx export', async () => { + ensureAppRoot.mockImplementation((path: string) => path); + + getChartMetadataRegistry.mockReturnValue({ + get: jest.fn().mockReturnValue({ useLegacyApi: true, parseMethod: 'json' }), + }); + + const onStartStreamingExport = jest.fn(); + const legacyFormData = { + datasource: '1__table', + viz_type: 'bubble', + }; + + await exportChart({ + formData: legacyFormData, + resultFormat: 'xlsx', + resultType: 'results', + onStartStreamingExport: onStartStreamingExport as unknown as null, + }); + + expect(onStartStreamingExport).toHaveBeenCalledTimes(1); + const callArgs = onStartStreamingExport.mock.calls[0][0]; + expect(callArgs.url).toBe('/superset/explore_json/?xlsx=true'); +}); + +test('exportChart legacy API calls postForm with relative URL', async () => { + const { SupersetClient } = jest.requireMock('@superset-ui/core'); + ensureAppRoot.mockImplementation((path: string) => path); + + getChartMetadataRegistry.mockReturnValue({ + get: jest.fn().mockReturnValue({ useLegacyApi: true, parseMethod: 'json' }), + }); + + const legacyFormData = { + datasource: '1__table', + viz_type: 'world_map', + }; + + await exportChart({ + formData: legacyFormData, + resultFormat: 'csv', + resultType: 'full', + }); + + expect(SupersetClient.postForm).toHaveBeenCalledTimes(1); + const [url] = SupersetClient.postForm.mock.calls[0]; + expect(url).toBe('/superset/explore_json/?csv=true'); + expect(url).not.toMatch(/^https?:\/\//); +}); + +test('exportChart legacy API includes force param when force=true', async () => { + ensureAppRoot.mockImplementation((path: string) => path); + + getChartMetadataRegistry.mockReturnValue({ + get: jest.fn().mockReturnValue({ useLegacyApi: true, parseMethod: 'json' }), + }); + + const onStartStreamingExport = jest.fn(); + const legacyFormData = { + datasource: '1__table', + viz_type: 'world_map', + }; + + await exportChart({ + formData: legacyFormData, + resultFormat: 'csv', + force: true, + onStartStreamingExport: onStartStreamingExport as unknown as null, + }); + + expect(onStartStreamingExport).toHaveBeenCalledTimes(1); + const callArgs = onStartStreamingExport.mock.calls[0][0]; + expect(callArgs.url).toBe('/superset/explore_json/?force=true&csv=true'); +}); diff --git a/superset-frontend/src/explore/exploreUtils/getExploreUrl.test.ts b/superset-frontend/src/explore/exploreUtils/getExploreUrl.test.ts index d1409b99c16..032133545e8 100644 --- a/superset-frontend/src/explore/exploreUtils/getExploreUrl.test.ts +++ b/superset-frontend/src/explore/exploreUtils/getExploreUrl.test.ts @@ -49,3 +49,34 @@ test('Get ExploreUrl with endpointType:full and method:GET', () => { getExploreUrl({ ...params, endpointType: 'full', method: 'GET' }), ).toBe('http://localhost/superset/explore_json/'); }); + +test('Get relative ExploreUrl with endpointType:csv', () => { + const params = createParams(); + expect( + getExploreUrl({ ...params, endpointType: 'csv', relative: true }), + ).toBe('/superset/explore_json/?csv=true'); +}); + +test('Get relative ExploreUrl with endpointType:xlsx', () => { + const params = createParams(); + expect( + getExploreUrl({ ...params, endpointType: 'xlsx', relative: true }), + ).toBe('/superset/explore_json/?xlsx=true'); +}); + +test('Get relative ExploreUrl with force:true', () => { + const params = createParams(); + expect( + getExploreUrl({ + ...params, + endpointType: 'csv', + force: true, + relative: true, + }), + ).toBe('/superset/explore_json/?force=true&csv=true'); +}); + +test('Get relative ExploreUrl with endpointType:base', () => { + const params = createParams(); + expect(getExploreUrl({ ...params, relative: true })).toBe('/explore/'); +}); diff --git a/superset-frontend/src/explore/exploreUtils/getLegacyEndpointType.test.ts b/superset-frontend/src/explore/exploreUtils/getLegacyEndpointType.test.ts index 31655fec5cf..eca145a680d 100644 --- a/superset-frontend/src/explore/exploreUtils/getLegacyEndpointType.test.ts +++ b/superset-frontend/src/explore/exploreUtils/getLegacyEndpointType.test.ts @@ -32,3 +32,9 @@ test('Should return resultFormat when resultFormat:csv', () => { getLegacyEndpointType({ ...createParams(), resultFormat: 'csv' }), ).toBe('csv'); }); + +test('Should return resultFormat when resultFormat:xlsx', () => { + expect( + getLegacyEndpointType({ ...createParams(), resultFormat: 'xlsx' }), + ).toBe('xlsx'); +}); diff --git a/superset-frontend/src/explore/exploreUtils/index.ts b/superset-frontend/src/explore/exploreUtils/index.ts index 1d4c0e3758b..d67e5f8474b 100644 --- a/superset-frontend/src/explore/exploreUtils/index.ts +++ b/superset-frontend/src/explore/exploreUtils/index.ts @@ -75,6 +75,7 @@ interface GetExploreUrlParams { requestParams?: Record; allowDomainSharding?: boolean; method?: 'GET' | 'POST'; + relative?: boolean; } interface BuildV1ChartDataPayloadParams { @@ -153,9 +154,15 @@ export function getURIDirectory( includeAppRoot = true, ): string { // Building the directory part of the URI - const uri = ['full', 'json', 'csv', 'query', 'results', 'samples'].includes( - endpointType, - ) + const uri = [ + 'full', + 'json', + 'csv', + 'xlsx', + 'query', + 'results', + 'samples', + ].includes(endpointType) ? '/superset/explore_json/' : '/explore/'; return includeAppRoot ? ensureAppRoot(uri) : uri; @@ -215,6 +222,7 @@ export function getExploreUrl({ requestParams = {}, allowDomainSharding = false, method = 'POST', + relative = false, }: GetExploreUrlParams): string | null { if (!formData.datasource) { return null; @@ -224,10 +232,12 @@ export function getExploreUrl({ // eslint-disable-next-line no-param-reassign delete formData.label_colors; - let uri = getChartDataUri({ - path: '/', - allowDomainSharding, - }); + let uri = relative + ? new URI('/') + : getChartDataUri({ + path: '/', + allowDomainSharding, + }); if (curUrl) { uri = URI(URI(curUrl).search()); } @@ -256,6 +266,9 @@ export function getExploreUrl({ if (endpointType === 'csv') { search.csv = 'true'; } + if (endpointType === 'xlsx') { + search.xlsx = 'true'; + } if (endpointType === URL_PARAMS.standalone.name) { search.standalone = '1'; } @@ -335,7 +348,8 @@ export const getLegacyEndpointType = ({ }: { resultType: string; resultFormat: string; -}): string => (resultFormat === 'csv' ? resultFormat : resultType); +}): string => + resultFormat === 'csv' || resultFormat === 'xlsx' ? resultFormat : resultType; export const exportChart = async ({ formData, @@ -353,7 +367,9 @@ export const exportChart = async ({ url = getExploreUrl({ formData, endpointType, + force, allowDomainSharding: false, + relative: true, }); payload = formData; } else { diff --git a/superset/views/core.py b/superset/views/core.py index 19e8f3cf1e2..9171fbf740f 100755 --- a/superset/views/core.py +++ b/superset/views/core.py @@ -86,6 +86,7 @@ from superset.utils import core as utils, json from superset.utils.cache import etag_cache from superset.utils.core import ( DatasourceType, + GenericDataType, get_user_id, ReservedUrlParameters, ) @@ -99,6 +100,7 @@ from superset.views.base import ( generate_download_headers, json_error_response, json_success, + XlsxResponse, ) from superset.views.error_handling import handle_api_exception from superset.views.utils import ( @@ -194,6 +196,9 @@ class Superset(BaseSupersetView): viz_obj.get_csv(), headers=generate_download_headers("csv") ) + if response_type == ChartDataResultFormat.XLSX: + return self._generate_xlsx(viz_obj) + if response_type == ChartDataResultType.QUERY: return self.get_query_string_response(viz_obj) @@ -206,6 +211,24 @@ class Superset(BaseSupersetView): payload = viz_obj.get_payload() return self.send_data_payload_response(viz_obj, payload) + @staticmethod + def _generate_xlsx(viz_obj: BaseViz) -> FlaskResponse: + import pandas as pd + + from superset.utils.excel import apply_column_types, df_to_excel + + payload = viz_obj.get_df_payload() + df = payload.get("df") + if df is None: + df = pd.DataFrame() + coltypes: list[GenericDataType] = [] + else: + coltypes = payload.get("coltypes") or [] + if coltypes: + df = apply_column_types(df, coltypes) + xlsx_data = df_to_excel(df, index=False) + return XlsxResponse(xlsx_data, headers=generate_download_headers("xlsx")) + @event_logger.log_this @api @has_access_api @@ -290,15 +313,20 @@ class Superset(BaseSupersetView): response_type = response_option break - # Verify user has permission to export CSV file - if ( - response_type == ChartDataResultFormat.CSV - and not security_manager.can_access("can_csv", "Superset") + # Verify user has permission to export data files + if response_type in ( + ChartDataResultFormat.CSV, + ChartDataResultFormat.XLSX, ): - return json_error_response( - _("You don't have the rights to download as csv"), - status=403, - ) + if is_feature_enabled("GRANULAR_EXPORT_CONTROLS"): + can_export = security_manager.can_access("can_export_data", "Superset") + else: + can_export = security_manager.can_access("can_csv", "Superset") + if not can_export: + return json_error_response( + _("You don't have the rights to export data"), + status=403, + ) form_data = get_form_data()[0] try: From 32a64d02c7435cd1e5210a24541f79d298e38981 Mon Sep 17 00:00:00 2001 From: Enzo Martellucci <52219496+EnxDev@users.noreply.github.com> Date: Fri, 13 Mar 2026 11:54:05 +0100 Subject: [PATCH 31/58] fix(deckgl): polygon chart not rendering when boundary column contains nested geometry JSON (#38595) --- .../src/layers/Polygon/transformProps.test.ts | 120 ++++++++++++++++++ .../src/layers/Polygon/transformProps.ts | 4 + 2 files changed, 124 insertions(+) diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Polygon/transformProps.test.ts b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Polygon/transformProps.test.ts index a7b7be3ab9a..4069a474eff 100644 --- a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Polygon/transformProps.test.ts +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Polygon/transformProps.test.ts @@ -289,6 +289,126 @@ describe('Polygon transformProps', () => { expect(features[0]?.elevation).toBeUndefined(); }); + test('should render polygons when boundary column contains GeoJSON Feature format', () => { + const geojsonFeatureProps = { + ...mockChartProps, + queriesData: [ + { + data: [ + { + geom: JSON.stringify({ + type: 'Feature', + geometry: { + type: 'Polygon', + coordinates: [ + [ + [-122.4, 37.8], + [-122.3, 37.8], + [-122.3, 37.9], + [-122.4, 37.9], + [-122.4, 37.8], + ], + ], + }, + properties: { name: 'test' }, + }), + }, + ], + }, + ], + }; + + const result = transformProps(geojsonFeatureProps as ChartProps); + + const features = result.payload.data.features as PolygonFeature[]; + expect(features).toHaveLength(1); + expect(features[0]?.polygon).toEqual([ + [-122.4, 37.8], + [-122.3, 37.8], + [-122.3, 37.9], + [-122.4, 37.9], + [-122.4, 37.8], + ]); + }); + + test('should render polygons when boundary column contains GeoJSON Geometry format', () => { + const geojsonGeometryProps = { + ...mockChartProps, + queriesData: [ + { + data: [ + { + geom: JSON.stringify({ + type: 'Polygon', + coordinates: [ + [ + [-122.4, 37.8], + [-122.3, 37.8], + [-122.3, 37.9], + [-122.4, 37.9], + [-122.4, 37.8], + ], + ], + }), + }, + ], + }, + ], + }; + + const result = transformProps(geojsonGeometryProps as ChartProps); + + const features = result.payload.data.features as PolygonFeature[]; + expect(features).toHaveLength(1); + expect(features[0]?.polygon).toEqual([ + [-122.4, 37.8], + [-122.3, 37.8], + [-122.3, 37.9], + [-122.4, 37.9], + [-122.4, 37.8], + ]); + }); + + test('should render polygons when boundary column contains JSON with nested geometry', () => { + // Real-world format: {"type":"Polygon","geometry":{"type":"Polygon","coordinates":[...]}} + const nonStandardProps = { + ...mockChartProps, + queriesData: [ + { + data: [ + { + geom: JSON.stringify({ + type: 'Polygon', + geometry: { + type: 'Polygon', + coordinates: [ + [ + [79.7912, 8.4641], + [79.7959, 8.4629], + [79.7994, 8.4535], + [79.7912, 8.4641], + ], + ], + }, + }), + }, + ], + }, + ], + }; + + const result = transformProps(nonStandardProps as ChartProps); + + const features = result.payload.data.features as PolygonFeature[]; + expect(features).toHaveLength(1); + expect(features[0]?.polygon).toEqual([ + [79.7912, 8.4641], + [79.7959, 8.4629], + [79.7994, 8.4535], + [79.7912, 8.4641], + ]); + }); + test('should handle geohash decoding successfully', () => { const props = { ...mockedChartPropsWithGeoHash, diff --git a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Polygon/transformProps.ts b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Polygon/transformProps.ts index bb070d92e36..5a9f71b2a53 100644 --- a/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Polygon/transformProps.ts +++ b/superset-frontend/plugins/legacy-preset-chart-deckgl/src/layers/Polygon/transformProps.ts @@ -115,6 +115,10 @@ function processPolygonData( if (parsed.coordinates) { polygonCoords = parsed.coordinates[0] || parsed.coordinates; + } else if (parsed.geometry?.coordinates) { + // Non-standard format with nested geometry + polygonCoords = + parsed.geometry.coordinates[0] || parsed.geometry.coordinates; } else if (Array.isArray(parsed)) { polygonCoords = parsed; } else { From af5e05db2e4cdbc3864a04fe432d221898301c71 Mon Sep 17 00:00:00 2001 From: Kamil Gabryjelski Date: Fri, 13 Mar 2026 11:58:12 +0100 Subject: [PATCH 32/58] fix(mcp): Support form_data_key without chart identifier for unsaved charts (#38628) --- superset/mcp_service/chart/schemas.py | 86 +++++- .../mcp_service/chart/tool/get_chart_data.py | 247 +++++++++++++++--- .../mcp_service/chart/tool/get_chart_info.py | 111 ++++++-- .../chart/tool/get_chart_preview.py | 112 +++++++- 4 files changed, 481 insertions(+), 75 deletions(-) diff --git a/superset/mcp_service/chart/schemas.py b/superset/mcp_service/chart/schemas.py index c5a2098442f..ea5459cfbc4 100644 --- a/superset/mcp_service/chart/schemas.py +++ b/superset/mcp_service/chart/schemas.py @@ -112,6 +112,14 @@ class ChartInfo(BaseModel): owners: List[UserInfo] = Field(default_factory=list, description="Chart owners") # Fields for unsaved state support + form_data: Dict[str, Any] | None = Field( + None, + description=( + "The chart's form_data configuration. When form_data_key is provided, " + "this contains the unsaved (cached) configuration rather than the " + "saved version." + ), + ) form_data_key: str | None = Field( None, description=( @@ -219,27 +227,45 @@ class VersionedResponse(BaseModel): class GetChartInfoRequest(BaseModel): - """Request schema for get_chart_info with support for ID or UUID. + """Request schema for get_chart_info with support for ID, UUID, or form_data_key. When form_data_key is provided, the tool will retrieve the unsaved chart state from cache, allowing you to explain what the user actually sees (not the saved version). This is useful when a user edits a chart in Explore but hasn't saved yet. + + For unsaved charts (no chart ID), provide only form_data_key to retrieve the + current chart configuration from cache. """ identifier: Annotated[ - int | str, - Field(description="Chart identifier - can be numeric ID or UUID string"), + int | str | None, + Field( + default=None, + description=( + "Chart identifier - can be numeric ID or UUID string. " + "Optional when form_data_key is provided (for unsaved charts)." + ), + ), ] form_data_key: str | None = Field( default=None, description=( - "Optional cache key for retrieving unsaved chart state. When a user " + "Cache key for retrieving unsaved chart state. When a user " "edits a chart in Explore but hasn't saved, the current state is stored " "with this key. If provided, the tool returns the current unsaved " - "configuration instead of the saved version." + "configuration instead of the saved version. " + "Can be used alone (without identifier) for unsaved charts." ), ) + @model_validator(mode="after") + def validate_identifier_or_form_data_key(self) -> "GetChartInfoRequest": + if not self.identifier and not self.form_data_key: + raise ValueError( + "At least one of 'identifier' or 'form_data_key' must be provided." + ) + return self + def serialize_chart_object(chart: ChartLike | None) -> ChartInfo | None: if not chart: @@ -1020,18 +1046,37 @@ class GetChartDataRequest(QueryCacheControl): from cache to query data, allowing you to get data for what the user actually sees (not the saved version). This is useful when a user edits a chart in Explore but hasn't saved yet. + + For unsaved charts (no chart ID), provide only form_data_key to query data using + the current chart configuration from cache. """ - identifier: int | str = Field(description="Chart identifier (ID, UUID)") + identifier: int | str | None = Field( + default=None, + description=( + "Chart identifier (ID, UUID). " + "Optional when form_data_key is provided (for unsaved charts)." + ), + ) form_data_key: str | None = Field( default=None, description=( - "Optional cache key for retrieving unsaved chart state. When a user " + "Cache key for retrieving unsaved chart state. When a user " "edits a chart in Explore but hasn't saved, the current state is stored " "with this key. If provided, the tool uses this configuration to query " - "data instead of the saved chart configuration." + "data instead of the saved chart configuration. " + "Can be used alone (without identifier) for unsaved charts." ), ) + + @model_validator(mode="after") + def validate_identifier_or_form_data_key(self) -> "GetChartDataRequest": + if not self.identifier and not self.form_data_key: + raise ValueError( + "At least one of 'identifier' or 'form_data_key' must be provided." + ) + return self + limit: int | None = Field( default=None, description=( @@ -1113,18 +1158,37 @@ class GetChartPreviewRequest(QueryCacheControl): chart configuration from cache, allowing you to preview what the user actually sees (not the saved version). This is useful when a user edits a chart in Explore but hasn't saved yet. + + For unsaved charts (no chart ID), provide only form_data_key to render a preview + using the current chart configuration from cache. """ - identifier: int | str = Field(description="Chart identifier (ID, UUID)") + identifier: int | str | None = Field( + default=None, + description=( + "Chart identifier (ID, UUID). " + "Optional when form_data_key is provided (for unsaved charts)." + ), + ) form_data_key: str | None = Field( default=None, description=( - "Optional cache key for retrieving unsaved chart state. When a user " + "Cache key for retrieving unsaved chart state. When a user " "edits a chart in Explore but hasn't saved, the current state is stored " "with this key. If provided, the tool renders a preview using this " - "configuration instead of the saved chart configuration." + "configuration instead of the saved chart configuration. " + "Can be used alone (without identifier) for unsaved charts." ), ) + + @model_validator(mode="after") + def validate_identifier_or_form_data_key(self) -> "GetChartPreviewRequest": + if not self.identifier and not self.form_data_key: + raise ValueError( + "At least one of 'identifier' or 'form_data_key' must be provided." + ) + return self + format: Literal["url", "ascii", "table", "vega_lite"] = Field( default="url", description=( diff --git a/superset/mcp_service/chart/tool/get_chart_data.py b/superset/mcp_service/chart/tool/get_chart_data.py index 96e7b1845ad..ed7a4e20c33 100644 --- a/superset/mcp_service/chart/tool/get_chart_data.py +++ b/superset/mcp_service/chart/tool/get_chart_data.py @@ -109,9 +109,42 @@ async def get_chart_data( # noqa: C901 from superset.daos.chart import ChartDAO from superset.utils import json as utils_json - # Find the chart + chart = None + + # Handle unsaved chart (form_data_key only, no identifier) + if not request.identifier and request.form_data_key: + with event_logger.log_context( + action="mcp.get_chart_data.unsaved_chart_from_cache" + ): + await ctx.info( + "No chart identifier - querying data from unsaved chart cache: " + "form_data_key=%s" % (request.form_data_key,) + ) + cached_form_data = _get_cached_form_data(request.form_data_key) + if not cached_form_data: + return ChartError( + error="No cached chart data found for form_data_key. " + "The cache may have expired.", + error_type="NotFound", + ) + try: + cached_form_data_dict = utils_json.loads(cached_form_data) + except (TypeError, ValueError) as e: + return ChartError( + error=f"Failed to parse cached form_data: {e}", + error_type="ParseError", + ) + if not isinstance(cached_form_data_dict, dict): + return ChartError( + error="Cached form_data is not a valid JSON object.", + error_type="ParseError", + ) + + # Build query context entirely from cached form_data + return await _query_from_form_data(cached_form_data_dict, request, ctx) + + # Find the chart by identifier with event_logger.log_context(action="mcp.get_chart_data.chart_lookup"): - chart = None if isinstance(request.identifier, int) or ( isinstance(request.identifier, str) and request.identifier.isdigit() ): @@ -124,7 +157,7 @@ async def get_chart_data( # noqa: C901 "Performing ID-based chart lookup: chart_id=%s" % (chart_id,) ) chart = ChartDAO.find_by_id(chart_id) - else: + elif isinstance(request.identifier, str): await ctx.debug( "Performing UUID-based chart lookup: uuid=%s" % (request.identifier,) @@ -178,36 +211,40 @@ async def get_chart_data( # noqa: C901 # Check if form_data_key is provided - use cached form_data instead if request.form_data_key: - await ctx.info( - "Retrieving unsaved chart state from cache: form_data_key=%s" - % (request.form_data_key,) - ) - if cached_form_data := _get_cached_form_data(request.form_data_key): - try: - parsed_form_data = utils_json.loads(cached_form_data) - # Only use if it's actually a dict (not null, list, etc.) - if isinstance(parsed_form_data, dict): - cached_form_data_dict = parsed_form_data - using_unsaved_state = True - await ctx.info( - "Using cached form_data from form_data_key " - "for data query" - ) - else: - await ctx.warning( - "Cached form_data is not a JSON object. " - "Falling back to saved chart configuration." - ) - except (TypeError, ValueError) as e: - await ctx.warning( - "Failed to parse cached form_data: %s. " - "Falling back to saved chart configuration." % str(e) - ) - else: - await ctx.warning( - "form_data_key provided but no cached data found. " - "The cache may have expired. Using saved chart configuration." + with event_logger.log_context( + action="mcp.get_chart_data.unsaved_state_override" + ): + await ctx.info( + "Retrieving unsaved chart state from cache: form_data_key=%s" + % (request.form_data_key,) ) + if cached_form_data := _get_cached_form_data(request.form_data_key): + try: + parsed_form_data = utils_json.loads(cached_form_data) + # Only use if it's actually a dict (not null, list, etc.) + if isinstance(parsed_form_data, dict): + cached_form_data_dict = parsed_form_data + using_unsaved_state = True + await ctx.info( + "Using cached form_data from form_data_key " + "for data query" + ) + else: + await ctx.warning( + "Cached form_data is not a JSON object. " + "Falling back to saved chart configuration." + ) + except (TypeError, ValueError) as e: + await ctx.warning( + "Failed to parse cached form_data: %s. " + "Falling back to saved chart configuration." % str(e) + ) + else: + await ctx.warning( + "form_data_key provided but no cached data found. " + "The cache may have expired. Using saved chart " + "configuration." + ) # Use the chart's saved query_context - this is the key! # The query_context contains all the information needed to reproduce @@ -707,6 +744,152 @@ async def get_chart_data( # noqa: C901 ) +async def _query_from_form_data( + form_data: Dict[str, Any], + request: GetChartDataRequest, + ctx: Context, +) -> ChartData | ChartError: + """Query chart data using only cached form_data (no saved chart). + + Used for unsaved charts where we only have form_data_key. + """ + from superset.commands.chart.data.get_data_command import ChartDataCommand + from superset.common.query_context_factory import QueryContextFactory + + datasource_id = form_data.get("datasource_id") + datasource_type: str = form_data.get("datasource_type") or "table" + + # Handle combined datasource field (e.g., "1__table") + if not datasource_id and form_data.get("datasource"): + parts = str(form_data["datasource"]).split("__") + if len(parts) == 2: + datasource_id, datasource_type = parts[0], parts[1] + + if not datasource_id: + return ChartError( + error="Cached form_data does not contain datasource information.", + error_type="InvalidFormData", + ) + + viz_type = form_data.get("viz_type", "unknown") + row_limit = ( + request.limit or form_data.get("row_limit") or current_app.config["ROW_LIMIT"] + ) + + # Extract metrics and groupby based on chart type + if viz_type in ("big_number", "big_number_total", "pop_kpi"): + metric = form_data.get("metric") + metrics = [metric] if metric else [] + groupby: list[str] = [] + else: + metrics = form_data.get("metrics", []) + groupby = list(form_data.get("groupby") or []) + + try: + factory = QueryContextFactory() + query_context = factory.create( + datasource={"id": datasource_id, "type": datasource_type}, + queries=[ + { + "filters": form_data.get("filters", []), + "columns": groupby, + "metrics": metrics, + "row_limit": row_limit, + "order_desc": form_data.get("order_desc", True), + } + ], + form_data=form_data, + force=request.force_refresh, + ) + + await ctx.report_progress(3, 4, "Executing data query") + with event_logger.log_context(action="mcp.get_chart_data.query_execution"): + command = ChartDataCommand(query_context) + command.validate() + result = command.run() + + if not result or "queries" not in result or len(result["queries"]) == 0: + return ChartError( + error="No query results returned for unsaved chart.", + error_type="EmptyQuery", + ) + + query_result = result["queries"][0] + data = query_result.get("data", []) + raw_columns = query_result.get("colnames", []) + + if not data: + return ChartError( + error="No data available for unsaved chart.", + error_type="NoData", + ) + + columns = [] + for col_name in raw_columns: + sample_values = [ + row.get(col_name) for row in data[:3] if row.get(col_name) is not None + ] + data_type = "string" + if sample_values and all( + isinstance(v, (int, float)) for v in sample_values + ): + data_type = "numeric" + columns.append( + DataColumn( + name=col_name, + display_name=col_name.replace("_", " ").title(), + data_type=data_type, + sample_values=sample_values[:3], + null_count=sum(1 for row in data if row.get(col_name) is None), + unique_count=len({str(row.get(col_name)) for row in data}), + ) + ) + + cache_status = get_cache_status_from_result( + query_result, force_refresh=request.force_refresh + ) + + chart_name = form_data.get("slice_name", "Unsaved chart") + summary = ( + f"Unsaved chart ({viz_type}). " + f"Contains {len(data)} rows across {len(raw_columns)} columns." + ) + + await ctx.report_progress(4, 4, "Building response") + return ChartData( + chart_id=0, + chart_name=chart_name, + chart_type=viz_type, + columns=columns, + data=data[: request.limit] if request.limit else data, + row_count=len(data), + total_rows=query_result.get("rowcount"), + summary=summary, + insights=["This is an unsaved chart queried from cached form_data."], + data_quality={ + "completeness": 1.0 + - ( + sum(col.null_count for col in columns) + / max(len(data) * len(columns), 1) + ) + }, + recommended_visualizations=[], + data_freshness=None, + performance=PerformanceMetadata( + query_duration_ms=0, + cache_status="fresh_query", + ), + cache_status=cache_status, + ) + + except (CommandException, SupersetException, ValueError) as e: + logger.error("Error querying unsaved chart data: %s", e) + return ChartError( + error=f"Error querying unsaved chart data: {e}", + error_type="DataError", + ) + + def _export_data_as_csv( chart: "Slice", data: List[Dict[str, Any]], diff --git a/superset/mcp_service/chart/tool/get_chart_info.py b/superset/mcp_service/chart/tool/get_chart_info.py index 6c60214f705..1061ec821c0 100644 --- a/superset/mcp_service/chart/tool/get_chart_info.py +++ b/superset/mcp_service/chart/tool/get_chart_info.py @@ -56,6 +56,65 @@ def _get_cached_form_data(form_data_key: str) -> str | None: return None +def _build_unsaved_chart_info(form_data_key: str) -> ChartInfo | ChartError: + """Build a ChartInfo from cached form_data when no chart identifier exists.""" + from superset.utils import json as utils_json + + cached_form_data = _get_cached_form_data(form_data_key) + if not cached_form_data: + return ChartError( + error="No cached chart data found for form_data_key. " + "The cache may have expired.", + error_type="NotFound", + ) + try: + form_data = utils_json.loads(cached_form_data) + except (TypeError, ValueError) as e: + return ChartError( + error=f"Failed to parse cached form_data: {e}", + error_type="ParseError", + ) + if not isinstance(form_data, dict): + return ChartError( + error="Cached form_data is not a valid JSON object.", + error_type="ParseError", + ) + return ChartInfo( + viz_type=form_data.get("viz_type"), + datasource_name=form_data.get("datasource_name"), + datasource_type=form_data.get("datasource_type"), + form_data=form_data, + form_data_key=form_data_key, + is_unsaved_state=True, + ) + + +def _apply_unsaved_state_override(result: ChartInfo, form_data_key: str) -> None: + """Override a ChartInfo's form_data with cached unsaved state.""" + from superset.utils import json as utils_json + + if cached_form_data := _get_cached_form_data(form_data_key): + try: + result.form_data = utils_json.loads(cached_form_data) + result.form_data_key = form_data_key + result.is_unsaved_state = True + + # Update viz_type from cached form_data if present + if result.form_data and "viz_type" in result.form_data: + result.viz_type = result.form_data["viz_type"] + except (TypeError, ValueError) as e: + logger.warning( + "Failed to parse cached form_data: %s. " + "Using saved chart configuration.", + e, + ) + else: + logger.warning( + "form_data_key provided but no cached data found. " + "The cache may have expired. Using saved chart configuration." + ) + + @tool(tags=["discovery"], class_permission_name="Chart") @parse_request(GetChartInfoRequest) async def get_chart_info( @@ -96,13 +155,28 @@ async def get_chart_info( """ from superset.daos.chart import ChartDAO from superset.models.slice import Slice - from superset.utils import json as utils_json await ctx.info( "Retrieving chart information: identifier=%s, form_data_key=%s" % (request.identifier, request.form_data_key) ) + # Handle unsaved chart (form_data_key only, no identifier) + if not request.identifier and request.form_data_key: + with event_logger.log_context( + action="mcp.get_chart_info.unsaved_chart_from_cache" + ): + await ctx.info( + "No chart identifier provided - retrieving unsaved chart from cache: " + "form_data_key=%s" % (request.form_data_key,) + ) + return _build_unsaved_chart_info(request.form_data_key) + + # At this point identifier must be set (validator ensures at least one + # of identifier/form_data_key is provided, and the form_data_key-only + # branch returned above). + assert request.identifier is not None + # Eager load owners and tags to avoid N+1 queries during serialization eager_options = [ subqueryload(Slice.owners), @@ -125,35 +199,14 @@ async def get_chart_info( if isinstance(result, ChartInfo): # If form_data_key is provided, override form_data with cached version if request.form_data_key: - await ctx.info( - "Retrieving unsaved chart state from cache: form_data_key=%s" - % (request.form_data_key,) - ) - cached_form_data = _get_cached_form_data(request.form_data_key) - - if cached_form_data: - try: - result.form_data = utils_json.loads(cached_form_data) - result.form_data_key = request.form_data_key - result.is_unsaved_state = True - - # Update viz_type from cached form_data if present - if result.form_data and "viz_type" in result.form_data: - result.viz_type = result.form_data["viz_type"] - - await ctx.info( - "Chart form_data overridden with unsaved state from cache" - ) - except (TypeError, ValueError) as e: - await ctx.warning( - "Failed to parse cached form_data: %s. " - "Using saved chart configuration." % (str(e),) - ) - else: - await ctx.warning( - "form_data_key provided but no cached data found. " - "The cache may have expired. Using saved chart configuration." + with event_logger.log_context( + action="mcp.get_chart_info.unsaved_state_override" + ): + await ctx.info( + "Retrieving unsaved chart state from cache: form_data_key=%s" + % (request.form_data_key,) ) + _apply_unsaved_state_override(result, request.form_data_key) await ctx.info( "Chart information retrieved successfully: chart_name=%s, " diff --git a/superset/mcp_service/chart/tool/get_chart_preview.py b/superset/mcp_service/chart/tool/get_chart_preview.py index 579a2d748f5..5fea5c461e6 100644 --- a/superset/mcp_service/chart/tool/get_chart_preview.py +++ b/superset/mcp_service/chart/tool/get_chart_preview.py @@ -1837,7 +1837,71 @@ async def _get_chart_preview_internal( # noqa: C901 # Find the chart with event_logger.log_context(action="mcp.get_chart_preview.chart_lookup"): chart: Any = None - if isinstance(request.identifier, int) or ( + + # Handle unsaved chart (form_data_key only, no identifier) + if not request.identifier and request.form_data_key: + with event_logger.log_context( + action="mcp.get_chart_preview.unsaved_chart_from_cache" + ): + await ctx.info( + "No chart identifier - creating transient chart from " + "form_data_key=%s" % (request.form_data_key,) + ) + from superset.commands.explore.form_data.get import ( + GetFormDataCommand, + ) + from superset.commands.explore.form_data.parameters import ( + CommandParameters, + ) + from superset.utils import json as utils_json + + try: + cmd_params = CommandParameters(key=request.form_data_key) + form_data_json = GetFormDataCommand(cmd_params).run() + if form_data_json: + form_data = utils_json.loads(form_data_json) + + class TransientChartFromKey: + def __init__(self, fd: Dict[str, Any]): + self.id = 0 + self.slice_name = "Unsaved Chart Preview" + self.viz_type = fd.get("viz_type", "table") + ds = fd.get("datasource", "") + parts = str(ds).split("__") if ds else [] + self.datasource_id = ( + int(parts[0]) + if len(parts) == 2 + else fd.get("datasource_id") + ) + self.datasource_type = ( + parts[1] + if len(parts) == 2 + else fd.get("datasource_type", "table") + ) + self.params = utils_json.dumps(fd) + self.form_data = fd + self.uuid = None + + chart = TransientChartFromKey(form_data) + except ( + CommandException, + ValueError, + KeyError, + AttributeError, + TypeError, + ) as e: + logger.warning( + "Failed to get form data for key %s: %s", + request.form_data_key, + e, + ) + return ChartError( + error="No cached chart data found for form_data_key. " + "The cache may have expired.", + error_type="NotFound", + ) + + elif isinstance(request.identifier, int) or ( isinstance(request.identifier, str) and request.identifier.isdigit() ): chart_id = ( @@ -1849,7 +1913,7 @@ async def _get_chart_preview_internal( # noqa: C901 "Performing ID-based chart lookup: chart_id=%s" % (chart_id,) ) chart = ChartDAO.find_by_id(chart_id) - else: + elif isinstance(request.identifier, str): await ctx.debug( "Performing UUID-based chart lookup: uuid=%s" % (request.identifier,) @@ -1883,7 +1947,7 @@ async def _get_chart_preview_internal( # noqa: C901 # Create a transient chart object from form data class TransientChart: def __init__(self, form_data: Dict[str, Any]): - self.id = None + self.id = 0 self.slice_name = "Unsaved Chart Preview" self.viz_type = form_data.get("viz_type", "table") self.datasource_id = None @@ -1951,6 +2015,48 @@ async def _get_chart_preview_internal( # noqa: C901 for warning in validation_result.warnings: await ctx.warning("Dataset warning: %s" % (warning,)) + # If form_data_key is provided, override chart.params with cached + # form_data so the preview reflects what the user actually sees + if request.form_data_key and getattr(chart, "id", None) is not None: + with event_logger.log_context( + action="mcp.get_chart_preview.unsaved_state_override" + ): + await ctx.info( + "Retrieving unsaved chart state from cache: form_data_key=%s" + % (request.form_data_key,) + ) + from superset.commands.explore.form_data.get import ( + GetFormDataCommand, + ) + from superset.commands.explore.form_data.parameters import ( + CommandParameters, + ) + + try: + cmd_params = CommandParameters(key=request.form_data_key) + cached_form_data = GetFormDataCommand(cmd_params).run() + if cached_form_data: + chart.params = cached_form_data + from superset.utils import json as utils_json + + parsed = utils_json.loads(cached_form_data) + if isinstance(parsed, dict) and "viz_type" in parsed: + chart.viz_type = parsed["viz_type"] + await ctx.info( + "Chart params overridden with unsaved state from cache" + ) + else: + await ctx.warning( + "form_data_key provided but no cached data found. " + "The cache may have expired. Using saved chart " + "configuration." + ) + except (CommandException, ValueError, KeyError) as e: + await ctx.warning( + "Failed to retrieve cached form_data: %s. " + "Using saved chart configuration." % (str(e),) + ) + import time start_time = time.time() From f458e2d484f68281ad8359eb48b4bac2de3974e2 Mon Sep 17 00:00:00 2001 From: Amin Ghadersohi Date: Fri, 13 Mar 2026 12:13:47 +0100 Subject: [PATCH 33/58] feat(mcp): add extra_form_data param to get_chart_data for dashboard filters (#38531) Co-authored-by: Claude Opus 4.6 --- superset/mcp_service/chart/schemas.py | 8 +++ .../mcp_service/chart/tool/get_chart_data.py | 63 +++++++++++++------ 2 files changed, 53 insertions(+), 18 deletions(-) diff --git a/superset/mcp_service/chart/schemas.py b/superset/mcp_service/chart/schemas.py index ea5459cfbc4..155d455f372 100644 --- a/superset/mcp_service/chart/schemas.py +++ b/superset/mcp_service/chart/schemas.py @@ -1084,6 +1084,14 @@ class GetChartDataRequest(QueryCacheControl): "chart's configured row limit." ), ) + extra_form_data: dict[str, Any] | None = Field( + default=None, + description=( + "Extra form data to merge into the chart query, typically from " + "dashboard native filters. Format: " + '{"filters": [{"col": "country", "op": "IN", "val": ["US"]}]}' + ), + ) format: Literal["json", "csv", "excel"] = Field( default="json", description="Data export format" ) diff --git a/superset/mcp_service/chart/tool/get_chart_data.py b/superset/mcp_service/chart/tool/get_chart_data.py index ed7a4e20c33..b6edb257f08 100644 --- a/superset/mcp_service/chart/tool/get_chart_data.py +++ b/superset/mcp_service/chart/tool/get_chart_data.py @@ -44,10 +44,21 @@ from superset.mcp_service.chart.schemas import ( ) from superset.mcp_service.utils.cache_utils import get_cache_status_from_result from superset.mcp_service.utils.schema_utils import parse_request +from superset.utils.core import merge_extra_filters logger = logging.getLogger(__name__) +def _apply_extra_form_data( + form_data: dict[str, Any], extra_form_data: dict[str, Any] | None +) -> None: + """Merge dashboard native filters into chart form_data in-place.""" + if not extra_form_data: + return + form_data["extra_form_data"] = extra_form_data + merge_extra_filters(form_data) + + def _get_cached_form_data(form_data_key: str) -> str | None: """Retrieve form_data from cache using form_data_key. @@ -286,20 +297,26 @@ async def get_chart_data( # noqa: C901 cached_metrics = cached_form_data_dict.get("metrics", []) cached_groupby = cached_form_data_dict.get("groupby", []) + _apply_extra_form_data(cached_form_data_dict, request.extra_form_data) + + cached_query: dict[str, Any] = { + "filters": cached_form_data_dict.get("filters", []), + "columns": cached_groupby, + "metrics": cached_metrics, + "row_limit": row_limit, + "order_desc": cached_form_data_dict.get("order_desc", True), + } + # Include adhoc_filters so dashboard native filters are applied + cached_adhoc = cached_form_data_dict.get("adhoc_filters") + if cached_adhoc: + cached_query["adhoc_filters"] = cached_adhoc + query_context = factory.create( datasource={ "id": datasource_id, "type": datasource_type, }, - queries=[ - { - "filters": cached_form_data_dict.get("filters", []), - "columns": cached_groupby, - "metrics": cached_metrics, - "row_limit": row_limit, - "order_desc": cached_form_data_dict.get("order_desc", True), - } - ], + queries=[cached_query], form_data=cached_form_data_dict, force=request.force_refresh, ) @@ -454,20 +471,26 @@ async def get_chart_data( # noqa: C901 error_type="MissingQueryContext", ) + _apply_extra_form_data(form_data, request.extra_form_data) + + fallback_query: dict[str, Any] = { + "filters": form_data.get("filters", []), + "columns": query_columns, + "metrics": metrics, + "row_limit": row_limit, + "order_desc": True, + } + # Include adhoc_filters so dashboard native filters are applied + fallback_adhoc = form_data.get("adhoc_filters") + if fallback_adhoc: + fallback_query["adhoc_filters"] = fallback_adhoc + query_context = factory.create( datasource={ "id": chart.datasource_id, "type": chart.datasource_type, }, - queries=[ - { - "filters": form_data.get("filters", []), - "columns": query_columns, - "metrics": metrics, - "row_limit": row_limit, - "order_desc": True, - } - ], + queries=[fallback_query], form_data=form_data, force=request.force_refresh, ) @@ -480,6 +503,10 @@ async def get_chart_data( # noqa: C901 for query in query_context_json.get("queries", []): query["row_limit"] = request.limit + # Merge dashboard native filters into query_context's form_data + qc_form_data = query_context_json.setdefault("form_data", {}) + _apply_extra_form_data(qc_form_data, request.extra_form_data) + # Create QueryContext from the saved context using the schema # This is exactly how the API does it query_context = ChartDataQueryContextSchema().load(query_context_json) From d5cf77cd60beaa2b82b6dd2ed1cf50a70c0e2b77 Mon Sep 17 00:00:00 2001 From: Amin Ghadersohi Date: Fri, 13 Mar 2026 12:46:52 +0100 Subject: [PATCH 34/58] fix(mcp): fix crashes in list tools, dataset info, chart preview, and add owner/favorite filters (#38277) Co-authored-by: Claude Opus 4.6 --- superset/daos/chart.py | 4 +- superset/daos/dashboard.py | 59 ++++++++++++++++++- superset/daos/dataset.py | 11 ++++ superset/mcp_service/chart/chart_utils.py | 8 +++ superset/mcp_service/chart/preview_utils.py | 17 +++--- superset/mcp_service/chart/schemas.py | 3 +- .../mcp_service/chart/tool/generate_chart.py | 17 +++++- .../mcp_service/chart/tool/get_chart_data.py | 6 +- .../chart/tool/get_chart_preview.py | 30 ++++++---- .../mcp_service/chart/tool/update_chart.py | 1 + .../chart/tool/update_chart_preview.py | 1 + superset/mcp_service/dashboard/schemas.py | 54 +++++++++++++---- .../tool/add_chart_to_existing_dashboard.py | 6 +- .../dashboard/tool/generate_dashboard.py | 7 ++- superset/mcp_service/dataset/schemas.py | 27 +++++++-- superset/mcp_service/mcp_core.py | 7 ++- superset/mcp_service/sql_lab/schemas.py | 18 ++++-- .../sql_lab/tool/open_sql_lab_with_context.py | 2 +- superset/mcp_service/system/system_utils.py | 4 +- .../mcp_service/system/tool/health_check.py | 4 ++ .../tool/test_dashboard_generation.py | 39 ++++++++---- .../dataset/tool/test_dataset_tools.py | 2 +- .../sql_lab/tool/test_execute_sql.py | 35 +++++++++++ .../system/tool/test_get_current_user.py | 2 +- .../mcp_service/system/tool/test_mcp_core.py | 5 +- 25 files changed, 297 insertions(+), 72 deletions(-) diff --git a/superset/daos/chart.py b/superset/daos/chart.py index 44d0057eea8..352b9b9cb68 100644 --- a/superset/daos/chart.py +++ b/superset/daos/chart.py @@ -34,8 +34,8 @@ logger = logging.getLogger(__name__) # Custom filterable fields for charts CHART_CUSTOM_FIELDS = { - "viz_type": ["eq", "in_", "like"], - "datasource_name": ["eq", "in_", "like"], + "viz_type": ["eq", "in", "like"], + "datasource_name": ["eq", "in", "like"], } diff --git a/superset/daos/dashboard.py b/superset/daos/dashboard.py index 3e8b7c8d82f..d0d6a027a94 100644 --- a/superset/daos/dashboard.py +++ b/superset/daos/dashboard.py @@ -23,6 +23,8 @@ from typing import Any, Dict, List from flask import g from flask_appbuilder.models.sqla.interface import SQLAInterface +from sqlalchemy import select +from sqlalchemy.orm import Query from superset import is_feature_enabled, security_manager from superset.commands.dashboard.exceptions import ( @@ -31,7 +33,7 @@ from superset.commands.dashboard.exceptions import ( DashboardNotFoundError, DashboardUpdateFailedError, ) -from superset.daos.base import BaseDAO +from superset.daos.base import BaseDAO, ColumnOperator, ColumnOperatorEnum from superset.dashboards.filters import DashboardAccessFilter, is_uuid from superset.exceptions import SupersetSecurityException from superset.extensions import db @@ -47,15 +49,66 @@ logger = logging.getLogger(__name__) # Custom filterable fields for dashboards DASHBOARD_CUSTOM_FIELDS = { - "tags": ["eq", "in_", "like"], - "owners": ["eq", "in_"], + "tags": ["eq", "in", "like"], + "owners": ["eq", "in"], "published": ["eq"], + "owner": ["eq", "in"], + "favorite": ["eq"], } class DashboardDAO(BaseDAO[Dashboard]): base_filter = DashboardAccessFilter + @classmethod + def apply_column_operators( + cls, + query: Query, + column_operators: list[ColumnOperator] | None = None, + ) -> Query: + """Override to handle owner and favorite filters via subqueries. + + - owner: filters dashboards by owner user ID via dashboard_user M2M table + - favorite: filters dashboards by whether the current user has favorited them + """ + if not column_operators: + return query + + remaining_operators: list[ColumnOperator] = [] + for c in column_operators: + if not isinstance(c, ColumnOperator): + c = ColumnOperator.model_validate(c) + if c.col == "owner": + from superset.models.dashboard import dashboard_user + + operator_enum = ColumnOperatorEnum(c.opr) + subq = select(dashboard_user.c.dashboard_id).where( + operator_enum.apply(dashboard_user.c.user_id, c.value) + ) + query = query.filter( + Dashboard.id.in_(subq) # type: ignore[attr-defined,unused-ignore] + ) + elif c.col == "favorite": + user_id = get_user_id() + fav_subq = select(FavStar.obj_id).where( + FavStar.class_name == FavStarClassName.DASHBOARD, + FavStar.user_id == user_id, + ) + if c.value is True or c.value == 1: + query = query.filter( + Dashboard.id.in_(fav_subq) # type: ignore[attr-defined,unused-ignore] + ) + else: + query = query.filter( + ~Dashboard.id.in_(fav_subq) # type: ignore[attr-defined,unused-ignore] + ) + else: + remaining_operators.append(c) + + if remaining_operators: + query = super().apply_column_operators(query, remaining_operators) + return query + @classmethod def get_filterable_columns_and_operators(cls) -> Dict[str, List[str]]: filterable = super().get_filterable_columns_and_operators() diff --git a/superset/daos/dataset.py b/superset/daos/dataset.py index 30476c01eb6..6f4bbc51320 100644 --- a/superset/daos/dataset.py +++ b/superset/daos/dataset.py @@ -40,6 +40,7 @@ logger = logging.getLogger(__name__) # Custom filterable fields for datasets (not direct model columns) DATASET_CUSTOM_FIELDS: dict[str, list[str]] = { "database_name": ["eq", "like", "ilike"], + "owner": ["eq", "in"], } @@ -77,6 +78,16 @@ class DatasetDAO(BaseDAO[SqlaTable]): operator_enum.apply(Database.database_name, c.value) ) query = query.filter(SqlaTable.database_id.in_(subq)) + elif c.col == "owner": + from superset.connectors.sqla.models import sqlatable_user + + operator_enum = ColumnOperatorEnum(c.opr) + subq = select(sqlatable_user.c.table_id).where( + operator_enum.apply(sqlatable_user.c.user_id, c.value) + ) + query = query.filter( + SqlaTable.id.in_(subq) # type: ignore[attr-defined,unused-ignore] + ) else: remaining_operators.append(c) diff --git a/superset/mcp_service/chart/chart_utils.py b/superset/mcp_service/chart/chart_utils.py index b1e5be880c1..bdaae915703 100644 --- a/superset/mcp_service/chart/chart_utils.py +++ b/superset/mcp_service/chart/chart_utils.py @@ -489,6 +489,8 @@ def configure_temporal_handling( For temporal columns, enables standard time series handling. For non-temporal columns (e.g., BIGINT year), disables DATE_TRUNC by setting categorical sorting options. + + Stores any warnings in ``form_data["_mcp_warnings"]``. """ if x_is_temporal: if time_grain: @@ -499,6 +501,12 @@ def configure_temporal_handling( form_data["x_axis_sort_series_ascending"] = True form_data["time_grain_sqla"] = None form_data["granularity_sqla"] = None + if time_grain: + form_data.setdefault("_mcp_warnings", []).append( + f"time_grain='{time_grain}' was ignored because the x-axis " + f"column is not a temporal type. time_grain only applies to " + f"DATE/DATETIME/TIMESTAMP columns." + ) def map_xy_config( diff --git a/superset/mcp_service/chart/preview_utils.py b/superset/mcp_service/chart/preview_utils.py index c28681a9da0..5b930797b06 100644 --- a/superset/mcp_service/chart/preview_utils.py +++ b/superset/mcp_service/chart/preview_utils.py @@ -23,6 +23,7 @@ from form data without requiring a saved chart object. """ import logging +import math from typing import Any, Dict, List from superset.commands.chart.data.get_data_command import ChartDataCommand @@ -183,19 +184,21 @@ def _calculate_column_widths( def _format_value(val: Any, width: int) -> str: """Format a value based on its type.""" if isinstance(val, float): - if abs(val) >= 1000000: + if math.isnan(val): + val_str = "N/A" + elif math.isfinite(val) and val.is_integer(): + # Integer-like float (e.g. 1988.0) — format without decimals + val_str = str(int(val)) + elif abs(val) >= 1000000: val_str = f"{val:.2e}" # Scientific notation for large numbers elif abs(val) >= 1000: val_str = f"{val:,.2f}" # Thousands separator else: - val_str = f"{val:.2f}" + val_str = f"{val:g}" elif isinstance(val, int): - if abs(val) >= 1000: - val_str = f"{val:,}" # Thousands separator - else: - val_str = str(val) + val_str = str(val) elif val is None: - val_str = "NULL" + val_str = "N/A" else: val_str = str(val) diff --git a/superset/mcp_service/chart/schemas.py b/superset/mcp_service/chart/schemas.py index 155d455f372..4cc2bffa07f 100644 --- a/superset/mcp_service/chart/schemas.py +++ b/superset/mcp_service/chart/schemas.py @@ -1200,7 +1200,8 @@ class GetChartPreviewRequest(QueryCacheControl): format: Literal["url", "ascii", "table", "vega_lite"] = Field( default="url", description=( - "Preview format: 'url' for image URL, 'ascii' for text art, " + "Preview format: 'url' for explore link (default), " + "'ascii' for text art, " "'table' for data table, " "'vega_lite' for interactive JSON specification" ), diff --git a/superset/mcp_service/chart/tool/generate_chart.py b/superset/mcp_service/chart/tool/generate_chart.py index 7cec6e9e71f..76ce79b5212 100644 --- a/superset/mcp_service/chart/tool/generate_chart.py +++ b/superset/mcp_service/chart/tool/generate_chart.py @@ -39,6 +39,7 @@ from superset.mcp_service.chart.chart_utils import ( ) from superset.mcp_service.chart.schemas import ( AccessibilityMetadata, + ChartError, GenerateChartRequest, GenerateChartResponse, PerformanceMetadata, @@ -257,7 +258,7 @@ async def generate_chart( # noqa: C901 chart_id = None explore_url = None form_data_key = None - response_warnings: list[str] = [] + response_warnings: list[str] = form_data.pop("_mcp_warnings", []) # Save chart by default (unless save_chart=False) if request.save_chart: @@ -636,7 +637,12 @@ async def generate_chart( # noqa: C901 preview_request, ctx ) - if hasattr(preview_result, "content"): + if isinstance(preview_result, ChartError): + await ctx.warning( + "Preview '%s' failed: %s" + % (format_type, preview_result.error) + ) + elif hasattr(preview_result, "content"): previews[format_type] = preview_result.content else: # For preview-only mode (save_chart=false) @@ -674,7 +680,12 @@ async def generate_chart( # noqa: C901 preview_format=format_type, ) - if not hasattr(preview_result, "error"): + if isinstance(preview_result, ChartError): + await ctx.warning( + "Preview '%s' failed: %s" + % (format_type, preview_result.error) + ) + else: previews[format_type] = preview_result except (CommandException, ValueError, KeyError) as e: diff --git a/superset/mcp_service/chart/tool/get_chart_data.py b/superset/mcp_service/chart/tool/get_chart_data.py index b6edb257f08..c6f9123c5cc 100644 --- a/superset/mcp_service/chart/tool/get_chart_data.py +++ b/superset/mcp_service/chart/tool/get_chart_data.py @@ -959,7 +959,7 @@ def _export_data_as_csv( chart_id=chart.id, chart_name=chart.slice_name or f"Chart {chart.id}", chart_type=chart.viz_type or "unknown", - columns=[], # Not needed for CSV export + columns=[], # Column names are embedded in CSV content data=[], # CSV content is in csv_data field row_count=len(data), total_rows=len(data), @@ -1114,7 +1114,7 @@ def _create_excel_chart_data( chart_id=chart.id, chart_name=chart_name, chart_type=chart.viz_type or "unknown", - columns=[], + columns=[], # Column names are embedded in the Excel file data=[], row_count=len(data), total_rows=len(data), @@ -1147,7 +1147,7 @@ def _create_excel_chart_data_xlsxwriter( chart_id=chart.id, chart_name=chart_name, chart_type=chart.viz_type or "unknown", - columns=[], + columns=[], # Column names are embedded in the Excel file data=[], row_count=len(data), total_rows=len(data), diff --git a/superset/mcp_service/chart/tool/get_chart_preview.py b/superset/mcp_service/chart/tool/get_chart_preview.py index 5fea5c461e6..d95e9500661 100644 --- a/superset/mcp_service/chart/tool/get_chart_preview.py +++ b/superset/mcp_service/chart/tool/get_chart_preview.py @@ -89,19 +89,20 @@ class PreviewFormatStrategy: class URLPreviewStrategy(PreviewFormatStrategy): - """Generate URL-based image preview.""" + """Generate URL-based preview with explore link.""" def generate(self) -> URLPreview | ChartError: - # Screenshot-based URL previews are not supported. - # Users should use the explore_url to view the chart interactively, - # or use other preview formats like 'ascii', 'table', or 'vega_lite'. - return ChartError( - error=( - "URL-based screenshot previews are not supported. " - "Use the explore_url to view the chart interactively, " - "or try formats: 'ascii', 'table', or 'vega_lite'." - ), - error_type="UnsupportedFormat", + chart = self.chart + if not chart.id: + return ChartError( + error="URL preview not available for transient charts without an ID", + error_type="UnsupportedFormat", + ) + explore_url = f"{get_superset_base_url()}/explore/?slice_id={chart.id}" + return URLPreview( + preview_url=explore_url, + width=self.request.width or 800, + height=self.request.height or 600, ) @@ -2143,7 +2144,12 @@ async def _get_chart_preview_internal( # noqa: C901 elif isinstance(content, TablePreview): result.format = "table" result.table_data = content.table_data - # Base64 preview support removed + elif isinstance(content, VegaLitePreview): + result.format = "vega_lite" + elif isinstance(content, URLPreview): + result.format = "url" + result.width = content.width + result.height = content.height return result diff --git a/superset/mcp_service/chart/tool/update_chart.py b/superset/mcp_service/chart/tool/update_chart.py index 7abc1ef9da3..d69e09693a9 100644 --- a/superset/mcp_service/chart/tool/update_chart.py +++ b/superset/mcp_service/chart/tool/update_chart.py @@ -130,6 +130,7 @@ async def update_chart( # Get dataset_id from existing chart for column type checking dataset_id = chart.datasource_id if chart.datasource_id else None new_form_data = map_config_to_form_data(request.config, dataset_id=dataset_id) + new_form_data.pop("_mcp_warnings", None) # Update chart using Superset's command from superset.commands.chart.update import UpdateChartCommand diff --git a/superset/mcp_service/chart/tool/update_chart_preview.py b/superset/mcp_service/chart/tool/update_chart_preview.py index 117a92a6331..f3242b14091 100644 --- a/superset/mcp_service/chart/tool/update_chart_preview.py +++ b/superset/mcp_service/chart/tool/update_chart_preview.py @@ -72,6 +72,7 @@ def update_chart_preview( new_form_data = map_config_to_form_data( request.config, dataset_id=request.dataset_id ) + new_form_data.pop("_mcp_warnings", None) # Generate new explore link with updated form_data explore_url = generate_explore_link(request.dataset_id, new_form_data) diff --git a/superset/mcp_service/dashboard/schemas.py b/superset/mcp_service/dashboard/schemas.py index e09b3871ce6..48aa72a952c 100644 --- a/superset/mcp_service/dashboard/schemas.py +++ b/superset/mcp_service/dashboard/schemas.py @@ -152,8 +152,9 @@ class DashboardFilter(ColumnOperator): col: Literal[ "dashboard_title", "published", - "favorite", "created_by_fk", + "owner", + "favorite", ] = Field( ..., description=( @@ -465,6 +466,12 @@ class GenerateDashboardResponse(BaseModel): def dashboard_serializer(dashboard: "Dashboard") -> DashboardInfo: + from superset.mcp_service.utils.url_utils import get_superset_base_url + + base_url = get_superset_base_url() + relative_url = dashboard.url # e.g. "/superset/dashboard/{slug_or_id}/" + absolute_url = f"{base_url}{relative_url}" if relative_url else None + return DashboardInfo( id=dashboard.id, dashboard_title=dashboard.dashboard_title or "Untitled", @@ -487,7 +494,7 @@ def dashboard_serializer(dashboard: "Dashboard") -> DashboardInfo: if dashboard.changed_by else None, uuid=str(dashboard.uuid) if dashboard.uuid else None, - url=dashboard.url, + url=absolute_url, created_on_humanized=dashboard.created_on_humanized, changed_on_humanized=dashboard.changed_on_humanized, chart_count=len(dashboard.slices) if dashboard.slices else 0, @@ -517,16 +524,28 @@ def dashboard_serializer(dashboard: "Dashboard") -> DashboardInfo: def serialize_dashboard_object(dashboard: Any) -> DashboardInfo: """Simple dashboard serializer that safely handles object attributes.""" + from superset.mcp_service.utils.url_utils import get_superset_base_url + + # Construct URL from id/slug (the model's @property isn't available on + # column-only query tuples returned by DAO.list with select_columns) + dashboard_id = getattr(dashboard, "id", None) + slug = getattr(dashboard, "slug", None) + dashboard_url = None + if dashboard_id is not None: + dashboard_url = ( + f"{get_superset_base_url()}/superset/dashboard/{slug or dashboard_id}/" + ) + return DashboardInfo( - id=getattr(dashboard, "id", None), + id=dashboard_id, dashboard_title=getattr(dashboard, "dashboard_title", None), - slug=getattr(dashboard, "slug", None), - url=getattr(dashboard, "url", None), + slug=slug, + url=dashboard_url, published=getattr(dashboard, "published", None), - changed_by_name=getattr(dashboard, "changed_by_name", None), + changed_by=getattr(dashboard, "changed_by_name", None), changed_on=getattr(dashboard, "changed_on", None), changed_on_humanized=getattr(dashboard, "changed_on_humanized", None), - created_by_name=getattr(dashboard, "created_by_name", None), + created_by=getattr(dashboard, "created_by_name", None), created_on=getattr(dashboard, "created_on", None), created_on_humanized=getattr(dashboard, "created_on_humanized", None), description=getattr(dashboard, "description", None), @@ -541,9 +560,24 @@ def serialize_dashboard_object(dashboard: Any) -> DashboardInfo: if getattr(dashboard, "uuid", None) else None, chart_count=len(getattr(dashboard, "slices", [])), - owners=getattr(dashboard, "owners", []), - tags=getattr(dashboard, "tags", []), - roles=getattr(dashboard, "roles", []), + owners=[ + UserInfo.model_validate(owner, from_attributes=True) + for owner in getattr(dashboard, "owners", []) + ] + if getattr(dashboard, "owners", None) + else [], + tags=[ + TagInfo.model_validate(tag, from_attributes=True) + for tag in getattr(dashboard, "tags", []) + ] + if getattr(dashboard, "tags", None) + else [], + roles=[ + RoleInfo.model_validate(role, from_attributes=True) + for role in getattr(dashboard, "roles", []) + ] + if getattr(dashboard, "roles", None) + else [], charts=[ serialize_chart_object(chart) for chart in getattr(dashboard, "slices", []) ] diff --git a/superset/mcp_service/dashboard/tool/add_chart_to_existing_dashboard.py b/superset/mcp_service/dashboard/tool/add_chart_to_existing_dashboard.py index 19f17c95a5e..2629aded0dc 100644 --- a/superset/mcp_service/dashboard/tool/add_chart_to_existing_dashboard.py +++ b/superset/mcp_service/dashboard/tool/add_chart_to_existing_dashboard.py @@ -29,6 +29,7 @@ from fastmcp import Context from superset_core.mcp.decorators import tool from superset.extensions import event_logger +from superset.mcp_service.chart.schemas import serialize_chart_object from superset.mcp_service.dashboard.constants import ( generate_id, GRID_COLUMN_COUNT, @@ -431,7 +432,10 @@ def add_chart_to_existing_dashboard( if serialize_tag_object(tag) is not None ], roles=[], - charts=[], + charts=[ + serialize_chart_object(chart) + for chart in getattr(updated_dashboard, "slices", []) + ], ) dashboard_url = ( diff --git a/superset/mcp_service/dashboard/tool/generate_dashboard.py b/superset/mcp_service/dashboard/tool/generate_dashboard.py index 61285c1a874..4bbc4450502 100644 --- a/superset/mcp_service/dashboard/tool/generate_dashboard.py +++ b/superset/mcp_service/dashboard/tool/generate_dashboard.py @@ -28,6 +28,7 @@ from fastmcp import Context from superset_core.mcp.decorators import tool from superset.extensions import event_logger +from superset.mcp_service.chart.schemas import serialize_chart_object from superset.mcp_service.dashboard.constants import ( generate_id, GRID_COLUMN_COUNT, @@ -295,7 +296,11 @@ def generate_dashboard( if serialize_tag_object(tag) is not None ], roles=[], # Dashboard roles not typically set at creation - charts=[], # Chart details not needed in response + charts=[ + obj + for chart in getattr(dashboard, "slices", []) + if (obj := serialize_chart_object(chart)) is not None + ], ) dashboard_url = f"{get_superset_base_url()}/superset/dashboard/{dashboard.id}/" diff --git a/superset/mcp_service/dataset/schemas.py b/superset/mcp_service/dataset/schemas.py index 14dd329e8f7..1fc5d67e112 100644 --- a/superset/mcp_service/dataset/schemas.py +++ b/superset/mcp_service/dataset/schemas.py @@ -57,7 +57,6 @@ class DatasetFilter(ColumnOperator): "schema", "database_name", "owner", - "favorite", ] = Field( ..., description="Column to filter on. Use get_schema(model_type='dataset') for " @@ -154,14 +153,16 @@ class DatasetInfo(BaseModel): # Get full serialization data = serializer(self) + # Normalize alias: Pydantic serializes as 'schema_name' (field name) + # but the DAO column and API convention is 'schema' + if "schema_name" in data: + data["schema"] = data.pop("schema_name") + # Check if we have a context with select_columns if info.context and isinstance(info.context, dict): select_columns = info.context.get("select_columns") if select_columns: - # Handle alias: 'schema' -> 'schema_name' requested_fields = set(select_columns) - if "schema" in requested_fields: - requested_fields.add("schema_name") # Filter to only requested fields return {k: v for k, v in data.items() if k in requested_fields} @@ -285,6 +286,20 @@ class GetDatasetInfoRequest(MetadataCacheControl): ] +def _parse_json_field(obj: Any, field_name: str) -> Dict[str, Any] | None: + """Parse a field that may be stored as a JSON string into a dict.""" + value = getattr(obj, field_name, None) + if isinstance(value, str): + try: + parsed = json.loads(value) + if isinstance(parsed, dict): + return parsed + except (ValueError, TypeError): + pass + return None + return value + + def serialize_dataset_object(dataset: Any) -> DatasetInfo | None: if not dataset: return None @@ -357,8 +372,8 @@ def serialize_dataset_object(dataset: Any) -> DatasetInfo | None: offset=getattr(dataset, "offset", None), cache_timeout=getattr(dataset, "cache_timeout", None), params=params, - template_params=getattr(dataset, "template_params", None), - extra=getattr(dataset, "extra", None), + template_params=_parse_json_field(dataset, "template_params"), + extra=_parse_json_field(dataset, "extra"), columns=columns, metrics=metrics, is_favorite=getattr(dataset, "is_favorite", None), diff --git a/superset/mcp_service/mcp_core.py b/superset/mcp_service/mcp_core.py index 3ac0f008ca4..0b1a817d26d 100644 --- a/superset/mcp_service/mcp_core.py +++ b/superset/mcp_service/mcp_core.py @@ -181,8 +181,11 @@ class ModelListCore(BaseCore, Generic[L]): total_pages = (total_count + page_size - 1) // page_size if page_size > 0 else 0 from superset.mcp_service.system.schemas import PaginationInfo + # Report 1-based page in response to match the 1-based input convention + # used by all list tool wrappers (list_charts, list_datasets, etc.) + page_1based = page + 1 pagination_info = PaginationInfo( - page=page, + page=page_1based, page_size=page_size, total_count=total_count, total_pages=total_pages, @@ -202,7 +205,7 @@ class ModelListCore(BaseCore, Generic[L]): self.list_field_name: item_objs, "count": len(item_objs), "total_count": total_count, - "page": page, + "page": page_1based, "page_size": page_size, "total_pages": total_pages, "has_previous": page > 0, diff --git a/superset/mcp_service/sql_lab/schemas.py b/superset/mcp_service/sql_lab/schemas.py index 36f76980704..436e9330733 100644 --- a/superset/mcp_service/sql_lab/schemas.py +++ b/superset/mcp_service/sql_lab/schemas.py @@ -19,12 +19,14 @@ from typing import Any -from pydantic import AliasChoices, BaseModel, Field, field_validator +from pydantic import AliasChoices, BaseModel, ConfigDict, Field, field_validator class ExecuteSqlRequest(BaseModel): """Request schema for executing SQL queries.""" + model_config = ConfigDict(populate_by_name=True) + database_id: int = Field( ..., description="Database connection ID to execute query against" ) @@ -36,9 +38,13 @@ class ExecuteSqlRequest(BaseModel): None, description="Schema to use for query execution", alias="schema" ) catalog: str | None = Field(None, description="Catalog name for query execution") - limit: int = Field( - default=1000, - description="Maximum number of rows to return", + limit: int | None = Field( + default=None, + description=( + "Maximum number of rows to return. " + "If not specified, respects the LIMIT in your SQL query. " + "If specified, overrides any SQL LIMIT clause." + ), ge=1, le=10000, ) @@ -118,6 +124,8 @@ class ExecuteSqlResponse(BaseModel): class OpenSqlLabRequest(BaseModel): """Request schema for opening SQL Lab with context.""" + model_config = ConfigDict(populate_by_name=True) + database_connection_id: int = Field( ..., description="Database connection ID to use in SQL Lab", @@ -140,6 +148,8 @@ class OpenSqlLabRequest(BaseModel): class SqlLabResponse(BaseModel): """Response schema for SQL Lab URL generation.""" + model_config = ConfigDict(populate_by_name=True) + url: str = Field(..., description="URL to open SQL Lab with context") database_id: int = Field(..., description="Database ID used") schema_name: str | None = Field(None, description="Schema selected", alias="schema") diff --git a/superset/mcp_service/sql_lab/tool/open_sql_lab_with_context.py b/superset/mcp_service/sql_lab/tool/open_sql_lab_with_context.py index 15a1919ffbf..d326cf26244 100644 --- a/superset/mcp_service/sql_lab/tool/open_sql_lab_with_context.py +++ b/superset/mcp_service/sql_lab/tool/open_sql_lab_with_context.py @@ -94,7 +94,7 @@ def open_sql_lab_with_context( context_comment += f"\nSELECT * FROM {table_reference} LIMIT 100;" params["sql"] = context_comment - # Construct SQL Lab URL + # Construct SQL Lab URL with full base URL query_string = urlencode(params) url = f"{get_superset_base_url()}/sqllab?{query_string}" diff --git a/superset/mcp_service/system/system_utils.py b/superset/mcp_service/system/system_utils.py index b9a4b8759f3..75fe81af5f8 100644 --- a/superset/mcp_service/system/system_utils.py +++ b/superset/mcp_service/system/system_utils.py @@ -71,8 +71,8 @@ def calculate_dashboard_breakdown( dashboards_with_charts = ( db.session.query(Dashboard).join(Dashboard.slices).distinct().count() ) - dashboards_without_charts = ( - base_counts.get("total_dashboards", 0) - dashboards_with_charts + dashboards_without_charts = max( + 0, base_counts.get("total_dashboards", 0) - dashboards_with_charts ) return DashboardBreakdown( diff --git a/superset/mcp_service/system/tool/health_check.py b/superset/mcp_service/system/tool/health_check.py index f5393d7cfb7..7faf3f18ee7 100644 --- a/superset/mcp_service/system/tool/health_check.py +++ b/superset/mcp_service/system/tool/health_check.py @@ -20,6 +20,7 @@ import datetime import logging import platform +import time from flask import current_app from superset_core.mcp.decorators import tool @@ -30,6 +31,8 @@ from superset.utils.version import get_version_metadata logger = logging.getLogger(__name__) +_start_time = time.monotonic() + @tool(tags=["core"]) async def health_check() -> HealthCheckResponse: @@ -77,6 +80,7 @@ async def health_check() -> HealthCheckResponse: version=version, python_version=platform.python_version(), platform=platform.system(), + uptime_seconds=round(time.monotonic() - _start_time, 1), ) logger.info("Health check completed successfully") diff --git a/tests/unit_tests/mcp_service/dashboard/tool/test_dashboard_generation.py b/tests/unit_tests/mcp_service/dashboard/tool/test_dashboard_generation.py index 912bb74fc08..b382f1f4ab2 100644 --- a/tests/unit_tests/mcp_service/dashboard/tool/test_dashboard_generation.py +++ b/tests/unit_tests/mcp_service/dashboard/tool/test_dashboard_generation.py @@ -64,6 +64,21 @@ def _mock_chart(id: int = 1, slice_name: str = "Test Chart") -> Mock: chart.id = id chart.slice_name = slice_name chart.uuid = f"chart-uuid-{id}" + chart.tags = [] + chart.owners = [] + chart.viz_type = "table" + chart.datasource_name = None + chart.datasource_type = None + chart.description = None + chart.cache_timeout = None + chart.changed_by = None + chart.changed_by_name = None + chart.changed_on = None + chart.changed_on_humanized = None + chart.created_by = None + chart.created_by_name = None + chart.created_on = None + chart.created_on_humanized = None return chart @@ -390,7 +405,7 @@ class TestAddChartToExistingDashboard: ): """Test adding a chart to an existing dashboard.""" mock_dashboard = _mock_dashboard(id=1, title="Existing Dashboard") - mock_dashboard.slices = [Mock(id=10), Mock(id=20)] + mock_dashboard.slices = [_mock_chart(id=10), _mock_chart(id=20)] mock_dashboard.position_json = json.dumps( { "ROOT_ID": { @@ -430,7 +445,11 @@ class TestAddChartToExistingDashboard: mock_db_session.get.return_value = mock_chart updated_dashboard = _mock_dashboard(id=1, title="Existing Dashboard") - updated_dashboard.slices = [Mock(id=10), Mock(id=20), Mock(id=30)] + updated_dashboard.slices = [ + _mock_chart(id=10), + _mock_chart(id=20), + _mock_chart(id=30), + ] mock_update_command.return_value.run.return_value = updated_dashboard request = {"dashboard_id": 1, "chart_id": 30} @@ -505,7 +524,7 @@ class TestAddChartToExistingDashboard: ): """Test error when chart is already in dashboard.""" mock_dashboard = _mock_dashboard() - mock_dashboard.slices = [Mock(id=5)] + mock_dashboard.slices = [_mock_chart(id=5)] mock_find_dashboard.return_value = mock_dashboard mock_db_session.get.return_value = _mock_chart(id=5) request = {"dashboard_id": 1, "chart_id": 5} @@ -537,7 +556,7 @@ class TestAddChartToExistingDashboard: mock_db_session.get.return_value = mock_chart updated_dashboard = _mock_dashboard(id=2) - updated_dashboard.slices = [Mock(id=15)] + updated_dashboard.slices = [_mock_chart(id=15)] mock_update_command.return_value.run.return_value = updated_dashboard request = {"dashboard_id": 2, "chart_id": 15} @@ -585,7 +604,7 @@ class TestAddChartToExistingDashboard: ): """Test adding chart to a dashboard that uses tabs.""" mock_dashboard = _mock_dashboard(id=3, title="Tabbed Dashboard") - mock_dashboard.slices = [Mock(id=10)] + mock_dashboard.slices = [_mock_chart(id=10)] mock_dashboard.position_json = json.dumps( { "ROOT_ID": { @@ -646,7 +665,7 @@ class TestAddChartToExistingDashboard: mock_db_session.get.return_value = mock_chart updated_dashboard = _mock_dashboard(id=3, title="Tabbed Dashboard") - updated_dashboard.slices = [Mock(id=10), Mock(id=25)] + updated_dashboard.slices = [_mock_chart(id=10), _mock_chart(id=25)] mock_update_command.return_value.run.return_value = updated_dashboard request = {"dashboard_id": 3, "chart_id": 25} @@ -685,7 +704,7 @@ class TestAddChartToExistingDashboard: ): """Test adding chart to a specific tab using target_tab name.""" mock_dashboard = _mock_dashboard(id=3, title="Tabbed Dashboard") - mock_dashboard.slices = [Mock(id=10)] + mock_dashboard.slices = [_mock_chart(id=10)] mock_dashboard.position_json = json.dumps( { "ROOT_ID": { @@ -746,7 +765,7 @@ class TestAddChartToExistingDashboard: mock_db_session.get.return_value = mock_chart updated_dashboard = _mock_dashboard(id=3, title="Tabbed Dashboard") - updated_dashboard.slices = [Mock(id=10), Mock(id=30)] + updated_dashboard.slices = [_mock_chart(id=10), _mock_chart(id=30)] mock_update_command.return_value.run.return_value = updated_dashboard request = {"dashboard_id": 3, "chart_id": 30, "target_tab": "Customers"} @@ -786,7 +805,7 @@ class TestAddChartToExistingDashboard: ): """Test adding chart to dashboard that has nanoid-style ROW IDs.""" mock_dashboard = _mock_dashboard(id=4, title="Nanoid Dashboard") - mock_dashboard.slices = [Mock(id=10)] + mock_dashboard.slices = [_mock_chart(id=10)] mock_dashboard.position_json = json.dumps( { "ROOT_ID": { @@ -821,7 +840,7 @@ class TestAddChartToExistingDashboard: mock_db_session.get.return_value = mock_chart updated_dashboard = _mock_dashboard(id=4, title="Nanoid Dashboard") - updated_dashboard.slices = [Mock(id=10), Mock(id=50)] + updated_dashboard.slices = [_mock_chart(id=10), _mock_chart(id=50)] mock_update_command.return_value.run.return_value = updated_dashboard request = {"dashboard_id": 4, "chart_id": 50} diff --git a/tests/unit_tests/mcp_service/dataset/tool/test_dataset_tools.py b/tests/unit_tests/mcp_service/dataset/tool/test_dataset_tools.py index 6ea8342be13..48742973863 100644 --- a/tests/unit_tests/mcp_service/dataset/tool/test_dataset_tools.py +++ b/tests/unit_tests/mcp_service/dataset/tool/test_dataset_tools.py @@ -1319,7 +1319,7 @@ class TestDatasetDefaultColumnFiltering: dataset_item = data["datasets"][0] # Verify ONLY default columns are present in the response item - expected_keys = {"id", "table_name", "schema_name", "changed_on_humanized"} + expected_keys = {"id", "table_name", "schema", "changed_on_humanized"} actual_keys = set(dataset_item.keys()) # The response should only contain the default columns, NOT all columns diff --git a/tests/unit_tests/mcp_service/sql_lab/tool/test_execute_sql.py b/tests/unit_tests/mcp_service/sql_lab/tool/test_execute_sql.py index c9d01def8f9..3576acd3d37 100644 --- a/tests/unit_tests/mcp_service/sql_lab/tool/test_execute_sql.py +++ b/tests/unit_tests/mcp_service/sql_lab/tool/test_execute_sql.py @@ -702,6 +702,41 @@ class TestExecuteSql: with pytest.raises(ToolError, match="less than or equal to 10000"): await client.call_tool("execute_sql", {"request": request}) + @patch("superset.security_manager") + @patch("superset.db") + @pytest.mark.asyncio + async def test_execute_sql_no_limit_respects_sql( + self, mock_db, mock_security_manager, mcp_server + ): + """Test that omitting limit lets the SQL LIMIT clause be respected.""" + mock_database = _mock_database() + mock_database.execute.return_value = _create_select_result( + rows=[{"id": i} for i in range(5)], + columns=["id"], + original_sql="SELECT id FROM users LIMIT 5", + ) + mock_db.session.query.return_value.filter_by.return_value.first.return_value = ( + mock_database + ) + mock_security_manager.can_access_database.return_value = True + + # No 'limit' key — should default to None (no override) + request = { + "database_id": 1, + "sql": "SELECT id FROM users LIMIT 5", + } + + async with Client(mcp_server) as client: + result = await client.call_tool("execute_sql", {"request": request}) + + data = result.structured_content + assert data["success"] is True + + # Verify limit=None was passed to QueryOptions (no override) + call_args = mock_database.execute.call_args + options = call_args[0][1] + assert options.limit is None + @patch("superset.security_manager") @patch("superset.db") @pytest.mark.asyncio diff --git a/tests/unit_tests/mcp_service/system/tool/test_get_current_user.py b/tests/unit_tests/mcp_service/system/tool/test_get_current_user.py index e8fb24674e2..01ac357f3eb 100644 --- a/tests/unit_tests/mcp_service/system/tool/test_get_current_user.py +++ b/tests/unit_tests/mcp_service/system/tool/test_get_current_user.py @@ -366,6 +366,6 @@ def test_chart_filter_existing_columns_still_work(): def test_dashboard_filter_existing_columns_still_work(): """Test that pre-existing dashboard filter columns are not broken.""" - for col in ("dashboard_title", "published", "favorite"): + for col in ("dashboard_title", "published", "created_by_fk"): f = DashboardFilter(col=col, opr="eq", value="test") assert f.col == col diff --git a/tests/unit_tests/mcp_service/system/tool/test_mcp_core.py b/tests/unit_tests/mcp_service/system/tool/test_mcp_core.py index aefdd8c8433..945b5fea084 100644 --- a/tests/unit_tests/mcp_service/system/tool/test_mcp_core.py +++ b/tests/unit_tests/mcp_service/system/tool/test_mcp_core.py @@ -95,10 +95,11 @@ def test_model_list_tool_basic(): assert result.count == 2 assert result.total_count == 2 assert isinstance(result.items[0], DummyOutputSchema) - assert result.page == 1 + # run_tool receives 0-based page; response reports 1-based (page+1) + assert result.page == 2 assert result.page_size == 2 assert result.total_pages == 1 - # For page=1, ModelListCore sets has_previous=True + # For page=1 (0-based), ModelListCore sets has_previous=True assert result.has_previous is True assert result.has_next is False assert result.columns_requested == ["id", "name"] From f5383263bc7a9ad3f4dcdfec9c8b01d730ab7465 Mon Sep 17 00:00:00 2001 From: Ville Brofeldt <33317356+villebro@users.noreply.github.com> Date: Fri, 13 Mar 2026 04:50:10 -0700 Subject: [PATCH 35/58] fix(extensions): fix gitignore template and bump version (#38614) --- superset-extensions-cli/pyproject.toml | 2 +- superset-extensions-cli/src/superset_extensions_cli/cli.py | 2 +- .../templates/{.gitignore.j2 => gitignore.j2} | 0 3 files changed, 2 insertions(+), 2 deletions(-) rename superset-extensions-cli/src/superset_extensions_cli/templates/{.gitignore.j2 => gitignore.j2} (100%) diff --git a/superset-extensions-cli/pyproject.toml b/superset-extensions-cli/pyproject.toml index 7a09fbe274e..3f88e880997 100644 --- a/superset-extensions-cli/pyproject.toml +++ b/superset-extensions-cli/pyproject.toml @@ -17,7 +17,7 @@ [project] name = "apache-superset-extensions-cli" -version = "0.1.0rc1" +version = "0.1.0rc2" description = "Official command-line interface for building, bundling, and managing Apache Superset extensions" readme = "README.md" authors = [ diff --git a/superset-extensions-cli/src/superset_extensions_cli/cli.py b/superset-extensions-cli/src/superset_extensions_cli/cli.py index 342baf637e7..079f4e639a3 100644 --- a/superset-extensions-cli/src/superset_extensions_cli/cli.py +++ b/superset-extensions-cli/src/superset_extensions_cli/cli.py @@ -686,7 +686,7 @@ def init( click.secho("✅ Created extension.json", fg="green") # Create .gitignore - gitignore = env.get_template(".gitignore.j2").render(ctx) + gitignore = env.get_template("gitignore.j2").render(ctx) (target_dir / ".gitignore").write_text(gitignore) click.secho("✅ Created .gitignore", fg="green") diff --git a/superset-extensions-cli/src/superset_extensions_cli/templates/.gitignore.j2 b/superset-extensions-cli/src/superset_extensions_cli/templates/gitignore.j2 similarity index 100% rename from superset-extensions-cli/src/superset_extensions_cli/templates/.gitignore.j2 rename to superset-extensions-cli/src/superset_extensions_cli/templates/gitignore.j2 From 186733690761b16d4856e0a7fca09c6f5204b758 Mon Sep 17 00:00:00 2001 From: "Michael S. Molina" <70410625+michael-s-molina@users.noreply.github.com> Date: Fri, 13 Mar 2026 09:06:55 -0300 Subject: [PATCH 36/58] fix(editor): implement missing methods, fix cursor position clearing (#38603) --- .../superset-core/src/editors/index.ts | 43 +++++++++++++++++++ .../superset-core/src/sqlLab/index.ts | 16 +++++++ .../src/core/editors/AceEditorProvider.tsx | 41 ++++++++++++++++-- superset-frontend/src/core/sqlLab/index.ts | 7 +++ 4 files changed, 103 insertions(+), 4 deletions(-) diff --git a/superset-frontend/packages/superset-core/src/editors/index.ts b/superset-frontend/packages/superset-core/src/editors/index.ts index a1ed3ed9653..0fea7c7fe71 100644 --- a/superset-frontend/packages/superset-core/src/editors/index.ts +++ b/superset-frontend/packages/superset-core/src/editors/index.ts @@ -369,6 +369,28 @@ export interface EditorProps { theme?: SupersetTheme; } +/** + * A single text change expressed as an offset-based replacement. + */ +export interface ContentChange { + /** Character offset in the document where the replaced range starts */ + rangeOffset: number; + /** Length in characters of the replaced range (0 for pure insertions) */ + rangeLength: number; + /** Text inserted at rangeOffset (empty string for pure deletions) */ + text: string; +} + +/** + * Payload delivered to `onDidChangeContent` listeners. + */ +export interface ContentChangeEvent { + /** Returns the full current content of the editor */ + getValue(): string; + /** The individual changes that occurred in this event */ + changes: ReadonlyArray; +} + /** * Imperative API for controlling the editor programmatically. * @@ -492,6 +514,27 @@ export interface EditorHandle { * - CodeMirror: editor.requestMeasure() */ resize(): void; + + /** + * Subscribe to content changes in the editor. + * + * The listener receives a {@link ContentChangeEvent} with: + * - `getValue()` — lazy accessor for the full content (call only when needed + * to avoid unnecessary O(n) string allocation on every keystroke) + * - `changes` — the individual edits that occurred, as offset-based replacements + * + * @param listener Called with a ContentChangeEvent on every change + * @param thisArgs Optional `this` context for the listener + * @returns A Disposable that unsubscribes the listener when disposed + * + * @example + * const disposable = editor.onDidChangeContent(e => { + * setStatements(parseStatements(e.getValue())); + * }); + * // Later, to unsubscribe: + * disposable.dispose(); + */ + onDidChangeContent: Event; } /** diff --git a/superset-frontend/packages/superset-core/src/sqlLab/index.ts b/superset-frontend/packages/superset-core/src/sqlLab/index.ts index cae3bc2d4e5..844c25f9ea6 100644 --- a/superset-frontend/packages/superset-core/src/sqlLab/index.ts +++ b/superset-frontend/packages/superset-core/src/sqlLab/index.ts @@ -252,6 +252,22 @@ export interface QueryResult { */ export declare const getActivePanel: () => Panel; +/** + * Switches the active panel in the SQL Lab south pane. + * Built-in panel IDs are 'Results' and 'History'. + * Pinned table panels use the table's ID as their panel ID. + * + * @param panelId The ID of the panel to activate + * @returns Promise that resolves when the panel is activated + * + * @example + * ```typescript + * // Focus the Results panel after running a query + * await setActivePanel('Results'); + * ``` + */ +export declare function setActivePanel(panelId: string): Promise; + /** * Gets the currently active tab in SQL Lab. * diff --git a/superset-frontend/src/core/editors/AceEditorProvider.tsx b/superset-frontend/src/core/editors/AceEditorProvider.tsx index abdf76d14f1..3deb345a217 100644 --- a/superset-frontend/src/core/editors/AceEditorProvider.tsx +++ b/superset-frontend/src/core/editors/AceEditorProvider.tsx @@ -55,6 +55,8 @@ type Range = editors.Range; type Selection = editors.Selection; type EditorAnnotation = editors.EditorAnnotation; type CompletionProvider = editors.CompletionProvider; +type ContentChange = editors.ContentChange; +type ContentChangeEvent = editors.ContentChangeEvent; /** * Maps EditorLanguage to the corresponding Ace editor component. @@ -117,10 +119,14 @@ const createAceEditorHandle = ( }, moveCursorToPosition: (position: Position) => { - aceEditorRef.current?.editor?.moveCursorToPosition({ - row: position.line, - column: position.column, - }); + const editor = aceEditorRef.current?.editor; + if (editor) { + editor.clearSelection(); + editor.moveCursorToPosition({ + row: position.line, + column: position.column, + }); + } }, getSelections: (): Selection[] => { @@ -186,6 +192,33 @@ const createAceEditorHandle = ( resize: () => { aceEditorRef.current?.editor?.resize(); }, + + onDidChangeContent: (listener, thisArgs?) => { + const editor = aceEditorRef.current?.editor; + if (!editor) return new Disposable(() => {}); + const bound = (thisArgs ? listener.bind(thisArgs) : listener) as ( + e: ContentChangeEvent, + ) => void; + const handler = (delta: { + action: 'insert' | 'remove'; + start: { row: number; column: number }; + lines: string[]; + }) => { + const rangeOffset = editor.session.doc.positionToIndex(delta.start); + const changeText = delta.lines.join( + editor.session.doc.getNewLineCharacter(), + ); + const change: ContentChange = + delta.action === 'insert' + ? { rangeOffset, rangeLength: 0, text: changeText } + : { rangeOffset, rangeLength: changeText.length, text: '' }; + bound({ getValue: () => editor.getValue(), changes: [change] }); + }; + editor.session.on('change', handler); + return new Disposable(() => { + editor.session.off('change', handler); + }); + }, }); /** diff --git a/superset-frontend/src/core/sqlLab/index.ts b/superset-frontend/src/core/sqlLab/index.ts index 8e16a41613a..b14be7efd07 100644 --- a/superset-frontend/src/core/sqlLab/index.ts +++ b/superset-frontend/src/core/sqlLab/index.ts @@ -694,6 +694,12 @@ const setSchema: typeof sqlLabApi.setSchema = async (schema: string | null) => { store.dispatch(queryEditorSetSchema(queryEditor ?? null, schema)); }; +const setActivePanel: typeof sqlLabApi.setActivePanel = async ( + panelId: string, +) => { + store.dispatch({ type: SET_ACTIVE_SOUTHPANE_TAB, tabId: panelId }); +}; + export const sqlLab: typeof sqlLabApi = { CTASMethod, getActivePanel, @@ -719,6 +725,7 @@ export const sqlLab: typeof sqlLabApi = { setDatabase, setCatalog, setSchema, + setActivePanel, }; // Export all models From f6106cd26f1b2da27e2157dc0034cf9933ef68bc Mon Sep 17 00:00:00 2001 From: Alexandru Soare <37236580+alexandrusoare@users.noreply.github.com> Date: Fri, 13 Mar 2026 15:24:56 +0200 Subject: [PATCH 37/58] fix(timeshiftcolor): Time shift color to match the original color (#38473) --- .../src/operators/utils/timeOffset.ts | 12 ++- .../test/operators/utils/timeOffset.test.ts | 92 ++++++++++++++++++- .../src/Timeseries/transformProps.ts | 17 +++- 3 files changed, 114 insertions(+), 7 deletions(-) diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/operators/utils/timeOffset.ts b/superset-frontend/packages/superset-ui-chart-controls/src/operators/utils/timeOffset.ts index 852b4eb1ab8..9aae5445bc8 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/src/operators/utils/timeOffset.ts +++ b/superset-frontend/packages/superset-ui-chart-controls/src/operators/utils/timeOffset.ts @@ -28,7 +28,9 @@ export const getTimeOffset = ( // offset is represented as , group by list series.name.includes(`${timeOffset},`) || // offset is represented as __ - series.name.includes(`__${timeOffset}`), + series.name.includes(`__${timeOffset}`) || + // offset is represented as , + series.name.includes(`, ${timeOffset}`), ); export const hasTimeOffset = ( @@ -45,10 +47,14 @@ export const getOriginalSeries = ( ): string => { let result = seriesName; timeCompare.forEach(compare => { - // offset is represented as , group by list + // offset in the middle: , , + result = result.replace(`, ${compare},`, ','); + // offset at start: , result = result.replace(`${compare},`, ''); - // offset is represented as __ + // offset with double underscore: __ result = result.replace(`__${compare}`, ''); + // offset at end: , + result = result.replace(`, ${compare}`, ''); }); return result.trim(); }; diff --git a/superset-frontend/packages/superset-ui-chart-controls/test/operators/utils/timeOffset.test.ts b/superset-frontend/packages/superset-ui-chart-controls/test/operators/utils/timeOffset.test.ts index bee8c5bbbd2..bec42af6c9b 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/test/operators/utils/timeOffset.test.ts +++ b/superset-frontend/packages/superset-ui-chart-controls/test/operators/utils/timeOffset.test.ts @@ -16,15 +16,101 @@ * specific language governing permissions and limitations * under the License. */ -import { getOriginalSeries } from '@superset-ui/chart-controls'; +import { + getOriginalSeries, + getTimeOffset, + hasTimeOffset, +} from '@superset-ui/chart-controls'; -test('returns the series name when time compare is empty', () => { +test('getOriginalSeries returns the series name when time compare is empty', () => { const seriesName = 'sum'; expect(getOriginalSeries(seriesName, [])).toEqual(seriesName); }); -test('returns the original series name', () => { +test('getOriginalSeries returns the original series name with __ pattern', () => { const seriesName = 'sum__1_month_ago'; const timeCompare = ['1_month_ago']; expect(getOriginalSeries(seriesName, timeCompare)).toEqual('sum'); }); + +test('getOriginalSeries returns the original series name with , pattern', () => { + const seriesName = '1 year ago, groupby_value'; + const timeCompare = ['1 year ago']; + expect(getOriginalSeries(seriesName, timeCompare)).toEqual('groupby_value'); +}); + +test('getOriginalSeries returns the original series name with , pattern', () => { + const seriesName = 'AVG(price_each), 1 year ago'; + const timeCompare = ['1 year ago']; + expect(getOriginalSeries(seriesName, timeCompare)).toEqual('AVG(price_each)'); +}); + +test('getOriginalSeries handles multiple time compares', () => { + const seriesName = 'count, 1 year ago'; + const timeCompare = ['1 month ago', '1 year ago']; + expect(getOriginalSeries(seriesName, timeCompare)).toEqual('count'); +}); + +test('getOriginalSeries strips offset in the middle with dimension', () => { + const seriesName = 'SUM(sales), 28 days ago, Medium'; + const timeCompare = ['28 days ago']; + expect(getOriginalSeries(seriesName, timeCompare)).toEqual( + 'SUM(sales), Medium', + ); +}); + +test('getOriginalSeries strips offset in the middle with multiple dimensions', () => { + const seriesName = 'SUM(sales), 1 year ago, Medium, 11'; + const timeCompare = ['1 year ago']; + expect(getOriginalSeries(seriesName, timeCompare)).toEqual( + 'SUM(sales), Medium, 11', + ); +}); + +test('getTimeOffset returns undefined when no time offset pattern matches', () => { + const series = { name: 'count' }; + const timeCompare = ['1 year ago']; + expect(getTimeOffset(series, timeCompare)).toBeUndefined(); +}); + +test('getTimeOffset detects __ pattern', () => { + const series = { name: 'count__1 year ago' }; + const timeCompare = ['1 year ago']; + expect(getTimeOffset(series, timeCompare)).toEqual('1 year ago'); +}); + +test('getTimeOffset detects , pattern', () => { + const series = { name: '1 year ago, groupby_value' }; + const timeCompare = ['1 year ago']; + expect(getTimeOffset(series, timeCompare)).toEqual('1 year ago'); +}); + +test('getTimeOffset detects , pattern', () => { + const series = { name: 'AVG(price_each), 1 year ago' }; + const timeCompare = ['1 year ago']; + expect(getTimeOffset(series, timeCompare)).toEqual('1 year ago'); +}); + +test('getTimeOffset detects , , pattern (offset in middle)', () => { + const series = { name: 'SUM(sales), 28 days ago, Medium' }; + const timeCompare = ['28 days ago']; + expect(getTimeOffset(series, timeCompare)).toEqual('28 days ago'); +}); + +test('hasTimeOffset returns false for original series', () => { + const series = { name: 'count' }; + const timeCompare = ['1 year ago']; + expect(hasTimeOffset(series, timeCompare)).toBe(false); +}); + +test('hasTimeOffset returns true for derived series with , pattern', () => { + const series = { name: 'AVG(price_each), 1 year ago' }; + const timeCompare = ['1 year ago']; + expect(hasTimeOffset(series, timeCompare)).toBe(true); +}); + +test('hasTimeOffset returns false when series name is not a string', () => { + const series = { name: 123 }; + const timeCompare = ['1 year ago']; + expect(hasTimeOffset(series, timeCompare)).toBe(false); +}); diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts index 6eff01ca80b..f7d90cc3f5a 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts @@ -336,6 +336,7 @@ export default function transformProps( chartProps.rawFormData, seriesName, ); + const lineStyle: LineStyleOption = {}; if (derivedSeries) { // Get the time offset for this series to assign different dash patterns @@ -370,7 +371,21 @@ export default function transformProps( let colorScaleKey = getOriginalSeries(seriesName, array); - // If series name exactly matches a time offset (single metric case), + // When there's a single metric with dimensions, the backend replaces the metric + // with the time offset in derived series (e.g., "28 days ago, Medium" instead of + // "SUM(sales), 28 days ago, Medium"). To match colors, strip the metric label + // from original series so both produce the same key (e.g., "Medium"). + if ( + groupby && + groupby.length > 0 && + array.length > 0 && + metrics?.length === 1 + ) { + const metricLabel = getMetricLabel(metrics[0]); + colorScaleKey = colorScaleKey.replace(`${metricLabel}, `, ''); + } + + // If series name exactly matches a time offset (single metric case, no dimensions), // find the original series for color matching if (derivedSeries && array.includes(seriesName)) { const originalSeries = rawSeries.find( From ca2d26a1e2f22ff815f33dc2f6e83096ec9757a0 Mon Sep 17 00:00:00 2001 From: amaannawab923 Date: Fri, 13 Mar 2026 19:42:14 +0530 Subject: [PATCH 38/58] fix(ag-grid-table): fix AND filter conditions not applied (#38369) --- .../src/AgGridTableChart.tsx | 3 + .../src/buildQuery.ts | 80 +++++- .../src/transformProps.ts | 38 ++- .../plugin-chart-ag-grid-table/src/types.ts | 1 + .../test/buildQuery.test.ts | 254 ++++++++++++++++++ 5 files changed, 369 insertions(+), 7 deletions(-) diff --git a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/AgGridTableChart.tsx b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/AgGridTableChart.tsx index b4eae18de41..0673bdd483a 100644 --- a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/AgGridTableChart.tsx +++ b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/AgGridTableChart.tsx @@ -85,6 +85,7 @@ export default function TableChart( width, onChartStateChange, chartState, + metricSqlExpressions, } = props; const [searchOptions, setSearchOptions] = useState([]); @@ -187,6 +188,7 @@ export default function TableChart( lastFilteredColumn: completeFilterState.lastFilteredColumn, lastFilteredInputPosition: completeFilterState.inputPosition, currentPage: 0, // Reset to first page when filtering + metricSqlExpressions, }; updateTableOwnState(setDataMask, modifiedOwnState); @@ -197,6 +199,7 @@ export default function TableChart( serverPaginationData, onChartStateChange, chartState, + metricSqlExpressions, ], ); diff --git a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/buildQuery.ts b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/buildQuery.ts index 8122c86f825..3d04b6c1d1c 100644 --- a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/buildQuery.ts +++ b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/buildQuery.ts @@ -482,12 +482,77 @@ const buildQuery: BuildQuery = ( }; } - // Add AG Grid complex WHERE clause from ownState (non-metric filters) + // Map metric/column labels to SQL expressions for WHERE/HAVING resolution + const sqlExpressionMap: Record = {}; + (metrics || []).forEach((m: QueryFormMetric) => { + if (typeof m === 'object' && 'expressionType' in m) { + const label = getMetricLabel(m); + if (m.expressionType === 'SQL' && m.sqlExpression) { + sqlExpressionMap[label] = m.sqlExpression; + } else if ( + m.expressionType === 'SIMPLE' && + m.aggregate && + m.column?.column_name + ) { + sqlExpressionMap[label] = `${m.aggregate}(${m.column.column_name})`; + } + } + }); + // Map dimension columns with custom SQL expressions + (columns || []).forEach((col: QueryFormColumn) => { + if (typeof col === 'object' && 'sqlExpression' in col) { + const label = getColumnLabel(col); + if (col.sqlExpression) { + sqlExpressionMap[label] = col.sqlExpression; + } + } + }); + // Merge datasource-level saved metrics and calculated columns + if (ownState.metricSqlExpressions) { + Object.entries( + ownState.metricSqlExpressions as Record, + ).forEach(([label, expression]) => { + if (!sqlExpressionMap[label]) { + sqlExpressionMap[label] = expression; + } + }); + } + + const resolveLabelsToSQL = (clause: string): string => { + let resolved = clause; + // Sort by label length descending to prevent substring false positives + const sortedEntries = Object.entries(sqlExpressionMap).sort( + ([a], [b]) => b.length - a.length, + ); + sortedEntries.forEach(([label, expression]) => { + if (resolved.includes(label)) { + const escapedLabel = label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + // Wrap complex expressions in parentheses for valid SQL + const isExpression = + expression.includes('(') || + expression.toUpperCase().includes('CASE') || + expression.includes('\n'); + const wrappedExpression = isExpression + ? `(${expression})` + : expression; + resolved = resolved.replace( + new RegExp(`\\b${escapedLabel}\\b`, 'g'), + wrappedExpression, + ); + } + }); + return resolved; + }; + + // Resolve and apply AG Grid WHERE clause if (ownState.agGridComplexWhere && ownState.agGridComplexWhere.trim()) { + const resolvedWhere = resolveLabelsToSQL(ownState.agGridComplexWhere); + (ownState as Record).agGridComplexWhere = + resolvedWhere; const existingWhere = queryObject.extras?.where; const combinedWhere = existingWhere - ? `${existingWhere} AND ${ownState.agGridComplexWhere}` - : ownState.agGridComplexWhere; + ? `${existingWhere} AND ${resolvedWhere}` + : resolvedWhere; queryObject = { ...queryObject, @@ -498,12 +563,15 @@ const buildQuery: BuildQuery = ( }; } - // Add AG Grid HAVING clause from ownState (metric filters only) + // Resolve and apply AG Grid HAVING clause if (ownState.agGridHavingClause && ownState.agGridHavingClause.trim()) { + const resolvedHaving = resolveLabelsToSQL(ownState.agGridHavingClause); + (ownState as Record).agGridHavingClause = + resolvedHaving; const existingHaving = queryObject.extras?.having; const combinedHaving = existingHaving - ? `${existingHaving} AND ${ownState.agGridHavingClause}` - : ownState.agGridHavingClause; + ? `${existingHaving} AND ${resolvedHaving}` + : resolvedHaving; queryObject = { ...queryObject, diff --git a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/transformProps.ts b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/transformProps.ts index be174cb5a21..c27bd856d89 100644 --- a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/transformProps.ts @@ -34,6 +34,8 @@ import { SMART_DATE_ID, TimeFormats, TimeFormatter, + AgGridChartState, + AgGridFilterModel, } from '@superset-ui/core'; import { GenericDataType } from '@apache-superset/core/common'; import { isEmpty, isEqual, merge } from 'lodash'; @@ -456,6 +458,9 @@ const getPageSize = ( return numRecords * numColumns > 5000 ? 200 : 0; }; +// Tracks slice_ids that have already applied their saved chartState filter on mount +const savedFilterAppliedSet = new Set(); + const transformProps = ( chartProps: TableChartProps, ): AgGridTableChartTransformedProps => { @@ -710,6 +715,36 @@ const transformProps = ( : totalQuery?.data[0] : undefined; + // Map saved metric/calculated column labels to their SQL expressions for filter resolution + const metricSqlExpressions: Record = {}; + chartProps.datasource.metrics.forEach(metric => { + if (metric.metric_name && metric.expression) { + metricSqlExpressions[metric.metric_name] = metric.expression; + } + }); + chartProps.datasource.columns.forEach(col => { + if (col.column_name && col.expression) { + metricSqlExpressions[col.column_name] = col.expression; + if (col.verbose_name && col.verbose_name !== col.column_name) { + metricSqlExpressions[col.verbose_name] = col.expression; + } + } + }); + + // Strip saved filter from chartState after initial application to prevent re-injection + let chartState = serverPaginationData?.chartState as + | AgGridChartState + | undefined; + const chartStateHasFilter = !!( + chartState?.filterModel && Object.keys(chartState.filterModel).length > 0 + ); + + if (chartStateHasFilter && savedFilterAppliedSet.has(slice_id)) { + chartState = { ...chartState!, filterModel: {} as AgGridFilterModel }; + } else if (chartStateHasFilter) { + savedFilterAppliedSet.add(slice_id); + } + return { height, width, @@ -742,7 +777,8 @@ const transformProps = ( basicColorColumnFormatters, basicColorFormatters, formData, - chartState: serverPaginationData?.chartState, + metricSqlExpressions, + chartState, onChartStateChange, }; }; diff --git a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/types.ts b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/types.ts index d97f36b267f..e3d6bf07079 100644 --- a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/types.ts +++ b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/types.ts @@ -128,6 +128,7 @@ export interface AgGridTableChartTransformedProps< basicColorFormatters?: { [Key: string]: BasicColorFormatterType }[]; basicColorColumnFormatters?: { [Key: string]: BasicColorFormatterType }[]; formData: TableChartFormData; + metricSqlExpressions: Record; onChartStateChange?: (chartState: JsonObject) => void; chartState?: AgGridChartState; } diff --git a/superset-frontend/plugins/plugin-chart-ag-grid-table/test/buildQuery.test.ts b/superset-frontend/plugins/plugin-chart-ag-grid-table/test/buildQuery.test.ts index 0778d6fde58..636fc190fcc 100644 --- a/superset-frontend/plugins/plugin-chart-ag-grid-table/test/buildQuery.test.ts +++ b/superset-frontend/plugins/plugin-chart-ag-grid-table/test/buildQuery.test.ts @@ -1090,4 +1090,258 @@ describe('plugin-chart-ag-grid-table', () => { expect(query.metrics).toEqual([]); }); }); + + describe('buildQuery - label-to-SQL resolution in WHERE/HAVING', () => { + test('should resolve inline SQL metric labels in WHERE clause', () => { + const query = buildQuery( + { + ...basicFormData, + server_pagination: true, + metrics: [ + { + expressionType: 'SQL', + sqlExpression: 'SUM(revenue)', + label: 'Total Revenue', + }, + ], + }, + { + ownState: { + agGridComplexWhere: 'Total Revenue > 1000', + }, + }, + ).queries[0]; + + expect(query.extras?.where).toBe('(SUM(revenue)) > 1000'); + }); + + test('should resolve SIMPLE metric labels in HAVING clause', () => { + const query = buildQuery( + { + ...basicFormData, + server_pagination: true, + metrics: [ + { + expressionType: 'SIMPLE', + aggregate: 'SUM', + column: { column_name: 'revenue' }, + label: 'Total Revenue', + }, + ], + }, + { + ownState: { + agGridHavingClause: 'Total Revenue > 1000', + }, + }, + ).queries[0]; + + expect(query.extras?.having).toBe('(SUM(revenue)) > 1000'); + }); + + test('should resolve adhoc column SQL expressions in WHERE clause', () => { + const adhocColumn = createAdhocColumn('UPPER(city)', 'City Upper'); + + const query = buildQuery( + { + ...basicFormData, + server_pagination: true, + groupby: [adhocColumn], + }, + { + ownState: { + agGridComplexWhere: "City Upper = 'NEW YORK'", + }, + }, + ).queries[0]; + + expect(query.extras?.where).toContain('UPPER(city)'); + }); + + test('should wrap CASE expressions in parentheses', () => { + const query = buildQuery( + { + ...basicFormData, + server_pagination: true, + }, + { + ownState: { + agGridComplexWhere: "degree_level = 'High'", + metricSqlExpressions: { + degree_level: + "CASE WHEN degree = 'PhD' THEN 'High' ELSE 'Low' END", + }, + }, + }, + ).queries[0]; + + expect(query.extras?.where).toBe( + "(CASE WHEN degree = 'PhD' THEN 'High' ELSE 'Low' END) = 'High'", + ); + }); + + test('should wrap aggregate expressions in parentheses', () => { + const query = buildQuery( + { + ...basicFormData, + server_pagination: true, + }, + { + ownState: { + agGridHavingClause: 'total_count > 100', + metricSqlExpressions: { + total_count: 'COUNT(*)', + }, + }, + }, + ).queries[0]; + + expect(query.extras?.having).toBe('(COUNT(*)) > 100'); + }); + + test('should quote simple column names without parentheses', () => { + const query = buildQuery( + { + ...basicFormData, + server_pagination: true, + }, + { + ownState: { + agGridComplexWhere: "status = 'active'", + metricSqlExpressions: { + status: 'user_status', + }, + }, + }, + ).queries[0]; + + expect(query.extras?.where).toBe('"user_status" = \'active\''); + }); + + test('should resolve longer labels before shorter ones', () => { + const query = buildQuery( + { + ...basicFormData, + server_pagination: true, + metrics: [ + { + expressionType: 'SQL', + sqlExpression: 'COUNT(*)', + label: 'count', + }, + { + expressionType: 'SQL', + sqlExpression: 'COUNT(DISTINCT id)', + label: 'count_distinct', + }, + ], + }, + { + ownState: { + agGridHavingClause: 'count_distinct > 5 AND count > 10', + }, + }, + ).queries[0]; + + expect(query.extras?.having).toContain('(COUNT(DISTINCT id)) > 5'); + expect(query.extras?.having).toContain('(COUNT(*)) > 10'); + }); + + test('should prefer query-level expressions over datasource-level', () => { + const query = buildQuery( + { + ...basicFormData, + server_pagination: true, + metrics: [ + { + expressionType: 'SQL', + sqlExpression: 'SUM(amount)', + label: 'total', + }, + ], + }, + { + ownState: { + agGridHavingClause: 'total > 500', + metricSqlExpressions: { + total: 'SUM(old_amount)', + }, + }, + }, + ).queries[0]; + + // Query-level SUM(amount) should win over datasource-level SUM(old_amount) + expect(query.extras?.having).toBe('(SUM(amount)) > 500'); + }); + + test('should not modify clause when no labels match', () => { + const query = buildQuery( + { + ...basicFormData, + server_pagination: true, + }, + { + ownState: { + agGridComplexWhere: 'physical_column > 10', + }, + }, + ).queries[0]; + + expect(query.extras?.where).toBe('physical_column > 10'); + }); + + test('should resolve labels in both WHERE and HAVING simultaneously', () => { + const query = buildQuery( + { + ...basicFormData, + server_pagination: true, + metrics: [ + { + expressionType: 'SQL', + sqlExpression: 'SUM(sales)', + label: 'Total Sales', + }, + ], + }, + { + ownState: { + agGridComplexWhere: "region = 'West'", + agGridHavingClause: 'Total Sales > 1000', + metricSqlExpressions: { + region: "CASE WHEN area = 'W' THEN 'West' ELSE 'East' END", + }, + }, + }, + ).queries[0]; + + expect(query.extras?.where).toContain('CASE WHEN'); + expect(query.extras?.having).toBe('(SUM(sales)) > 1000'); + }); + + test('should not resolve labels when server pagination is disabled', () => { + const query = buildQuery( + { + ...basicFormData, + server_pagination: false, + metrics: [ + { + expressionType: 'SQL', + sqlExpression: 'SUM(revenue)', + label: 'Total Revenue', + }, + ], + }, + { + ownState: { + agGridComplexWhere: 'Total Revenue > 1000', + metricSqlExpressions: { + some_col: 'COUNT(*)', + }, + }, + }, + ).queries[0]; + + expect(query.extras?.where || undefined).toBeUndefined(); + }); + }); }); From ba7271b4d89c6c8837cca39ced93b1bd93b6cc92 Mon Sep 17 00:00:00 2001 From: Rafael Benitez Date: Fri, 13 Mar 2026 12:02:03 -0300 Subject: [PATCH 39/58] fix(world-map): add fallback fill color when colorFn returns null (#38602) Co-authored-by: Claude Opus 4.6 --- .../src/WorldMap.ts | 14 +++-- .../test/WorldMap.test.ts | 53 +++++++++++++++++++ 2 files changed, 63 insertions(+), 4 deletions(-) diff --git a/superset-frontend/plugins/legacy-plugin-chart-world-map/src/WorldMap.ts b/superset-frontend/plugins/legacy-plugin-chart-world-map/src/WorldMap.ts index e6c7bc2679d..9388c379dd1 100644 --- a/superset-frontend/plugins/legacy-plugin-chart-world-map/src/WorldMap.ts +++ b/superset-frontend/plugins/legacy-plugin-chart-world-map/src/WorldMap.ts @@ -150,14 +150,20 @@ function WorldMap(element: HTMLElement, props: WorldMapProps): void { fillColor: colorFn(d.name, sliceId), })); } else { - colorFn = getSequentialSchemeRegistry() - .get(linearColorScheme) - .createLinearScale(d3Extent(filteredData, d => d.m1)); + const rawExtents = d3Extent(filteredData, d => d.m1); + const extents: [number, number] = + rawExtents[0] != null && rawExtents[1] != null + ? [rawExtents[0], rawExtents[1]] + : [0, 1]; + const colorSchemeObj = getSequentialSchemeRegistry().get(linearColorScheme); + colorFn = colorSchemeObj + ? colorSchemeObj.createLinearScale(extents) + : () => theme.colorBorder; processedData = filteredData.map(d => ({ ...d, radius: radiusScale(Math.sqrt(d.m2)), - fillColor: colorFn(d.m1), + fillColor: colorFn(d.m1) ?? theme.colorBorder, })); } diff --git a/superset-frontend/plugins/legacy-plugin-chart-world-map/test/WorldMap.test.ts b/superset-frontend/plugins/legacy-plugin-chart-world-map/test/WorldMap.test.ts index 5a53aab9e82..4476bcefe85 100644 --- a/superset-frontend/plugins/legacy-plugin-chart-world-map/test/WorldMap.test.ts +++ b/superset-frontend/plugins/legacy-plugin-chart-world-map/test/WorldMap.test.ts @@ -489,6 +489,59 @@ test('popupTemplate returns tooltip HTML when country data exists', () => { expect(tooltipHtml).toContain('hoverinfo'); }); +test('assigns fill colors from sequential scheme when colorBy is metric', () => { + WorldMap(container, { + ...baseProps, + colorBy: ColorBy.Metric, + }); + + const data = lastDatamapConfig?.data as Record< + string, + WorldMapDataEntry & { fillColor: string } + >; + expect(data).toHaveProperty('USA'); + expect(data).toHaveProperty('CAN'); + expect(data.USA).toMatchObject({ + country: 'USA', + name: 'United States', + m1: 100, + }); + // fillColor should be a valid color string from the sequential scale + expect(data.USA.fillColor).toMatch(/^(#|rgb)/); + expect(data.CAN.fillColor).toMatch(/^(#|rgb)/); +}); + +test('falls back to theme.colorBorder when metric values are null', () => { + WorldMap(container, { + ...baseProps, + colorBy: ColorBy.Metric, + data: [ + { + country: 'USA', + name: 'United States', + m1: null as unknown as number, + m2: 200, + code: 'US', + latitude: 37.0902, + longitude: -95.7129, + }, + ], + } as any); + + const data = lastDatamapConfig?.data as Record; + expect(data.USA.fillColor).toBe('#e0e0e0'); +}); + +test('does not throw with empty data and metric coloring', () => { + expect(() => { + WorldMap(container, { + ...baseProps, + colorBy: ColorBy.Metric, + data: [], + }); + }).not.toThrow(); +}); + test('popupTemplate handles null/undefined country data gracefully', () => { WorldMap(container, baseProps); From ba7d7dcec0d095896aaa1c2509664e573150272a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 13 Mar 2026 22:06:21 +0700 Subject: [PATCH 40/58] chore(deps): bump react-syntax-highlighter from 16.1.0 to 16.1.1 in /superset-frontend (#38619) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- superset-frontend/package-lock.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index cced9035249..061e545651f 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -51269,7 +51269,7 @@ "react-js-cron": "^5.2.0", "react-markdown": "^8.0.7", "react-resize-detector": "^7.1.2", - "react-syntax-highlighter": "^16.1.1", + "react-syntax-highlighter": "^16.1.0", "react-ultimate-pagination": "^1.3.2", "regenerator-runtime": "^0.14.1", "rehype-raw": "^7.0.0", From 242636b36b1b78272161e25879d8eff186f2db47 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 13 Mar 2026 22:06:43 +0700 Subject: [PATCH 41/58] chore(deps): bump baseline-browser-mapping from 2.10.0 to 2.10.7 in /docs (#38622) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- docs/package.json | 2 +- docs/yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/package.json b/docs/package.json index 342ab607ed7..50eff6532f5 100644 --- a/docs/package.json +++ b/docs/package.json @@ -69,7 +69,7 @@ "@superset-ui/core": "^0.20.4", "@swc/core": "^1.15.17", "antd": "^6.3.2", - "baseline-browser-mapping": "^2.10.0", + "baseline-browser-mapping": "^2.10.7", "caniuse-lite": "^1.0.30001778", "docusaurus-plugin-openapi-docs": "^4.6.0", "docusaurus-theme-openapi-docs": "^4.6.0", diff --git a/docs/yarn.lock b/docs/yarn.lock index 8e90a7b93f4..edb44272dd2 100644 --- a/docs/yarn.lock +++ b/docs/yarn.lock @@ -5961,10 +5961,10 @@ base64-js@^1.3.1, base64-js@^1.5.1: resolved "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz" integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== -baseline-browser-mapping@^2.10.0, baseline-browser-mapping@^2.9.0, baseline-browser-mapping@^2.9.19: - version "2.10.0" - resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz#5b09935025bf8a80e29130251e337c6a7fc8cbb9" - integrity sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA== +baseline-browser-mapping@^2.10.7, baseline-browser-mapping@^2.9.0, baseline-browser-mapping@^2.9.19: + version "2.10.7" + resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.7.tgz#2c017adffe4f7bbe93c2e55526cc1869d36f588c" + integrity sha512-1ghYO3HnxGec0TCGBXiDLVns4eCSx4zJpxnHrlqFQajmhfKMQBzUGDdkMK7fUW7PTHTeLf+j87aTuKuuwWzMGw== batch@0.6.1: version "0.6.1" From f4a57a13bc9a36cdc46326c42c5b618fa9f2db0d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 13 Mar 2026 22:07:09 +0700 Subject: [PATCH 42/58] chore(deps): bump dompurify from 3.3.2 to 3.3.3 in /superset-frontend (#38592) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Đỗ Trọng Hải <41283691+hainenber@users.noreply.github.com> --- superset-frontend/package-lock.json | 22 +++++++------------ .../packages/superset-ui-core/package.json | 2 +- .../legacy-preset-chart-nvd3/package.json | 2 +- 3 files changed, 10 insertions(+), 16 deletions(-) diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index 061e545651f..869e3f07d4d 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -51255,7 +51255,7 @@ "d3-time": "^3.1.0", "d3-time-format": "^4.1.0", "dayjs": "^1.11.19", - "dompurify": "^3.3.2", + "dompurify": "^3.3.3", "fetch-retry": "^6.0.0", "handlebars": "^4.7.8", "jed": "^1.1.1", @@ -51358,13 +51358,10 @@ } }, "packages/superset-ui-core/node_modules/dompurify": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.2.tgz", - "integrity": "sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz", + "integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==", "license": "(MPL-2.0 OR Apache-2.0)", - "engines": { - "node": ">=20" - }, "optionalDependencies": { "@types/trusted-types": "^2.0.7" } @@ -52625,7 +52622,7 @@ "dependencies": { "d3": "^3.5.17", "d3-tip": "^0.9.1", - "dompurify": "^3.3.2", + "dompurify": "^3.3.3", "fast-safe-stringify": "^2.1.1", "lodash": "^4.17.23", "nvd3-fork": "^2.0.5", @@ -52641,13 +52638,10 @@ } }, "plugins/legacy-preset-chart-nvd3/node_modules/dompurify": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.2.tgz", - "integrity": "sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz", + "integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==", "license": "(MPL-2.0 OR Apache-2.0)", - "engines": { - "node": ">=20" - }, "optionalDependencies": { "@types/trusted-types": "^2.0.7" } diff --git a/superset-frontend/packages/superset-ui-core/package.json b/superset-frontend/packages/superset-ui-core/package.json index 74bf4ad3807..3d4855ca3d0 100644 --- a/superset-frontend/packages/superset-ui-core/package.json +++ b/superset-frontend/packages/superset-ui-core/package.json @@ -41,7 +41,7 @@ "d3-scale": "^4.0.2", "d3-time": "^3.1.0", "d3-time-format": "^4.1.0", - "dompurify": "^3.3.2", + "dompurify": "^3.3.3", "fetch-retry": "^6.0.0", "handlebars": "^4.7.8", "jed": "^1.1.1", diff --git a/superset-frontend/plugins/legacy-preset-chart-nvd3/package.json b/superset-frontend/plugins/legacy-preset-chart-nvd3/package.json index c9bcd0a4d41..aa4526ebb7a 100644 --- a/superset-frontend/plugins/legacy-preset-chart-nvd3/package.json +++ b/superset-frontend/plugins/legacy-preset-chart-nvd3/package.json @@ -34,7 +34,7 @@ "fast-safe-stringify": "^2.1.1", "lodash": "^4.17.23", "nvd3-fork": "^2.0.5", - "dompurify": "^3.3.2", + "dompurify": "^3.3.3", "prop-types": "^15.8.1", "urijs": "^1.19.11" }, From b6c3b3ef46d9386f8e318458a243695da95ed3e4 Mon Sep 17 00:00:00 2001 From: Amin Ghadersohi Date: Fri, 13 Mar 2026 16:53:52 +0100 Subject: [PATCH 43/58] fix(mcp): return all statement results for multi-statement SQL queries (#38388) Co-authored-by: Claude Opus 4.6 --- superset/mcp_service/sql_lab/schemas.py | 26 ++++ .../mcp_service/sql_lab/tool/execute_sql.py | 87 ++++++++------ .../sql_lab/tool/test_execute_sql.py | 111 ++++++++++++++++++ 3 files changed, 188 insertions(+), 36 deletions(-) diff --git a/superset/mcp_service/sql_lab/schemas.py b/superset/mcp_service/sql_lab/schemas.py index 436e9330733..e55ca53130f 100644 --- a/superset/mcp_service/sql_lab/schemas.py +++ b/superset/mcp_service/sql_lab/schemas.py @@ -84,6 +84,15 @@ class ColumnInfo(BaseModel): is_nullable: bool | None = Field(None, description="Whether column allows NULL") +class StatementData(BaseModel): + """Row data and column metadata for a single SQL statement.""" + + rows: list[dict[str, Any]] = Field( + ..., description="Result rows as list of dictionaries" + ) + columns: list[ColumnInfo] = Field(..., description="Column metadata information") + + class StatementInfo(BaseModel): """Information about a single SQL statement execution.""" @@ -95,6 +104,14 @@ class StatementInfo(BaseModel): execution_time_ms: float | None = Field( None, description="Statement execution time in milliseconds" ) + data: StatementData | None = Field( + None, + description=( + "Row data and column metadata for this statement. " + "Present for data-bearing statements (e.g., SELECT), " + "absent for DML/DDL statements (e.g., SET, UPDATE)." + ), + ) class ExecuteSqlResponse(BaseModel): @@ -119,6 +136,15 @@ class ExecuteSqlResponse(BaseModel): statements: list[StatementInfo] | None = Field( None, description="Per-statement execution info (for multi-statement queries)" ) + multi_statement_warning: str | None = Field( + None, + description=( + "Warning when multiple data-bearing statements were executed. " + "The top-level rows/columns contain only the last " + "data-bearing statement's results. " + "Check each entry in the statements array for per-statement data." + ), + ) class OpenSqlLabRequest(BaseModel): diff --git a/superset/mcp_service/sql_lab/tool/execute_sql.py b/superset/mcp_service/sql_lab/tool/execute_sql.py index 42739ff75fa..7f3164d8852 100644 --- a/superset/mcp_service/sql_lab/tool/execute_sql.py +++ b/superset/mcp_service/sql_lab/tool/execute_sql.py @@ -43,6 +43,7 @@ from superset.mcp_service.sql_lab.schemas import ( ColumnInfo, ExecuteSqlRequest, ExecuteSqlResponse, + StatementData, StatementInfo, ) from superset.mcp_service.utils.schema_utils import parse_request @@ -162,56 +163,69 @@ def _convert_to_response(result: QueryResult) -> ExecuteSqlResponse: error_type=result.status.value, ) - # Build statement info list - statements = [ - StatementInfo( - original_sql=stmt.original_sql, - executed_sql=stmt.executed_sql, - row_count=stmt.row_count, - execution_time_ms=stmt.execution_time_ms, - ) - for stmt in result.statements - ] + # Build statement info list, including per-statement row data + # for data-bearing statements (e.g., SELECT). + statements: list[StatementInfo] = [] + data_bearing_count = 0 - # Find the last statement with data (SELECT results). - # For single statements this is the same as first. - # For multi-statement queries (e.g., SET ...; SELECT ...) this skips - # non-data statements and returns the actual query results. + for stmt in result.statements: + stmt_data: StatementData | None = None + if stmt.data is not None: + df = stmt.data + stmt_data = StatementData( + rows=df.to_dict(orient="records"), + columns=[ + ColumnInfo(name=col, type=str(df[col].dtype)) for col in df.columns + ], + ) + data_bearing_count += 1 + + statements.append( + StatementInfo( + original_sql=stmt.original_sql, + executed_sql=stmt.executed_sql, + row_count=stmt.row_count, + execution_time_ms=stmt.execution_time_ms, + data=stmt_data, + ) + ) + + # Top-level rows/columns come from the last data-bearing statement + # for backward compatibility. rows: list[dict[str, Any]] | None = None columns: list[ColumnInfo] | None = None row_count: int | None = None affected_rows: int | None = None - data_stmt = None - for stmt in reversed(result.statements): + last_data_stmt = None + for stmt in reversed(statements): if stmt.data is not None: - data_stmt = stmt + last_data_stmt = stmt break - if data_stmt is not None and data_stmt.data is not None: - # SELECT query - convert DataFrame - import pandas as pd - - df = data_stmt.data - if not isinstance(df, pd.DataFrame): - logger.error( - "Expected DataFrame but got %s for statement data", - type(df).__name__, - ) - return ExecuteSqlResponse( - success=False, - error=f"Internal error: unexpected data type ({type(df).__name__})", - error_type="data_conversion_error", - statements=statements, - ) - rows = df.to_dict(orient="records") - columns = [ColumnInfo(name=col, type=str(df[col].dtype)) for col in df.columns] - row_count = len(df) + if last_data_stmt is not None and last_data_stmt.data is not None: + rows = last_data_stmt.data.rows + columns = last_data_stmt.data.columns + row_count = len(last_data_stmt.data.rows) elif result.statements: # DML-only query last_stmt = result.statements[-1] affected_rows = last_stmt.row_count + # Warn when multiple data-bearing statements exist so the LLM + # knows to inspect the statements array for all results. + multi_statement_warning: str | None = None + if data_bearing_count > 1: + multi_statement_warning = ( + f"This query contained {data_bearing_count} " + "data-bearing statements. " + "The top-level rows/columns contain only the " + "last data-bearing statement's results. " + "Check the 'data' field in each entry of the " + "'statements' array to see results from ALL " + "statements." + ) + return ExecuteSqlResponse( success=True, rows=rows, @@ -224,4 +238,5 @@ def _convert_to_response(result: QueryResult) -> ExecuteSqlResponse: else None ), statements=statements, + multi_statement_warning=multi_statement_warning, ) diff --git a/tests/unit_tests/mcp_service/sql_lab/tool/test_execute_sql.py b/tests/unit_tests/mcp_service/sql_lab/tool/test_execute_sql.py index 3576acd3d37..764e1bf2cee 100644 --- a/tests/unit_tests/mcp_service/sql_lab/tool/test_execute_sql.py +++ b/tests/unit_tests/mcp_service/sql_lab/tool/test_execute_sql.py @@ -530,6 +530,21 @@ class TestExecuteSql: assert data["rows"] == [{"b": 2}] assert data["row_count"] == 1 + # Per-statement data should be present for both statements + assert data["statements"][0]["data"] is not None + assert data["statements"][0]["data"]["rows"] == [{"a": 1}] + assert len(data["statements"][0]["data"]["columns"]) == 1 + assert data["statements"][0]["data"]["columns"][0]["name"] == "a" + + assert data["statements"][1]["data"] is not None + assert data["statements"][1]["data"]["rows"] == [{"b": 2}] + assert len(data["statements"][1]["data"]["columns"]) == 1 + assert data["statements"][1]["data"]["columns"][0]["name"] == "b" + + # Warning should be present for multi-data-bearing queries + assert data["multi_statement_warning"] is not None + assert "2 data-bearing statements" in data["multi_statement_warning"] + @patch("superset.security_manager") @patch("superset.db") @pytest.mark.asyncio @@ -609,6 +624,14 @@ class TestExecuteSql: assert "id" in column_names assert "amount" in column_names + # SET statement should have no data, SELECT should have data + assert data["statements"][0]["data"] is None + assert data["statements"][1]["data"] is not None + assert len(data["statements"][1]["data"]["rows"]) == 2 + + # No warning since only one data-bearing statement + assert data["multi_statement_warning"] is None + @patch("superset.security_manager") @patch("superset.db") @pytest.mark.asyncio @@ -664,6 +687,94 @@ class TestExecuteSql: # affected_rows should come from the last statement assert data["affected_rows"] == 5 + # DML statements should have no per-statement data + assert data["statements"][0]["data"] is None + assert data["statements"][1]["data"] is None + + # No warning for DML-only queries + assert data["multi_statement_warning"] is None + + @patch("superset.security_manager") + @patch("superset.db") + @pytest.mark.asyncio + async def test_execute_sql_multi_statement_preserves_all_data( + self, mock_db, mock_security_manager, mcp_server + ) -> None: + """Test that multi-statement SQL returns per-statement data for ALL results. + + Regression test: previously, running two SELECT statements would only + return the last statement's rows in the top-level response and + completely lose the first statement's row data. + """ + mock_database = _mock_database() + mock_database.execute.return_value = QueryResult( + status=QueryStatus.SUCCESS, + statements=[ + StatementResult( + original_sql="SELECT COUNT(*) AS order_count FROM orders", + executed_sql="SELECT COUNT(*) AS order_count FROM orders", + data=pd.DataFrame([{"order_count": 42}]), + row_count=1, + execution_time_ms=5.0, + ), + StatementResult( + original_sql="SELECT SUM(revenue) AS total_revenue FROM orders", + executed_sql="SELECT SUM(revenue) AS total_revenue FROM orders", + data=pd.DataFrame([{"total_revenue": 12345.67}]), + row_count=1, + execution_time_ms=7.0, + ), + ], + query_id=None, + total_execution_time_ms=12.0, + is_cached=False, + ) + mock_db.session.query.return_value.filter_by.return_value.first.return_value = ( + mock_database + ) + mock_security_manager.can_access_database.return_value = True + + request = { + "database_id": 1, + "sql": ( + "SELECT COUNT(*) AS order_count FROM orders;" + " SELECT SUM(revenue) AS total_revenue FROM orders" + ), + "limit": 100, + } + + async with Client(mcp_server) as client: + result = await client.call_tool("execute_sql", {"request": request}) + + data = result.structured_content + assert data["success"] is True + + # Top-level rows/columns should be from the LAST data-bearing stmt + assert data["rows"] == [{"total_revenue": 12345.67}] + assert data["row_count"] == 1 + + # Both statements should have per-statement data + assert len(data["statements"]) == 2 + + # First statement's data is accessible + first_stmt = data["statements"][0] + assert first_stmt["data"] is not None + assert first_stmt["data"]["rows"] == [{"order_count": 42}] + assert len(first_stmt["data"]["columns"]) == 1 + assert first_stmt["data"]["columns"][0]["name"] == "order_count" + + # Second statement's data is accessible + second_stmt = data["statements"][1] + assert second_stmt["data"] is not None + assert second_stmt["data"]["rows"] == [{"total_revenue": 12345.67}] + assert len(second_stmt["data"]["columns"]) == 1 + assert second_stmt["data"]["columns"][0]["name"] == "total_revenue" + + # Warning should tell LLM to check statements array + assert data["multi_statement_warning"] is not None + assert "2 data-bearing statements" in data["multi_statement_warning"] + assert "statements" in data["multi_statement_warning"] + @pytest.mark.asyncio async def test_execute_sql_empty_query_validation(self, mcp_server): """Test validation of empty SQL query.""" From 97a66f7a6474f79be36e4cfd16b6f6df938c1e5b Mon Sep 17 00:00:00 2001 From: Amin Ghadersohi Date: Fri, 13 Mar 2026 18:06:11 +0100 Subject: [PATCH 44/58] feat(mcp): add BM25 tool search transform to reduce initial context size (#38562) Co-authored-by: Claude Opus 4.6 --- pyproject.toml | 2 +- requirements/development.txt | 61 ++-- superset/mcp_service/chart/schemas.py | 323 +++++------------- superset/mcp_service/common/cache_schemas.py | 35 +- superset/mcp_service/dashboard/schemas.py | 64 ++-- superset/mcp_service/flask_singleton.py | 70 ++-- superset/mcp_service/mcp_config.py | 34 ++ superset/mcp_service/server.py | 141 +++++++- superset/mcp_service/system/schemas.py | 100 ++---- .../mcp_service/test_mcp_tool_registration.py | 25 +- .../mcp_service/test_tool_search_transform.py | 173 ++++++++++ 11 files changed, 552 insertions(+), 476 deletions(-) create mode 100644 tests/unit_tests/mcp_service/test_tool_search_transform.py diff --git a/pyproject.toml b/pyproject.toml index 70feeeb4d3e..12d5224cb38 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -144,7 +144,7 @@ solr = ["sqlalchemy-solr >= 0.2.0"] elasticsearch = ["elasticsearch-dbapi>=0.2.12, <0.3.0"] exasol = ["sqlalchemy-exasol >= 2.4.0, <3.0"] excel = ["xlrd>=1.2.0, <1.3"] -fastmcp = ["fastmcp==2.14.3"] +fastmcp = ["fastmcp>=3.1.0,<4.0"] firebird = ["sqlalchemy-firebird>=0.7.0, <0.8"] firebolt = ["firebolt-sqlalchemy>=1.0.0, <2"] gevent = ["gevent>=23.9.1"] diff --git a/requirements/development.txt b/requirements/development.txt index 993bbfdc3b4..296fe15d820 100644 --- a/requirements/development.txt +++ b/requirements/development.txt @@ -10,6 +10,8 @@ # via # -r requirements/development.in # apache-superset +aiofile==3.9.0 + # via py-key-value-aio alembic==1.15.2 # via # -c requirements/base-constraint.txt @@ -26,8 +28,10 @@ anyio==4.11.0 # via # httpx # mcp + # py-key-value-aio # sse-starlette # starlette + # watchfiles apispec==6.6.1 # via # -c requirements/base-constraint.txt @@ -65,9 +69,7 @@ bcrypt==4.3.0 # -c requirements/base-constraint.txt # paramiko beartype==0.22.5 - # via - # py-key-value-aio - # py-key-value-shared + # via py-key-value-aio billiard==4.2.1 # via # -c requirements/base-constraint.txt @@ -100,6 +102,8 @@ cachetools==6.2.1 # -c requirements/base-constraint.txt # google-auth # py-key-value-aio +caio==0.9.25 + # via aiofile cattrs==25.1.1 # via # -c requirements/base-constraint.txt @@ -138,7 +142,6 @@ click==8.2.1 # click-repl # flask # flask-appbuilder - # typer # uvicorn click-didyoumean==0.3.1 # via @@ -156,8 +159,6 @@ click-repl==0.3.0 # via # -c requirements/base-constraint.txt # celery -cloudpickle==3.1.2 - # via pydocket cmdstanpy==1.1.0 # via prophet colorama==0.4.6 @@ -206,8 +207,6 @@ deprecation==2.1.0 # apache-superset dill==0.4.0 # via pylint -diskcache==5.6.3 - # via py-key-value-aio distlib==0.3.8 # via virtualenv dnspython==2.7.0 @@ -237,9 +236,7 @@ et-xmlfile==2.0.0 # openpyxl exceptiongroup==1.3.0 # via fastmcp -fakeredis==2.32.1 - # via pydocket -fastmcp==2.14.3 +fastmcp==3.1.0 # via apache-superset filelock==3.20.3 # via @@ -474,6 +471,8 @@ jsonpath-ng==1.7.0 # via # -c requirements/base-constraint.txt # apache-superset +jsonref==1.1.0 + # via fastmcp jsonschema==4.23.0 # via # -c requirements/base-constraint.txt @@ -504,8 +503,6 @@ limits==5.1.0 # via # -c requirements/base-constraint.txt # flask-limiter -lupa==2.6 - # via fakeredis mako==1.3.10 # via # -c requirements/base-constraint.txt @@ -603,7 +600,7 @@ openpyxl==3.1.5 # -c requirements/base-constraint.txt # pandas opentelemetry-api==1.39.1 - # via pydocket + # via fastmcp ordered-set==4.1.0 # via # -c requirements/base-constraint.txt @@ -622,6 +619,7 @@ packaging==25.0 # deprecation # docker # duckdb-engine + # fastmcp # google-cloud-bigquery # gunicorn # limits @@ -653,8 +651,6 @@ parsedatetime==2.6 # apache-superset pathable==0.4.3 # via jsonschema-path -pathvalidate==3.3.1 - # via py-key-value-aio pgsanity==0.2.9 # via # -c requirements/base-constraint.txt @@ -691,8 +687,6 @@ prison==0.2.1 # flask-appbuilder progress==1.6 # via apache-superset -prometheus-client==0.23.1 - # via pydocket prompt-toolkit==3.0.51 # via # -c requirements/base-constraint.txt @@ -714,12 +708,8 @@ psutil==6.1.0 # via apache-superset psycopg2-binary==2.9.9 # via apache-superset -py-key-value-aio==0.3.0 - # via - # fastmcp - # pydocket -py-key-value-shared==0.3.0 - # via py-key-value-aio +py-key-value-aio==0.4.4 + # via fastmcp pyarrow==16.1.0 # via # -c requirements/base-constraint.txt @@ -758,8 +748,6 @@ pydantic-settings==2.10.1 # via mcp pydata-google-auth==1.9.0 # via pandas-gbq -pydocket==0.17.1 - # via fastmcp pydruid==0.6.9 # via apache-superset pyfakefs==5.3.5 @@ -844,8 +832,6 @@ python-dotenv==1.1.0 # apache-superset # fastmcp # pydantic-settings -python-json-logger==4.0.0 - # via pydocket python-ldap==3.4.4 # via apache-superset python-multipart==0.0.20 @@ -866,15 +852,13 @@ pyyaml==6.0.2 # -c requirements/base-constraint.txt # apache-superset # apispec + # fastmcp # jsonschema-path # pre-commit redis==5.3.1 # via # -c requirements/base-constraint.txt # apache-superset - # fakeredis - # py-key-value-aio - # pydocket referencing==0.36.2 # via # -c requirements/base-constraint.txt @@ -910,9 +894,7 @@ rich==13.9.4 # cyclopts # fastmcp # flask-limiter - # pydocket # rich-rst - # typer rich-rst==1.3.1 # via cyclopts rpds-py==0.25.0 @@ -944,8 +926,6 @@ setuptools==80.9.0 # pydata-google-auth # zope-event # zope-interface -shellingham==1.5.4 - # via typer shillelagh==1.4.3 # via # -c requirements/base-constraint.txt @@ -973,7 +953,6 @@ sniffio==1.3.1 sortedcontainers==2.4.0 # via # -c requirements/base-constraint.txt - # fakeredis # trio sqlalchemy==1.4.54 # via @@ -1034,8 +1013,6 @@ trio-websocket==0.12.2 # via # -c requirements/base-constraint.txt # selenium -typer==0.20.0 - # via pydocket typing-extensions==4.15.0 # via # -c requirements/base-constraint.txt @@ -1048,16 +1025,14 @@ typing-extensions==4.15.0 # limits # mcp # opentelemetry-api - # py-key-value-shared + # py-key-value-aio # pydantic # pydantic-core - # pydocket # pyopenssl # referencing # selenium # shillelagh # starlette - # typer # typing-inspection typing-inspection==0.4.1 # via @@ -1072,6 +1047,8 @@ tzdata==2025.2 # pandas tzlocal==5.2 # via trino +uncalled-for==0.2.0 + # via fastmcp url-normalize==2.2.1 # via # -c requirements/base-constraint.txt @@ -1101,6 +1078,8 @@ watchdog==6.0.0 # -c requirements/base-constraint.txt # apache-superset # apache-superset-extensions-cli +watchfiles==1.1.1 + # via fastmcp wcwidth==0.2.13 # via # -c requirements/base-constraint.txt diff --git a/superset/mcp_service/chart/schemas.py b/superset/mcp_service/chart/schemas.py index 4cc2bffa07f..373f667e586 100644 --- a/superset/mcp_service/chart/schemas.py +++ b/superset/mcp_service/chart/schemas.py @@ -384,15 +384,12 @@ class ChartList(BaseModel): class ColumnRef(BaseModel): name: str = Field( ..., - description="Column name", min_length=1, max_length=255, pattern=r"^[a-zA-Z0-9_][a-zA-Z0-9_\s\-\.]*$", ) - label: str | None = Field( - None, description="Display label for the column", max_length=500 - ) - dtype: str | None = Field(None, description="Data type hint") + label: str | None = Field(None, max_length=500) + dtype: str | None = None aggregate: ( Literal[ "SUM", @@ -407,11 +404,7 @@ class ColumnRef(BaseModel): "PERCENTILE", ] | None - ) = Field( - None, - description="SQL aggregation function. Only these validated functions are " - "supported to prevent SQL errors.", - ) + ) = Field(None, description="SQL aggregate function") @field_validator("name") @classmethod @@ -431,25 +424,22 @@ class ColumnRef(BaseModel): class AxisConfig(BaseModel): - title: str | None = Field(None, description="Axis title", max_length=200) - scale: Literal["linear", "log"] | None = Field( - "linear", description="Axis scale type" - ) - format: str | None = Field( - None, description="Format string (e.g. '$,.2f')", max_length=50 - ) + title: str | None = Field(None, max_length=200) + scale: Literal["linear", "log"] | None = "linear" + format: str | None = Field(None, description="e.g. '$,.2f'", max_length=50) class LegendConfig(BaseModel): - show: bool = Field(True, description="Whether to show legend") - position: Literal["top", "bottom", "left", "right"] | None = Field( - "right", description="Legend position" - ) + show: bool = True + position: Literal["top", "bottom", "left", "right"] | None = "right" class FilterConfig(BaseModel): column: str = Field( - ..., description="Column to filter on", min_length=1, max_length=255 + ..., + min_length=1, + max_length=255, + pattern=r"^[a-zA-Z0-9_][a-zA-Z0-9_\s\-\.]*$", ) op: Literal[ "=", @@ -465,17 +455,11 @@ class FilterConfig(BaseModel): "NOT IN", ] = Field( ..., - description=( - "Filter operator. Use LIKE/ILIKE for pattern matching with % wildcards " - "(e.g., '%mario%'). Use IN/NOT IN with a list of values." - ), + description="LIKE/ILIKE use % wildcards. IN/NOT IN take a list.", ) value: str | int | float | bool | list[str | int | float | bool] = Field( ..., - description=( - "Filter value. For IN/NOT IN operators, provide a list of values. " - "For LIKE/ILIKE, use % as wildcard (e.g., '%mario%')." - ), + description="For IN/NOT IN, provide a list.", ) @field_validator("column") @@ -516,26 +500,13 @@ class FilterConfig(BaseModel): class PieChartConfig(BaseModel): model_config = ConfigDict(extra="forbid") - chart_type: Literal["pie"] = Field( - ..., - description=( - "Chart type discriminator - MUST be 'pie' for pie/donut charts. " - "This field is REQUIRED and tells Superset which chart " - "configuration schema to use." - ), - ) - dimension: ColumnRef = Field( - ..., description="Category column that defines the pie slices" - ) + chart_type: Literal["pie"] = "pie" + dimension: ColumnRef = Field(..., description="Category column for slices") metric: ColumnRef = Field( - ..., - description=( - "Value metric that determines slice sizes. " - "Must include an aggregate function (e.g., SUM, COUNT)." - ), + ..., description="Value metric (needs aggregate e.g. SUM, COUNT)" ) - donut: bool = Field(False, description="Render as a donut chart with a center hole") - show_labels: bool = Field(True, description="Display labels on slices") + donut: bool = False + show_labels: bool = True label_type: Literal[ "key", "value", @@ -544,63 +515,32 @@ class PieChartConfig(BaseModel): "key_percent", "key_value_percent", "value_percent", - ] = Field("key_value_percent", description="Type of labels to show on slices") - sort_by_metric: bool = Field(True, description="Sort slices by metric value") - show_legend: bool = Field(True, description="Whether to show legend") - filters: List[FilterConfig] | None = Field(None, description="Filters to apply") - row_limit: int = Field( - 100, - description="Maximum number of slices to display", - ge=1, - le=10000, - ) - number_format: str = Field( - "SMART_NUMBER", - description="Number format string", - max_length=50, - ) - show_total: bool = Field(False, description="Display aggregate count in center") - labels_outside: bool = Field(True, description="Place labels outside the pie") - outer_radius: int = Field( - 70, - description="Outer edge radius as a percentage (1-100)", - ge=1, - le=100, - ) + ] = "key_value_percent" + sort_by_metric: bool = True + show_legend: bool = True + filters: List[FilterConfig] | None = None + row_limit: int = Field(100, description="Max slices", ge=1, le=10000) + number_format: str = Field("SMART_NUMBER", max_length=50) + show_total: bool = Field(False, description="Show total in center") + labels_outside: bool = True + outer_radius: int = Field(70, description="Outer radius % (1-100)", ge=1, le=100) inner_radius: int = Field( - 30, - description="Inner radius as a percentage for donut (1-100)", - ge=1, - le=100, + 30, description="Donut inner radius % (1-100)", ge=1, le=100 ) class PivotTableChartConfig(BaseModel): model_config = ConfigDict(extra="forbid") - chart_type: Literal["pivot_table"] = Field( - ..., - description=( - "Chart type discriminator - MUST be 'pivot_table' for interactive " - "pivot tables. This field is REQUIRED." - ), - ) - rows: List[ColumnRef] = Field( - ..., - min_length=1, - description="Row grouping columns (at least one required)", - ) + chart_type: Literal["pivot_table"] = "pivot_table" + rows: List[ColumnRef] = Field(..., min_length=1, description="Row grouping columns") columns: List[ColumnRef] | None = Field( - None, - description="Column grouping columns (optional, for cross-tabulation)", + None, description="Column groups for cross-tabulation" ) metrics: List[ColumnRef] = Field( ..., min_length=1, - description=( - "Metrics to aggregate. Each must have an aggregate function " - "(e.g., SUM, COUNT, AVG)." - ), + description="Metrics (need aggregate e.g. SUM, COUNT, AVG)", ) aggregate_function: Literal[ "Sum", @@ -614,108 +554,56 @@ class PivotTableChartConfig(BaseModel): "Count Unique Values", "First", "Last", - ] = Field("Sum", description="Default aggregation function for the pivot table") - show_row_totals: bool = Field(True, description="Show row totals") - show_column_totals: bool = Field(True, description="Show column totals") - transpose: bool = Field(False, description="Swap rows and columns") - combine_metric: bool = Field( - False, - description="Display metrics side by side within columns", - ) - filters: List[FilterConfig] | None = Field(None, description="Filters to apply") - row_limit: int = Field( - 10000, - description="Maximum number of cells", - ge=1, - le=50000, - ) - value_format: str = Field( - "SMART_NUMBER", - description="Value format string", - max_length=50, - ) + ] = "Sum" + show_row_totals: bool = True + show_column_totals: bool = True + transpose: bool = False + combine_metric: bool = Field(False, description="Metrics side by side in columns") + filters: List[FilterConfig] | None = None + row_limit: int = Field(10000, description="Max cells", ge=1, le=50000) + value_format: str = Field("SMART_NUMBER", max_length=50) class MixedTimeseriesChartConfig(BaseModel): model_config = ConfigDict(extra="forbid") - chart_type: Literal["mixed_timeseries"] = Field( - ..., - description=( - "Chart type discriminator - MUST be 'mixed_timeseries' for charts " - "that combine two different series types (e.g., line + bar). " - "This field is REQUIRED." - ), - ) - x: ColumnRef = Field(..., description="X-axis temporal column (shared)") - time_grain: TimeGrain | None = Field( - None, - description=( - "Time granularity for the x-axis. " - "Common values: PT1H (hourly), P1D (daily), P1W (weekly), " - "P1M (monthly), P1Y (yearly)." - ), - ) + chart_type: Literal["mixed_timeseries"] = "mixed_timeseries" + x: ColumnRef = Field(..., description="Shared temporal X-axis column") + time_grain: TimeGrain | None = Field(None, description="PT1H, P1D, P1W, P1M, P1Y") # Primary series (Query A) - y: List[ColumnRef] = Field( - ..., - min_length=1, - description="Primary Y-axis metrics (Query A)", - ) - primary_kind: Literal["line", "bar", "area", "scatter"] = Field( - "line", description="Primary series chart type" - ) - group_by: ColumnRef | None = Field( - None, description="Group by column for primary series" - ) + y: List[ColumnRef] = Field(..., min_length=1, description="Primary Y-axis metrics") + primary_kind: Literal["line", "bar", "area", "scatter"] = "line" + group_by: ColumnRef | None = Field(None, description="Primary series group by") # Secondary series (Query B) y_secondary: List[ColumnRef] = Field( - ..., - min_length=1, - description="Secondary Y-axis metrics (Query B)", - ) - secondary_kind: Literal["line", "bar", "area", "scatter"] = Field( - "bar", description="Secondary series chart type" + ..., min_length=1, description="Secondary Y-axis metrics" ) + secondary_kind: Literal["line", "bar", "area", "scatter"] = "bar" group_by_secondary: ColumnRef | None = Field( - None, description="Group by column for secondary series" + None, description="Secondary series group by" ) # Display options - show_legend: bool = Field(True, description="Whether to show legend") - x_axis: AxisConfig | None = Field(None, description="X-axis configuration") - y_axis: AxisConfig | None = Field(None, description="Primary Y-axis configuration") - y_axis_secondary: AxisConfig | None = Field( - None, description="Secondary Y-axis configuration" - ) - filters: List[FilterConfig] | None = Field(None, description="Filters to apply") + show_legend: bool = True + x_axis: AxisConfig | None = None + y_axis: AxisConfig | None = None + y_axis_secondary: AxisConfig | None = None + filters: List[FilterConfig] | None = None class TableChartConfig(BaseModel): model_config = ConfigDict(extra="forbid") - chart_type: Literal["table"] = Field( - ..., description="Chart type (REQUIRED: must be 'table')" - ) + chart_type: Literal["table"] = "table" viz_type: Literal["table", "ag-grid-table"] = Field( - "table", - description=( - "Visualization type: 'table' for standard table, 'ag-grid-table' for " - "AG Grid Interactive Table with advanced features like column resizing, " - "sorting, filtering, and server-side pagination" - ), + "table", description="'ag-grid-table' for interactive features" ) columns: List[ColumnRef] = Field( ..., min_length=1, - description=( - "Columns to display. Must have at least one column. Each column must have " - "a unique label " - "(either explicitly set via 'label' field or auto-generated " - "from name/aggregate)" - ), + description="Columns with unique labels", ) - filters: List[FilterConfig] | None = Field(None, description="Filters to apply") - sort_by: List[str] | None = Field(None, description="Columns to sort by") + filters: List[FilterConfig] | None = None + sort_by: List[str] | None = None @model_validator(mode="after") def validate_unique_column_labels(self) -> "TableChartConfig": @@ -748,56 +636,26 @@ class TableChartConfig(BaseModel): class XYChartConfig(BaseModel): model_config = ConfigDict(extra="forbid") - chart_type: Literal["xy"] = Field( - ..., - description=( - "Chart type discriminator - MUST be 'xy' for XY charts " - "(line, bar, area, scatter). " - "This field is REQUIRED and tells Superset which chart " - "configuration schema to use." - ), - ) + chart_type: Literal["xy"] = "xy" x: ColumnRef = Field(..., description="X-axis column") y: List[ColumnRef] = Field( - ..., - min_length=1, - description="Y-axis columns (metrics). Must have at least one Y-axis column. " - "Each column must have a unique label " - "that doesn't conflict with x-axis or group_by labels", - ) - kind: Literal["line", "bar", "area", "scatter"] = Field( - "line", description="Chart visualization type" + ..., min_length=1, description="Y-axis metrics (unique labels)" ) + kind: Literal["line", "bar", "area", "scatter"] = "line" time_grain: TimeGrain | None = Field( - None, - description=( - "Time granularity for the x-axis when it's a temporal column. " - "Common values: PT1S (second), PT1M (minute), PT1H (hour), " - "P1D (day), P1W (week), P1M (month), P3M (quarter), P1Y (year). " - "If not specified, Superset will use its default behavior." - ), + None, description="PT1S, PT1M, PT1H, P1D, P1W, P1M, P3M, P1Y" ) orientation: Literal["vertical", "horizontal"] | None = Field( - None, - description=( - "Bar chart orientation. Only applies when kind='bar'. " - "'vertical' (default): bars extend upward. " - "'horizontal': bars extend rightward, useful for long category names." - ), - ) - stacked: bool = Field( - False, - description="Stack bars/areas on top of each other instead of side-by-side", + None, description="Bar orientation (only for kind='bar')" ) + stacked: bool = False group_by: ColumnRef | None = Field( - None, - description="Column to group by (creates series/breakdown). " - "Use this field for series grouping — do NOT use 'series'.", + None, description="Series breakdown column (not 'series')" ) - x_axis: AxisConfig | None = Field(None, description="X-axis configuration") - y_axis: AxisConfig | None = Field(None, description="Y-axis configuration") - legend: LegendConfig | None = Field(None, description="Legend configuration") - filters: List[FilterConfig] | None = Field(None, description="Filters to apply") + x_axis: AxisConfig | None = None + y_axis: AxisConfig | None = None + legend: LegendConfig | None = None + filters: List[FilterConfig] | None = None @model_validator(mode="after") def validate_unique_column_labels(self) -> "XYChartConfig": @@ -949,21 +807,12 @@ class GenerateChartRequest(QueryCacheControl): dataset_id: int | str = Field(..., description="Dataset identifier (ID, UUID)") config: ChartConfig = Field(..., description="Chart configuration") chart_name: str | None = Field( - None, - description="Custom chart name (optional, auto-generates if not provided)", - max_length=255, - ) - save_chart: bool = Field( - default=False, - description="Whether to permanently save the chart in Superset", - ) - generate_preview: bool = Field( - default=True, - description="Whether to generate a preview image", + None, description="Auto-generates if omitted", max_length=255 ) + save_chart: bool = Field(default=False, description="Save permanently in Superset") + generate_preview: bool = True preview_formats: List[Literal["url", "ascii", "vega_lite", "table"]] = Field( default_factory=lambda: ["url"], - description="List of preview formats to generate", ) @field_validator("chart_name") @@ -1002,20 +851,14 @@ class GenerateExploreLinkRequest(FormDataCacheControl): class UpdateChartRequest(QueryCacheControl): - identifier: int | str = Field(..., description="Chart identifier (ID, UUID)") - config: ChartConfig = Field(..., description="New chart configuration") + identifier: int | str = Field(..., description="Chart ID or UUID") + config: ChartConfig chart_name: str | None = Field( - None, - description="New chart name (optional, will auto-generate if not provided)", - max_length=255, - ) - generate_preview: bool = Field( - default=True, - description="Whether to generate a preview after updating", + None, description="Auto-generates if omitted", max_length=255 ) + generate_preview: bool = True preview_formats: List[Literal["url", "ascii", "vega_lite", "table"]] = Field( default_factory=lambda: ["url"], - description="List of preview formats to generate", ) @field_validator("chart_name") @@ -1027,15 +870,11 @@ class UpdateChartRequest(QueryCacheControl): class UpdateChartPreviewRequest(FormDataCacheControl): form_data_key: str = Field(..., description="Existing form_data_key to update") - dataset_id: int | str = Field(..., description="Dataset identifier (ID, UUID)") - config: ChartConfig = Field(..., description="New chart configuration") - generate_preview: bool = Field( - default=True, - description="Whether to generate a preview after updating", - ) + dataset_id: int | str = Field(..., description="Dataset ID or UUID") + config: ChartConfig + generate_preview: bool = True preview_formats: List[Literal["url", "ascii", "vega_lite", "table"]] = Field( default_factory=lambda: ["url"], - description="List of preview formats to generate", ) diff --git a/superset/mcp_service/common/cache_schemas.py b/superset/mcp_service/common/cache_schemas.py index 59b3112b29f..bf51bbf49b0 100644 --- a/superset/mcp_service/common/cache_schemas.py +++ b/superset/mcp_service/common/cache_schemas.py @@ -37,22 +37,10 @@ class CacheControlMixin(BaseModel): - Dashboard Cache: Caches rendered dashboard components """ - use_cache: bool = Field( - default=True, - description=( - "Whether to use Superset's cache layers. When True, will serve from " - "cache if available (query results, metadata, form data). When False, " - "will bypass cache and fetch fresh data." - ), - ) + use_cache: bool = Field(default=True, description="Use cache if available") force_refresh: bool = Field( - default=False, - description=( - "Whether to force refresh cached data. When True, will invalidate " - "existing cache entries and fetch fresh data, then update the cache. " - "Overrides use_cache=True if both are specified." - ), + default=False, description="Invalidate cache and fetch fresh data" ) @@ -65,12 +53,7 @@ class QueryCacheControl(CacheControlMixin): """ cache_timeout: int | None = Field( - default=None, - description=( - "Override the default cache timeout in seconds for this query. " - "If not specified, uses dataset-level or global cache settings. " - "Set to 0 to disable caching for this specific query." - ), + default=None, description="Cache timeout override in seconds (0 to disable)" ) @@ -83,11 +66,7 @@ class MetadataCacheControl(CacheControlMixin): """ refresh_metadata: bool = Field( - default=False, - description=( - "Whether to refresh metadata cache for datasets, tables, and columns. " - "Useful when database schema has changed and you need fresh metadata." - ), + default=False, description="Refresh metadata cache for schema changes" ) @@ -100,11 +79,7 @@ class FormDataCacheControl(CacheControlMixin): """ cache_form_data: bool = Field( - default=True, - description=( - "Whether to cache the form data configuration for future use. " - "When False, generates temporary configurations that are not cached." - ), + default=True, description="Cache form data for future use" ) diff --git a/superset/mcp_service/dashboard/schemas.py b/superset/mcp_service/dashboard/schemas.py index 48aa72a952c..ba56cf774b0 100644 --- a/superset/mcp_service/dashboard/schemas.py +++ b/superset/mcp_service/dashboard/schemas.py @@ -287,45 +287,31 @@ class GetDashboardInfoRequest(MetadataCacheControl): class DashboardInfo(BaseModel): - id: int | None = Field(None, description="Dashboard ID") - dashboard_title: str | None = Field(None, description="Dashboard title") - slug: str | None = Field(None, description="Dashboard slug") - description: str | None = Field(None, description="Dashboard description") - css: str | None = Field(None, description="Custom CSS for the dashboard") - certified_by: str | None = Field(None, description="Who certified the dashboard") - certification_details: str | None = Field(None, description="Certification details") - json_metadata: str | None = Field( - None, description="Dashboard metadata (JSON string)" - ) - position_json: str | None = Field(None, description="Chart positions (JSON string)") - published: bool | None = Field( - None, description="Whether the dashboard is published" - ) - is_managed_externally: bool | None = Field( - None, description="Whether managed externally" - ) - external_url: str | None = Field(None, description="External URL") - created_on: str | datetime | None = Field(None, description="Creation timestamp") - changed_on: str | datetime | None = Field( - None, description="Last modification timestamp" - ) - created_by: str | None = Field(None, description="Dashboard creator (username)") - changed_by: str | None = Field(None, description="Last modifier (username)") - uuid: str | None = Field(None, description="Dashboard UUID (converted to string)") - url: str | None = Field(None, description="Dashboard URL") - created_on_humanized: str | None = Field( - None, description="Humanized creation time" - ) - changed_on_humanized: str | None = Field( - None, description="Humanized modification time" - ) - chart_count: int = Field(0, description="Number of charts in the dashboard") - owners: List[UserInfo] = Field(default_factory=list, description="Dashboard owners") - tags: List[TagInfo] = Field(default_factory=list, description="Dashboard tags") - roles: List[RoleInfo] = Field(default_factory=list, description="Dashboard roles") - charts: List[ChartInfo] = Field( - default_factory=list, description="Dashboard charts" - ) + id: int | None = None + dashboard_title: str | None = None + slug: str | None = None + description: str | None = None + css: str | None = None + certified_by: str | None = None + certification_details: str | None = None + json_metadata: str | None = None + position_json: str | None = None + published: bool | None = None + is_managed_externally: bool | None = None + external_url: str | None = None + created_on: str | datetime | None = None + changed_on: str | datetime | None = None + created_by: str | None = None + changed_by: str | None = None + uuid: str | None = None + url: str | None = None + created_on_humanized: str | None = None + changed_on_humanized: str | None = None + chart_count: int = 0 + owners: List[UserInfo] = Field(default_factory=list) + tags: List[TagInfo] = Field(default_factory=list) + roles: List[RoleInfo] = Field(default_factory=list) + charts: List[ChartInfo] = Field(default_factory=list) # Fields for permalink/filter state support permalink_key: str | None = Field( diff --git a/superset/mcp_service/flask_singleton.py b/superset/mcp_service/flask_singleton.py index 3c35d31a6a4..d3a7124ec13 100644 --- a/superset/mcp_service/flask_singleton.py +++ b/superset/mcp_service/flask_singleton.py @@ -25,7 +25,6 @@ Following the Stack Overflow recommendation: """ import logging -import os from flask import current_app, Flask, has_app_context @@ -52,62 +51,45 @@ try: logger.info("Reusing existing Flask app from app context for MCP service") # Use _get_current_object() to get the actual Flask app, not the LocalProxy app = current_app._get_current_object() + elif appbuilder_initialized: + # appbuilder is initialized but we have no app context. Calling + # create_app() here would invoke appbuilder.init_app() a second + # time with a *different* Flask app, overwriting shared internal + # state (views, security manager, etc.). Fail loudly instead of + # silently corrupting the singleton. + raise RuntimeError( + "appbuilder is already initialized but no Flask app context is " + "available. Cannot call create_app() as it would re-initialize " + "appbuilder with a different Flask app instance." + ) else: - # Either appbuilder is not initialized (standalone MCP server), - # or appbuilder is initialized but we're not in an app context - # (edge case - should rarely happen). In both cases, create a minimal app. + # Standalone MCP server — Superset models are deeply coupled to + # appbuilder, security_manager, event_logger, encrypted_field_factory, + # etc. so we use create_app() for full initialization rather than + # trying to init a minimal subset (which leads to cascading failures). # - # We avoid calling create_app() which would run full FAB initialization - # and could corrupt the shared appbuilder singleton if main app starts. - from superset.app import SupersetApp + # create_app() is safe here because in standalone mode the main + # Superset web server is not running in-process. + from superset.app import create_app from superset.mcp_service.mcp_config import get_mcp_config - if appbuilder_initialized: - logger.warning( - "Appbuilder initialized but not in app context - " - "creating separate MCP Flask app" - ) - else: - logger.info("Creating minimal Flask app for standalone MCP service") - - # Disable debug mode to avoid side-effects like file watchers - _mcp_app = SupersetApp(__name__) + logger.info("Creating fully initialized Flask app for standalone MCP service") + _mcp_app = create_app() _mcp_app.debug = False - # Load configuration - config_module = os.environ.get("SUPERSET_CONFIG", "superset.config") - _mcp_app.config.from_object(config_module) - - # Apply MCP-specific configuration + # Apply MCP-specific configuration on top mcp_config = get_mcp_config(_mcp_app.config) _mcp_app.config.update(mcp_config) - # Initialize only the minimal dependencies needed for MCP service with _mcp_app.app_context(): - try: - from superset.extensions import db + from superset.core.mcp.core_mcp_injection import ( + initialize_core_mcp_dependencies, + ) - db.init_app(_mcp_app) - - # Initialize only MCP-specific dependencies - # MCP tools import directly from superset.daos/models, so we only need - # the MCP decorator injection, not the full superset_core abstraction - from superset.core.mcp.core_mcp_injection import ( - initialize_core_mcp_dependencies, - ) - - initialize_core_mcp_dependencies() - - logger.info( - "Minimal MCP dependencies initialized for standalone MCP service" - ) - except Exception as e: - logger.warning( - "Failed to initialize dependencies for MCP service: %s", e - ) + initialize_core_mcp_dependencies() app = _mcp_app - logger.info("Minimal Flask app instance created successfully for MCP service") + logger.info("Flask app fully initialized for standalone MCP service") except Exception as e: logger.error("Failed to create Flask app: %s", e) diff --git a/superset/mcp_service/mcp_config.py b/superset/mcp_service/mcp_config.py index 75221f854ef..e1e65c11f0b 100644 --- a/superset/mcp_service/mcp_config.py +++ b/superset/mcp_service/mcp_config.py @@ -227,10 +227,44 @@ MCP_RESPONSE_SIZE_CONFIG: Dict[str, Any] = { "get_chart_preview", # Returns URLs, not data "generate_explore_link", # Returns URLs "open_sql_lab_with_context", # Returns URLs + "search_tools", # Returns tool schemas for discovery (intentionally large) ], } +# ============================================================================= +# MCP Tool Search Transform Configuration +# ============================================================================= +# +# Overview: +# --------- +# When enabled, replaces the full tool catalog with a search interface. +# LLMs see only 2 synthetic tools (search_tools + call_tool) plus any +# pinned tools, and discover other tools on-demand via natural language search. +# This reduces initial context by ~70% (from ~40k tokens to ~5-8k tokens). +# +# Strategies: +# ----------- +# - "bm25": Natural language search using BM25 ranking (recommended) +# - "regex": Pattern-based search using regular expressions +# +# Rollback: +# --------- +# Set enabled=False in superset_config.py for instant rollback. +# ============================================================================= +MCP_TOOL_SEARCH_CONFIG: Dict[str, Any] = { + "enabled": True, # Enabled by default — reduces initial context by ~70% + "strategy": "bm25", # "bm25" (natural language) or "regex" (pattern matching) + "max_results": 5, # Max tools returned per search + "always_visible": [ # Tools always shown in list_tools (pinned) + "health_check", + "get_instance_info", + ], + "search_tool_name": "search_tools", # Name of the search tool + "call_tool_name": "call_tool", # Name of the call proxy tool +} + + def create_default_mcp_auth_factory(app: Flask) -> Optional[Any]: """Default MCP auth factory using app.config values.""" if not app.config.get("MCP_AUTH_ENABLED", False): diff --git a/superset/mcp_service/server.py b/superset/mcp_service/server.py index 5435a66e68b..732e8f4622f 100644 --- a/superset/mcp_service/server.py +++ b/superset/mcp_service/server.py @@ -24,12 +24,17 @@ For multi-pod deployments, configure MCP_EVENT_STORE_CONFIG with Redis URL. import logging import os +from collections.abc import Sequence from typing import Any import uvicorn from superset.mcp_service.app import create_mcp_app, init_fastmcp_server -from superset.mcp_service.mcp_config import get_mcp_factory_config, MCP_STORE_CONFIG +from superset.mcp_service.mcp_config import ( + get_mcp_factory_config, + MCP_STORE_CONFIG, + MCP_TOOL_SEARCH_CONFIG, +) from superset.mcp_service.middleware import ( create_response_size_guard_middleware, GlobalErrorHandlerMiddleware, @@ -111,8 +116,7 @@ def create_event_store(config: dict[str, Any] | None = None) -> Any | None: if config is None: config = MCP_STORE_CONFIG - redis_url = config.get("CACHE_REDIS_URL") - if not redis_url: + if not config.get("CACHE_REDIS_URL"): logging.info("EventStore: Using in-memory storage (single-pod mode)") return None @@ -151,6 +155,117 @@ def create_event_store(config: dict[str, Any] | None = None) -> Any | None: return None +def _strip_titles(obj: Any, in_properties_map: bool = False) -> Any: + """Recursively strip schema metadata ``title`` keys. + + Keeps real field names inside ``properties`` (e.g. a property literally + named ``title``), while removing auto-generated schema title metadata. + """ + if isinstance(obj, dict): + result: dict[str, Any] = {} + for key, value in obj.items(): + if key == "title" and not in_properties_map: + continue + result[key] = _strip_titles(value, in_properties_map=(key == "properties")) + return result + if isinstance(obj, list): + return [_strip_titles(item, in_properties_map=False) for item in obj] + return obj + + +def _serialize_tools_without_output_schema( + tools: Sequence[Any], +) -> list[dict[str, Any]]: + """Serialize tools to JSON, stripping outputSchema and titles to reduce tokens. + + LLMs only need inputSchema to call tools. outputSchema accounts for + 50-80% of the per-tool schema size, and auto-generated 'title' fields + add ~12% bloat. Stripping both cuts search result tokens significantly. + """ + results = [] + for tool in tools: + data = tool.to_mcp_tool().model_dump(mode="json", exclude_none=True) + data.pop("outputSchema", None) + if input_schema := data.get("inputSchema"): + data["inputSchema"] = _strip_titles(input_schema) + results.append(data) + return results + + +def _fix_call_tool_arguments(tool: Any) -> Any: + """Fix anyOf schema in call_tool ``arguments`` for MCP bridge compatibility. + + FastMCP's BaseSearchTransform defines ``arguments`` as + ``dict[str, Any] | None`` which emits an ``anyOf`` JSON Schema. + Some MCP bridges (mcp-remote, Claude Desktop) don't handle ``anyOf`` + and strip it, leaving the field without a ``type`` — causing all + call_tool invocations to fail with "Input should be a valid dictionary". + + Replaces the ``anyOf`` with a flat ``type: object``. + """ + if "arguments" in (props := (tool.parameters or {}).get("properties", {})): + props["arguments"] = { + "additionalProperties": True, + "default": None, + "description": "Arguments to pass to the tool", + "type": "object", + } + return tool + + +def _apply_tool_search_transform(mcp_instance: Any, config: dict[str, Any]) -> None: + """Apply tool search transform to reduce initial context size. + + When enabled, replaces the full tool catalog with a search interface. + LLMs see only synthetic search/call tools plus pinned tools, and + discover other tools on-demand via natural language search. + + Uses subclassing (not monkey-patching) to override ``_make_call_tool`` + and fix the ``arguments`` schema for MCP bridge compatibility. + + NOTE: ``_make_call_tool`` is a private API in FastMCP 3.x + (fastmcp>=3.1.0,<4.0). If FastMCP changes or removes this method + in a future major version, these subclasses will need to be updated. + """ + strategy = config.get("strategy", "bm25") + kwargs: dict[str, Any] = { + "max_results": config.get("max_results", 5), + "always_visible": config.get("always_visible", []), + "search_tool_name": config.get("search_tool_name", "search_tools"), + "call_tool_name": config.get("call_tool_name", "call_tool"), + "search_result_serializer": _serialize_tools_without_output_schema, + } + + if strategy == "regex": + from fastmcp.server.transforms.search import RegexSearchTransform + + class _FixedRegexSearchTransform(RegexSearchTransform): + """Regex search with fixed call_tool arguments schema.""" + + def _make_call_tool(self) -> Any: + return _fix_call_tool_arguments(super()._make_call_tool()) + + transform = _FixedRegexSearchTransform(**kwargs) + else: + from fastmcp.server.transforms.search import BM25SearchTransform + + class _FixedBM25SearchTransform(BM25SearchTransform): + """BM25 search with fixed call_tool arguments schema.""" + + def _make_call_tool(self) -> Any: + return _fix_call_tool_arguments(super()._make_call_tool()) + + transform = _FixedBM25SearchTransform(**kwargs) + + mcp_instance.add_transform(transform) + logger.info( + "Tool search transform enabled (strategy=%s, max_results=%d, pinned=%s)", + strategy, + kwargs["max_results"], + kwargs["always_visible"], + ) + + def _create_auth_provider(flask_app: Any) -> Any | None: """Create an auth provider from Flask app config. @@ -218,6 +333,11 @@ def run_server( logging.info("Creating MCP app from factory configuration...") factory_config = get_mcp_factory_config() mcp_instance = create_mcp_app(**factory_config) + + # Apply tool search transform if configured + tool_search_config = MCP_TOOL_SEARCH_CONFIG + if tool_search_config.get("enabled", False): + _apply_tool_search_transform(mcp_instance, tool_search_config) else: # Use default initialization with auth from Flask config logging.info("Creating MCP app with default configuration...") @@ -233,8 +353,7 @@ def run_server( middleware_list = [] # Add caching middleware (innermost – runs closest to the tool) - caching_middleware = create_response_caching_middleware() - if caching_middleware: + if caching_middleware := create_response_caching_middleware(): middleware_list.append(caching_middleware) # Add response size guard (protects LLM clients from huge responses) @@ -252,6 +371,18 @@ def run_server( middleware=middleware_list or None, ) + # Apply tool search transform if configured + tool_search_config = flask_app.config.get( + "MCP_TOOL_SEARCH_CONFIG", MCP_TOOL_SEARCH_CONFIG + ) + if tool_search_config.get("enabled", False): + _apply_tool_search_transform(mcp_instance, tool_search_config) + # Ensure the configured search tool name is excluded from the + # response size guard (search results are intentionally large) + if size_guard_middleware: + search_name = tool_search_config.get("search_tool_name", "search_tools") + size_guard_middleware.excluded_tools.add(search_name) + # Create EventStore for session management (Redis for multi-pod, None for in-memory) event_store = create_event_store(event_store_config) diff --git a/superset/mcp_service/system/schemas.py b/superset/mcp_service/system/schemas.py index 9810cc4d3b3..ae0fab91e62 100644 --- a/superset/mcp_service/system/schemas.py +++ b/superset/mcp_service/system/schemas.py @@ -58,54 +58,40 @@ class GetSupersetInstanceInfoRequest(BaseModel): class InstanceSummary(BaseModel): - total_dashboards: int = Field(..., description="Total number of dashboards") - total_charts: int = Field(..., description="Total number of charts") - total_datasets: int = Field(..., description="Total number of datasets") - total_databases: int = Field(..., description="Total number of databases") - total_users: int = Field(..., description="Total number of users") - total_roles: int = Field(..., description="Total number of roles") - total_tags: int = Field(..., description="Total number of tags") - avg_charts_per_dashboard: float = Field( - ..., description="Average number of charts per dashboard" - ) + total_dashboards: int + total_charts: int + total_datasets: int + total_databases: int + total_users: int + total_roles: int + total_tags: int + avg_charts_per_dashboard: float class RecentActivity(BaseModel): - dashboards_created_last_30_days: int = Field( - ..., description="Dashboards created in the last 30 days" - ) - charts_created_last_30_days: int = Field( - ..., description="Charts created in the last 30 days" - ) - datasets_created_last_30_days: int = Field( - ..., description="Datasets created in the last 30 days" - ) - dashboards_modified_last_7_days: int = Field( - ..., description="Dashboards modified in the last 7 days" - ) - charts_modified_last_7_days: int = Field( - ..., description="Charts modified in the last 7 days" - ) - datasets_modified_last_7_days: int = Field( - ..., description="Datasets modified in the last 7 days" - ) + dashboards_created_last_30_days: int + charts_created_last_30_days: int + datasets_created_last_30_days: int + dashboards_modified_last_7_days: int + charts_modified_last_7_days: int + datasets_modified_last_7_days: int class DashboardBreakdown(BaseModel): - published: int = Field(..., description="Number of published dashboards") - unpublished: int = Field(..., description="Number of unpublished dashboards") - certified: int = Field(..., description="Number of certified dashboards") - with_charts: int = Field(..., description="Number of dashboards with charts") - without_charts: int = Field(..., description="Number of dashboards without charts") + published: int + unpublished: int + certified: int + with_charts: int + without_charts: int class DatabaseBreakdown(BaseModel): - by_type: Dict[str, int] = Field(..., description="Breakdown of databases by type") + by_type: Dict[str, int] class PopularContent(BaseModel): - top_tags: List[str] = Field(..., description="Most popular tags") - top_creators: List[str] = Field(..., description="Most active creators") + top_tags: List[str] = Field(default_factory=list) + top_creators: List[str] = Field(default_factory=list) class FeatureAvailability(BaseModel): @@ -125,33 +111,19 @@ class FeatureAvailability(BaseModel): class InstanceInfo(BaseModel): - instance_summary: InstanceSummary = Field( - ..., description="Instance summary information" - ) - recent_activity: RecentActivity = Field( - ..., description="Recent activity information" - ) - dashboard_breakdown: DashboardBreakdown = Field( - ..., description="Dashboard breakdown information" - ) - database_breakdown: DatabaseBreakdown = Field( - ..., description="Database breakdown by type" - ) - popular_content: PopularContent = Field( - ..., description="Popular content information" - ) + instance_summary: InstanceSummary + recent_activity: RecentActivity + dashboard_breakdown: DashboardBreakdown + database_breakdown: DatabaseBreakdown + popular_content: PopularContent current_user: UserInfo | None = Field( None, - description="The authenticated user making the request. " - "Use current_user.id with created_by_fk filter to find your own assets.", - ) - feature_availability: FeatureAvailability = Field( - ..., description=( - "Dynamic feature availability for the current user and deployment" + "Use current_user.id with created_by_fk filter to find your own assets." ), ) - timestamp: datetime = Field(..., description="Response timestamp") + feature_availability: FeatureAvailability + timestamp: datetime class UserInfo(BaseModel): @@ -207,10 +179,10 @@ class RoleInfo(BaseModel): class PaginationInfo(BaseModel): - page: int = Field(..., description="Current page number") - page_size: int = Field(..., description="Number of items per page") - total_count: int = Field(..., description="Total number of items") - total_pages: int = Field(..., description="Total number of pages") - has_next: bool = Field(..., description="Whether there is a next page") - has_previous: bool = Field(..., description="Whether there is a previous page") + page: int + page_size: int + total_count: int + total_pages: int + has_next: bool + has_previous: bool model_config = ConfigDict(ser_json_timedelta="iso8601") diff --git a/tests/unit_tests/mcp_service/test_mcp_tool_registration.py b/tests/unit_tests/mcp_service/test_mcp_tool_registration.py index be470bc4fd0..9307a671659 100644 --- a/tests/unit_tests/mcp_service/test_mcp_tool_registration.py +++ b/tests/unit_tests/mcp_service/test_mcp_tool_registration.py @@ -17,25 +17,32 @@ """Test MCP app imports and tool/prompt registration.""" +import asyncio + + +def _run(coro): + """Run an async coroutine synchronously.""" + return asyncio.run(coro) + def test_mcp_app_imports_successfully(): """Test that the MCP app can be imported without errors.""" from superset.mcp_service.app import mcp assert mcp is not None - assert hasattr(mcp, "_tool_manager") - tools = mcp._tool_manager._tools - assert len(tools) > 0 - assert "health_check" in tools - assert "list_charts" in tools + tools = _run(mcp.list_tools()) + tool_names = [t.name for t in tools] + assert len(tool_names) > 0 + assert "health_check" in tool_names + assert "list_charts" in tool_names def test_mcp_prompts_registered(): """Test that MCP prompts are registered.""" from superset.mcp_service.app import mcp - prompts = mcp._prompt_manager._prompts + prompts = _run(mcp.list_prompts()) assert len(prompts) > 0 @@ -48,12 +55,10 @@ def test_mcp_resources_registered(): """ from superset.mcp_service.app import mcp - resource_manager = mcp._resource_manager - resources = resource_manager._resources + resources = _run(mcp.list_resources()) assert len(resources) > 0, "No MCP resources registered" - # Verify the two documented resources are registered - resource_uris = set(resources.keys()) + resource_uris = {str(r.uri) for r in resources} assert "chart://configs" in resource_uris, ( "chart://configs resource not registered - " "check superset/mcp_service/chart/__init__.py exists" diff --git a/tests/unit_tests/mcp_service/test_tool_search_transform.py b/tests/unit_tests/mcp_service/test_tool_search_transform.py new file mode 100644 index 00000000000..7adfeacad47 --- /dev/null +++ b/tests/unit_tests/mcp_service/test_tool_search_transform.py @@ -0,0 +1,173 @@ +# 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. + +"""Tests for MCP tool search transform configuration and application.""" + +from types import SimpleNamespace +from unittest.mock import MagicMock + +from fastmcp.server.transforms.search import BM25SearchTransform, RegexSearchTransform + +from superset.mcp_service.mcp_config import MCP_TOOL_SEARCH_CONFIG +from superset.mcp_service.server import ( + _apply_tool_search_transform, + _fix_call_tool_arguments, + _serialize_tools_without_output_schema, +) + + +def test_tool_search_config_defaults(): + """Default config has expected keys and values.""" + assert MCP_TOOL_SEARCH_CONFIG["enabled"] is True + assert MCP_TOOL_SEARCH_CONFIG["strategy"] == "bm25" + assert MCP_TOOL_SEARCH_CONFIG["max_results"] == 5 + assert "health_check" in MCP_TOOL_SEARCH_CONFIG["always_visible"] + assert "get_instance_info" in MCP_TOOL_SEARCH_CONFIG["always_visible"] + assert MCP_TOOL_SEARCH_CONFIG["search_tool_name"] == "search_tools" + assert MCP_TOOL_SEARCH_CONFIG["call_tool_name"] == "call_tool" + + +def test_apply_bm25_transform(): + """BM25 subclass is created and added when strategy is 'bm25'.""" + mock_mcp = MagicMock() + config = { + "strategy": "bm25", + "max_results": 5, + "always_visible": ["health_check"], + "search_tool_name": "search_tools", + "call_tool_name": "call_tool", + } + + _apply_tool_search_transform(mock_mcp, config) + + mock_mcp.add_transform.assert_called_once() + transform = mock_mcp.add_transform.call_args[0][0] + assert isinstance(transform, BM25SearchTransform) + + +def test_apply_regex_transform(): + """Regex subclass is created and added when strategy is 'regex'.""" + mock_mcp = MagicMock() + config = { + "strategy": "regex", + "max_results": 10, + "always_visible": ["health_check", "get_instance_info"], + "search_tool_name": "find_tools", + "call_tool_name": "invoke_tool", + } + + _apply_tool_search_transform(mock_mcp, config) + + mock_mcp.add_transform.assert_called_once() + transform = mock_mcp.add_transform.call_args[0][0] + assert isinstance(transform, RegexSearchTransform) + + +def test_apply_transform_uses_defaults_for_missing_keys(): + """Missing config keys fall back to sensible defaults (BM25).""" + mock_mcp = MagicMock() + config = {} # All keys missing — should use defaults + + _apply_tool_search_transform(mock_mcp, config) + + mock_mcp.add_transform.assert_called_once() + transform = mock_mcp.add_transform.call_args[0][0] + assert isinstance(transform, BM25SearchTransform) + + +def test_fix_call_tool_arguments_replaces_anyof(): + """_fix_call_tool_arguments replaces anyOf with flat type: object.""" + tool = SimpleNamespace( + parameters={ + "type": "object", + "properties": { + "name": {"type": "string"}, + "arguments": { + "anyOf": [ + {"type": "object", "additionalProperties": True}, + {"type": "null"}, + ], + "default": None, + }, + }, + } + ) + + result = _fix_call_tool_arguments(tool) + + assert result.parameters["properties"]["arguments"] == { + "additionalProperties": True, + "default": None, + "description": "Arguments to pass to the tool", + "type": "object", + } + # Other properties untouched + assert result.parameters["properties"]["name"] == {"type": "string"} + + +def test_fix_call_tool_arguments_no_arguments_field(): + """_fix_call_tool_arguments is a no-op when arguments field is absent.""" + tool = SimpleNamespace( + parameters={ + "type": "object", + "properties": {"name": {"type": "string"}}, + } + ) + + result = _fix_call_tool_arguments(tool) + + assert "arguments" not in result.parameters["properties"] + + +def test_serialize_tools_strips_output_schema(): + """Custom serializer removes outputSchema from tool definitions.""" + mock_tool = MagicMock() + mock_mcp_tool = MagicMock() + mock_mcp_tool.model_dump.return_value = { + "name": "test_tool", + "description": "A test tool", + "inputSchema": {"type": "object", "properties": {"x": {"type": "integer"}}}, + "outputSchema": { + "type": "object", + "properties": {"result": {"type": "string"}}, + }, + } + mock_tool.to_mcp_tool.return_value = mock_mcp_tool + + result = _serialize_tools_without_output_schema([mock_tool]) + + assert len(result) == 1 + assert result[0]["name"] == "test_tool" + assert "inputSchema" in result[0] + assert "outputSchema" not in result[0] + + +def test_serialize_tools_handles_no_output_schema(): + """Custom serializer works when tool has no outputSchema.""" + mock_tool = MagicMock() + mock_mcp_tool = MagicMock() + mock_mcp_tool.model_dump.return_value = { + "name": "simple_tool", + "inputSchema": {"type": "object"}, + } + mock_tool.to_mcp_tool.return_value = mock_mcp_tool + + result = _serialize_tools_without_output_schema([mock_tool]) + + assert len(result) == 1 + assert result[0]["name"] == "simple_tool" + assert "outputSchema" not in result[0] From e8061a9c2b458f142804a2993396f10ff8cdf8cc Mon Sep 17 00:00:00 2001 From: Luiz Otavio <45200344+luizotavio32@users.noreply.github.com> Date: Fri, 13 Mar 2026 14:24:14 -0300 Subject: [PATCH 45/58] style(metadata-bar): use bold font weight for metadata bar title (#38608) --- .../src/components/MetadataBar/ContentConfig.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/superset-frontend/packages/superset-ui-core/src/components/MetadataBar/ContentConfig.tsx b/superset-frontend/packages/superset-ui-core/src/components/MetadataBar/ContentConfig.tsx index 225549989b5..4a9af897d73 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/MetadataBar/ContentConfig.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/MetadataBar/ContentConfig.tsx @@ -23,7 +23,7 @@ import { Icons } from '@superset-ui/core/components/Icons'; import { ContentType, MetadataType } from '.'; const Header = styled.div` - font-weight: ${({ theme }) => theme.fontWeightStrong}; + font-weight: ${({ theme }) => theme.fontWeightBold}; `; const Info = ({ From 6e7d6a85b451b5bc569d10f8581c16a92f752ba2 Mon Sep 17 00:00:00 2001 From: amaannawab923 Date: Fri, 13 Mar 2026 23:29:52 +0530 Subject: [PATCH 46/58] fix(ag-grid-table): fix failing buildQuery test expectation (#38636) --- .../plugins/plugin-chart-ag-grid-table/test/buildQuery.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/superset-frontend/plugins/plugin-chart-ag-grid-table/test/buildQuery.test.ts b/superset-frontend/plugins/plugin-chart-ag-grid-table/test/buildQuery.test.ts index 636fc190fcc..acec22c6965 100644 --- a/superset-frontend/plugins/plugin-chart-ag-grid-table/test/buildQuery.test.ts +++ b/superset-frontend/plugins/plugin-chart-ag-grid-table/test/buildQuery.test.ts @@ -1215,7 +1215,7 @@ describe('plugin-chart-ag-grid-table', () => { }, ).queries[0]; - expect(query.extras?.where).toBe('"user_status" = \'active\''); + expect(query.extras?.where).toBe("user_status = 'active'"); }); test('should resolve longer labels before shorter ones', () => { From ed622e254a49b383541d3c056c7c67efd735a1dc Mon Sep 17 00:00:00 2001 From: Mehmet Salih Yavuz Date: Fri, 13 Mar 2026 21:51:53 +0300 Subject: [PATCH 47/58] feat(matrixify): Revamp control panel (#38519) --- .../src/sections/matrixify.tsx | 42 +++---- .../src/shared-controls/matrixifyControls.tsx | 114 +++++++++--------- .../Matrixify/MatrixifyGridRenderer.test.tsx | 56 +++++---- .../Matrixify/MatrixifyGridRenderer.tsx | 6 +- .../src/chart/types/matrixify.test.ts | 57 +++++---- .../src/chart/types/matrixify.ts | 82 +++++++------ .../ChartContextMenu/ChartContextMenu.tsx | 6 +- .../components/Chart/ChartRenderer.test.tsx | 22 ++-- .../src/components/Chart/ChartRenderer.tsx | 6 +- .../Chart/DrillBy/DrillBySubmenu.test.tsx | 4 +- .../Chart/DrillBy/DrillBySubmenu.tsx | 6 +- .../ControlPanelsContainer.test.tsx | 9 +- .../components/ControlPanelsContainer.tsx | 7 +- .../ExploreChartHeader.test.tsx | 2 +- .../components/ExploreChartPanel/index.tsx | 10 +- .../controls/MatrixifyDimensionControl.tsx | 74 +++++++++--- .../components/controls/SwitchControl.tsx | 65 ++++++++++ .../controls/VerticalRadioControl.tsx | 104 ++++++++++++++++ .../src/explore/components/controls/index.ts | 4 + .../src/explore/controlPanels/sections.tsx | 86 ++++++++----- .../pages/ChartList/ChartList.testHelpers.tsx | 2 +- 21 files changed, 517 insertions(+), 247 deletions(-) create mode 100644 superset-frontend/src/explore/components/controls/SwitchControl.tsx create mode 100644 superset-frontend/src/explore/components/controls/VerticalRadioControl.tsx diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/sections/matrixify.tsx b/superset-frontend/packages/superset-ui-chart-controls/src/sections/matrixify.tsx index dde1f0431db..928ec6331b8 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/src/sections/matrixify.tsx +++ b/superset-frontend/packages/superset-ui-chart-controls/src/sections/matrixify.tsx @@ -25,30 +25,17 @@ export const matrixifyEnableSection: ControlPanelSectionConfig = { controlSetRows: [ [ { - name: 'matrixify_enable_horizontal_layout', + name: 'matrixify_enable', config: { - type: 'CheckboxControl', - label: t('Enable horizontal layout (columns)'), - description: t( - 'Create matrix columns by placing charts side-by-side', - ), - default: false, - renderTrigger: true, - }, - }, - ], - [ - { - name: 'matrixify_enable_vertical_layout', - config: { - type: 'CheckboxControl', - label: t('Enable vertical layout (rows)'), - description: t('Create matrix rows by stacking charts vertically'), + type: 'SwitchControl', + label: t('Enable matrixify'), default: false, renderTrigger: true, }, }, ], + ['matrixify_mode_columns'], + ['matrixify_mode_rows'], ], tabOverride: 'matrixify', }; @@ -57,8 +44,11 @@ export const matrixifySection: ControlPanelSectionConfig = { label: t('Cell layout & styling'), expanded: false, visibility: ({ controls }) => - controls?.matrixify_enable_vertical_layout?.value === true || - controls?.matrixify_enable_horizontal_layout?.value === true, + controls?.matrixify_enable?.value === true && + (controls?.matrixify_mode_rows?.value === 'metrics' || + controls?.matrixify_mode_rows?.value === 'dimensions' || + controls?.matrixify_mode_columns?.value === 'metrics' || + controls?.matrixify_mode_columns?.value === 'dimensions'), controlSetRows: [ [ { @@ -119,13 +109,13 @@ export const matrixifySection: ControlPanelSectionConfig = { }; export const matrixifyRowSection: ControlPanelSectionConfig = { - label: t('Vertical layout (rows)'), expanded: false, visibility: ({ controls }) => - controls?.matrixify_enable_vertical_layout?.value === true, + controls?.matrixify_enable?.value === true && + (controls?.matrixify_mode_rows?.value === 'metrics' || + controls?.matrixify_mode_rows?.value === 'dimensions'), controlSetRows: [ ['matrixify_show_row_labels'], - ['matrixify_mode_rows'], ['matrixify_rows'], ['matrixify_dimension_rows'], ['matrixify_dimension_selection_mode_rows'], @@ -137,13 +127,13 @@ export const matrixifyRowSection: ControlPanelSectionConfig = { }; export const matrixifyColumnSection: ControlPanelSectionConfig = { - label: t('Horizontal layout (columns)'), expanded: false, visibility: ({ controls }) => - controls?.matrixify_enable_horizontal_layout?.value === true, + controls?.matrixify_enable?.value === true && + (controls?.matrixify_mode_columns?.value === 'metrics' || + controls?.matrixify_mode_columns?.value === 'dimensions'), controlSetRows: [ ['matrixify_show_column_headers'], - ['matrixify_mode_columns'], ['matrixify_columns'], ['matrixify_dimension_columns'], ['matrixify_dimension_selection_mode_columns'], diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/matrixifyControls.tsx b/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/matrixifyControls.tsx index be93b6fec1c..48dcaa3dafc 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/matrixifyControls.tsx +++ b/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/matrixifyControls.tsx @@ -34,19 +34,18 @@ const isMatrixifyVisible = ( controls: any, axis: 'rows' | 'columns', mode?: 'metrics' | 'dimensions', - selectionMode?: 'members' | 'topn', + selectionMode?: 'members' | 'topn' | 'all', ) => { - const layoutControl = `matrixify_enable_${axis === 'rows' ? 'vertical' : 'horizontal'}_layout`; const modeControl = `matrixify_mode_${axis}`; const selectionModeControl = `matrixify_dimension_selection_mode_${axis}`; - const isLayoutEnabled = controls?.[layoutControl]?.value === true; + const modeValue = controls?.[modeControl]?.value; + const isLayoutEnabled = modeValue === 'metrics' || modeValue === 'dimensions'; if (!isLayoutEnabled) return false; if (mode) { - const isModeMatch = controls?.[modeControl]?.value === mode; - if (!isModeMatch) return false; + if (modeValue !== mode) return false; if (selectionMode && mode === 'dimensions') { return controls?.[selectionModeControl]?.value === selectionMode; @@ -66,22 +65,20 @@ const matrixifyControls: Record> = {}; matrixifyControls[`matrixify_mode_${axis}`] = { type: 'RadioButtonControl', - label: t(`Metrics / Dimensions`), - default: axis === 'columns' ? 'metrics' : 'dimensions', + default: 'disabled', renderTrigger: true, tabOverride: 'matrixify', - visibility: ({ controls }) => isMatrixifyVisible(controls, axis), mapStateToProps: ({ controls }) => { const otherAxisControlName = `matrixify_mode_${otherAxis}`; const otherAxisValue = - controls?.[otherAxisControlName]?.value ?? - (otherAxis === 'columns' ? 'metrics' : 'dimensions'); + controls?.[otherAxisControlName]?.value ?? 'disabled'; const isMetricsDisabled = otherAxisValue === 'metrics'; return { options: [ + { value: 'disabled', label: t('Disabled') }, { value: 'metrics', label: t('Metrics'), @@ -92,7 +89,7 @@ const matrixifyControls: Record> = {}; ) : undefined, }, - { value: 'dimensions', label: t('Dimension members') }, + { value: 'dimensions', label: t('Dimensions') }, ], }; }, @@ -125,6 +122,7 @@ const matrixifyControls: Record> = {}; `matrixify_topn_metric_${axis}`, `matrixify_topn_order_${axis}`, `matrixify_dimension_selection_mode_${axis}`, + `matrixify_all_sort_by_${axis}`, ]; return fieldsToCheck.some( @@ -161,7 +159,10 @@ const matrixifyControls: Record> = {}; selectionMode, topNMetric: getValue(`matrixify_topn_metric_${axis}`), topNValue: getValue(`matrixify_topn_value_${axis}`), - topNOrder: getValue(`matrixify_topn_order_${axis}`), + topNOrder: getValue(`matrixify_topn_order_${axis}`, true) + ? 'DESC' + : 'ASC', + allSortBy: getValue(`matrixify_all_sort_by_${axis}`, 'a_to_z'), formData: form_data, validators, }; @@ -187,19 +188,24 @@ const matrixifyControls: Record> = {}; visibility: () => false, }; - // Add selection mode control (Dimension Members vs TopN) + // Add selection mode control (Dimension Members / Top N / All) matrixifyControls[`matrixify_dimension_selection_mode_${axis}`] = { - type: 'RadioButtonControl', + type: 'VerticalRadioControl', label: t(`Selection method`), default: 'members', - options: [ - ['members', t('Dimension members')], - ['topn', t('Top n')], - ], renderTrigger: true, tabOverride: 'matrixify', visibility: ({ controls }) => isMatrixifyVisible(controls, axis, 'dimensions'), + options: [ + { value: 'members', label: t('Dimension members') }, + { value: 'topn', label: t('Top n') }, + { + value: 'all', + label: t('All dimensions'), + tooltip: t('Uses the first 25 values if the dimension has more.'), + }, + ], }; // TopN controls @@ -236,15 +242,15 @@ const matrixifyControls: Record> = {}; description: t(`Metric to use for ordering Top N values`), tabOverride: 'matrixify', visibility: ({ controls }) => - isMatrixifyVisible(controls, axis, 'dimensions', 'topn'), + isMatrixifyVisible(controls, axis, 'dimensions', 'topn') || + (isMatrixifyVisible(controls, axis, 'dimensions', 'all') && + controls?.[`matrixify_all_sort_by_${axis}`]?.value === 'metric'), mapStateToProps: (state, controlState) => { const { controls, datasource } = state; - const isVisible = isMatrixifyVisible( - controls, - axis, - 'dimensions', - 'topn', - ); + const isVisible = + isMatrixifyVisible(controls, axis, 'dimensions', 'topn') || + (isMatrixifyVisible(controls, axis, 'dimensions', 'all') && + controls?.[`matrixify_all_sort_by_${axis}`]?.value === 'metric'); const originalProps = dndAdhocMetricControl.mapStateToProps?.(state, controlState) || {}; @@ -261,17 +267,31 @@ const matrixifyControls: Record> = {}; }; matrixifyControls[`matrixify_topn_order_${axis}`] = { - type: 'RadioButtonControl', - label: t(`Sort order`), - default: 'desc', - options: [ - ['asc', t('Ascending')], - ['desc', t('Descending')], - ], + type: 'CheckboxControl', + label: t('Sort descending'), + default: true, renderTrigger: true, tabOverride: 'matrixify', visibility: ({ controls }) => - isMatrixifyVisible(controls, axis, 'dimensions', 'topn'), + isMatrixifyVisible(controls, axis, 'dimensions', 'topn') || + (isMatrixifyVisible(controls, axis, 'dimensions', 'all') && + controls?.[`matrixify_all_sort_by_${axis}`]?.value === 'metric'), + }; + + matrixifyControls[`matrixify_all_sort_by_${axis}`] = { + type: 'SelectControl', + label: t('Sort by'), + default: 'a_to_z', + clearable: false, + renderTrigger: true, + tabOverride: 'matrixify', + visibility: ({ controls }) => + isMatrixifyVisible(controls, axis, 'dimensions', 'all'), + choices: [ + ['a_to_z', t('A-Z')], + ['z_to_a', t('Z-A')], + ['metric', t('Metric')], + ], }; }); @@ -317,24 +337,6 @@ matrixifyControls.matrixify_charts_per_row = { !controls?.matrixify_fit_columns_dynamically?.value, }; -matrixifyControls.matrixify_enable_vertical_layout = { - type: 'CheckboxControl', - label: t('Enable vertical layout (rows)'), - description: t('Create matrix rows by stacking charts vertically'), - default: false, - renderTrigger: true, - tabOverride: 'matrixify', -}; - -matrixifyControls.matrixify_enable_horizontal_layout = { - type: 'CheckboxControl', - label: t('Enable horizontal layout (columns)'), - description: t('Create matrix columns by placing charts side-by-side'), - default: false, - renderTrigger: true, - tabOverride: 'matrixify', -}; - // Cell title control for Matrixify matrixifyControls.matrixify_cell_title_template = { type: 'TextControl', @@ -345,8 +347,8 @@ matrixifyControls.matrixify_cell_title_template = { default: '', renderTrigger: true, visibility: ({ controls }) => - controls?.matrixify_enable_vertical_layout?.value === true || - controls?.matrixify_enable_horizontal_layout?.value === true, + isMatrixifyVisible(controls, 'rows') || + isMatrixifyVisible(controls, 'columns'), }; // Matrix display controls @@ -357,8 +359,7 @@ matrixifyControls.matrixify_show_row_labels = { default: true, renderTrigger: true, tabOverride: 'matrixify', - visibility: ({ controls }) => - controls?.matrixify_enable_vertical_layout?.value === true, + visibility: ({ controls }) => isMatrixifyVisible(controls, 'rows'), }; matrixifyControls.matrixify_show_column_headers = { @@ -368,8 +369,7 @@ matrixifyControls.matrixify_show_column_headers = { default: true, renderTrigger: true, tabOverride: 'matrixify', - visibility: ({ controls }) => - controls?.matrixify_enable_horizontal_layout?.value === true, + visibility: ({ controls }) => isMatrixifyVisible(controls, 'columns'), }; export { matrixifyControls }; diff --git a/superset-frontend/packages/superset-ui-core/src/chart/components/Matrixify/MatrixifyGridRenderer.test.tsx b/superset-frontend/packages/superset-ui-core/src/chart/components/Matrixify/MatrixifyGridRenderer.test.tsx index 7c1fc8d7bc7..588585372da 100644 --- a/superset-frontend/packages/superset-ui-core/src/chart/components/Matrixify/MatrixifyGridRenderer.test.tsx +++ b/superset-frontend/packages/superset-ui-core/src/chart/components/Matrixify/MatrixifyGridRenderer.test.tsx @@ -22,6 +22,7 @@ import '@testing-library/jest-dom'; import { ThemeProvider } from '@apache-superset/core/theme'; import { supersetTheme } from '@apache-superset/core/theme'; import MatrixifyGridRenderer from './MatrixifyGridRenderer'; +import type { MatrixifyMode } from '../../types/matrixify'; import { generateMatrixifyGrid } from './MatrixifyGridGenerator'; // Mock the MatrixifyGridGenerator @@ -74,8 +75,9 @@ test('should create single group when fitting columns dynamically', () => { const formData = { viz_type: 'test_chart', - matrixify_enable_vertical_layout: true, - matrixify_enable_horizontal_layout: true, + matrixify_enable: true, + matrixify_mode_rows: 'metrics' as MatrixifyMode, + matrixify_mode_columns: 'metrics' as MatrixifyMode, matrixify_fit_columns_dynamically: true, matrixify_charts_per_row: 3, matrixify_show_row_labels: true, @@ -124,8 +126,9 @@ test('should create multiple groups when not fitting columns dynamically', () => const formData = { viz_type: 'test_chart', - matrixify_enable_vertical_layout: true, - matrixify_enable_horizontal_layout: true, + matrixify_enable: true, + matrixify_mode_rows: 'metrics' as MatrixifyMode, + matrixify_mode_columns: 'metrics' as MatrixifyMode, matrixify_fit_columns_dynamically: false, matrixify_charts_per_row: 3, matrixify_show_row_labels: true, @@ -160,8 +163,9 @@ test('should handle exact division of columns', () => { const formData = { viz_type: 'test_chart', - matrixify_enable_vertical_layout: true, - matrixify_enable_horizontal_layout: true, + matrixify_enable: true, + matrixify_mode_rows: 'metrics' as MatrixifyMode, + matrixify_mode_columns: 'metrics' as MatrixifyMode, matrixify_fit_columns_dynamically: false, matrixify_charts_per_row: 2, matrixify_show_row_labels: true, @@ -189,8 +193,9 @@ test('should handle case where charts_per_row exceeds total columns', () => { const formData = { viz_type: 'test_chart', - matrixify_enable_vertical_layout: true, - matrixify_enable_horizontal_layout: true, + matrixify_enable: true, + matrixify_mode_rows: 'metrics' as MatrixifyMode, + matrixify_mode_columns: 'metrics' as MatrixifyMode, matrixify_fit_columns_dynamically: false, matrixify_charts_per_row: 5, matrixify_show_row_labels: true, @@ -220,8 +225,9 @@ test('should show headers for each group when wrapping occurs', () => { const formData = { viz_type: 'test_chart', - matrixify_enable_vertical_layout: true, - matrixify_enable_horizontal_layout: true, + matrixify_enable: true, + matrixify_mode_rows: 'metrics' as MatrixifyMode, + matrixify_mode_columns: 'metrics' as MatrixifyMode, matrixify_fit_columns_dynamically: false, matrixify_charts_per_row: 2, matrixify_show_row_labels: true, @@ -255,8 +261,9 @@ test('should show headers only on first row when not wrapping', () => { const formData = { viz_type: 'test_chart', - matrixify_enable_vertical_layout: true, - matrixify_enable_horizontal_layout: true, + matrixify_enable: true, + matrixify_mode_rows: 'metrics' as MatrixifyMode, + matrixify_mode_columns: 'metrics' as MatrixifyMode, matrixify_fit_columns_dynamically: true, // No wrapping matrixify_show_row_labels: true, matrixify_show_column_headers: true, @@ -285,8 +292,9 @@ test('should hide headers when disabled', () => { const formData = { viz_type: 'test_chart', - matrixify_enable_vertical_layout: true, - matrixify_enable_horizontal_layout: true, + matrixify_enable: true, + matrixify_mode_rows: 'metrics' as MatrixifyMode, + matrixify_mode_columns: 'metrics' as MatrixifyMode, matrixify_show_row_labels: false, matrixify_show_column_headers: false, }; @@ -313,8 +321,9 @@ test('should place cells correctly in wrapped layout', () => { const formData = { viz_type: 'test_chart', - matrixify_enable_vertical_layout: true, - matrixify_enable_horizontal_layout: true, + matrixify_enable: true, + matrixify_mode_rows: 'metrics' as MatrixifyMode, + matrixify_mode_columns: 'metrics' as MatrixifyMode, matrixify_fit_columns_dynamically: false, matrixify_charts_per_row: 2, matrixify_show_row_labels: true, @@ -344,8 +353,9 @@ test('should handle null grid gracefully', () => { const formData = { viz_type: 'test_chart', - matrixify_enable_vertical_layout: true, - matrixify_enable_horizontal_layout: true, + matrixify_enable: true, + matrixify_mode_rows: 'metrics' as MatrixifyMode, + matrixify_mode_columns: 'metrics' as MatrixifyMode, }; const { container } = renderWithTheme( @@ -366,8 +376,9 @@ test('should handle empty grid gracefully', () => { const formData = { viz_type: 'test_chart', - matrixify_enable_vertical_layout: true, - matrixify_enable_horizontal_layout: true, + matrixify_enable: true, + matrixify_mode_rows: 'metrics' as MatrixifyMode, + matrixify_mode_columns: 'metrics' as MatrixifyMode, }; const { container } = renderWithTheme( @@ -391,8 +402,9 @@ test('should use default values for missing configuration', () => { const formData = { viz_type: 'test_chart', - matrixify_enable_vertical_layout: true, - matrixify_enable_horizontal_layout: true, + matrixify_enable: true, + matrixify_mode_rows: 'metrics' as MatrixifyMode, + matrixify_mode_columns: 'metrics' as MatrixifyMode, // Missing optional configurations }; diff --git a/superset-frontend/packages/superset-ui-core/src/chart/components/Matrixify/MatrixifyGridRenderer.tsx b/superset-frontend/packages/superset-ui-core/src/chart/components/Matrixify/MatrixifyGridRenderer.tsx index 90402f8e782..fc4b6422459 100644 --- a/superset-frontend/packages/superset-ui-core/src/chart/components/Matrixify/MatrixifyGridRenderer.tsx +++ b/superset-frontend/packages/superset-ui-core/src/chart/components/Matrixify/MatrixifyGridRenderer.tsx @@ -130,10 +130,12 @@ function MatrixifyGridRenderer({ // Determine layout parameters - only show headers/labels if layout is enabled const showRowLabels = - formData.matrixify_enable_vertical_layout === true && + formData.matrixify_mode_rows !== undefined && + formData.matrixify_mode_rows !== 'disabled' && (formData.matrixify_show_row_labels ?? true); const showColumnHeaders = - formData.matrixify_enable_horizontal_layout === true && + formData.matrixify_mode_columns !== undefined && + formData.matrixify_mode_columns !== 'disabled' && (formData.matrixify_show_column_headers ?? true); const rowHeight = formData.matrixify_row_height || DEFAULT_ROW_HEIGHT; const fitColumnsDynamically = diff --git a/superset-frontend/packages/superset-ui-core/src/chart/types/matrixify.test.ts b/superset-frontend/packages/superset-ui-core/src/chart/types/matrixify.test.ts index 6fe052a5a74..b0eaf837fcd 100644 --- a/superset-frontend/packages/superset-ui-core/src/chart/types/matrixify.test.ts +++ b/superset-frontend/packages/superset-ui-core/src/chart/types/matrixify.test.ts @@ -37,12 +37,11 @@ test('isMatrixifyEnabled should return false when no matrixify configuration exi expect(isMatrixifyEnabled(formData)).toBe(false); }); -test('isMatrixifyEnabled should return false when layout controls are false', () => { +test('isMatrixifyEnabled should return false when layout controls are disabled', () => { const formData = { viz_type: 'table', - matrixify_enable_vertical_layout: false, - matrixify_enable_horizontal_layout: false, - matrixify_mode_rows: 'metrics', + matrixify_mode_rows: 'disabled', + matrixify_mode_columns: 'disabled', matrixify_rows: [createMetric('Revenue')], } as MatrixifyFormData; @@ -52,7 +51,7 @@ test('isMatrixifyEnabled should return false when layout controls are false', () test('isMatrixifyEnabled should return true for valid metrics mode configuration', () => { const formData = { viz_type: 'table', - matrixify_enable_vertical_layout: true, + matrixify_enable: true, matrixify_mode_rows: 'metrics', matrixify_mode_columns: 'metrics', matrixify_rows: [createMetric('Revenue')], @@ -65,7 +64,7 @@ test('isMatrixifyEnabled should return true for valid metrics mode configuration test('isMatrixifyEnabled should return true for valid dimensions mode configuration', () => { const formData = { viz_type: 'table', - matrixify_enable_vertical_layout: true, + matrixify_enable: true, matrixify_mode_rows: 'dimensions', matrixify_mode_columns: 'dimensions', matrixify_dimension_rows: { dimension: 'country', values: ['USA'] }, @@ -78,7 +77,7 @@ test('isMatrixifyEnabled should return true for valid dimensions mode configurat test('isMatrixifyEnabled should return true for mixed mode configuration', () => { const formData = { viz_type: 'table', - matrixify_enable_vertical_layout: true, + matrixify_enable: true, matrixify_mode_rows: 'metrics', matrixify_mode_columns: 'dimensions', matrixify_rows: [createMetric('Revenue')], @@ -91,7 +90,7 @@ test('isMatrixifyEnabled should return true for mixed mode configuration', () => test('isMatrixifyEnabled should return true for topn dimension selection mode', () => { const formData = { viz_type: 'table', - matrixify_enable_vertical_layout: true, + matrixify_enable: true, matrixify_mode_rows: 'dimensions', matrixify_mode_columns: 'dimensions', matrixify_dimension_rows: { @@ -110,7 +109,7 @@ test('isMatrixifyEnabled should return true for topn dimension selection mode', test('isMatrixifyEnabled should return false when both axes have empty metrics arrays', () => { const formData = { viz_type: 'table', - matrixify_enable_vertical_layout: true, + matrixify_enable: true, matrixify_mode_rows: 'metrics', matrixify_mode_columns: 'metrics', matrixify_rows: [], @@ -123,7 +122,7 @@ test('isMatrixifyEnabled should return false when both axes have empty metrics a test('isMatrixifyEnabled should return false when both dimensions have empty values and no topn mode', () => { const formData = { viz_type: 'table', - matrixify_enable_vertical_layout: true, + matrixify_enable: true, matrixify_mode_rows: 'dimensions', matrixify_mode_columns: 'dimensions', matrixify_dimension_rows: { dimension: 'country', values: [] }, @@ -141,7 +140,6 @@ test('getMatrixifyConfig should return null when no matrixify configuration exis test('getMatrixifyConfig should return valid config for metrics mode', () => { const formData = { viz_type: 'table', - matrixify_enable_vertical_layout: true, matrixify_mode_rows: 'metrics', matrixify_mode_columns: 'metrics', matrixify_rows: [createMetric('Revenue')], @@ -159,7 +157,6 @@ test('getMatrixifyConfig should return valid config for metrics mode', () => { test('getMatrixifyConfig should return valid config for dimensions mode', () => { const formData = { viz_type: 'table', - matrixify_enable_vertical_layout: true, matrixify_mode_rows: 'dimensions', matrixify_mode_columns: 'dimensions', matrixify_dimension_rows: { dimension: 'country', values: ['USA'] }, @@ -183,7 +180,6 @@ test('getMatrixifyConfig should return valid config for dimensions mode', () => test('getMatrixifyConfig should handle topn selection mode', () => { const formData = { viz_type: 'table', - matrixify_enable_vertical_layout: true, matrixify_mode_rows: 'dimensions', matrixify_mode_columns: 'dimensions', matrixify_dimension_rows: { @@ -204,8 +200,8 @@ test('getMatrixifyConfig should handle topn selection mode', () => { test('getMatrixifyValidationErrors should return empty array when matrixify is not enabled', () => { const formData = { viz_type: 'table', - matrixify_enable_vertical_layout: false, - matrixify_enable_horizontal_layout: false, + matrixify_mode_rows: 'disabled', + matrixify_mode_columns: 'disabled', } as MatrixifyFormData; expect(getMatrixifyValidationErrors(formData)).toEqual([]); @@ -214,7 +210,6 @@ test('getMatrixifyValidationErrors should return empty array when matrixify is n test('getMatrixifyValidationErrors should return empty array when properly configured', () => { const formData = { viz_type: 'table', - matrixify_enable_vertical_layout: true, matrixify_mode_rows: 'metrics', matrixify_mode_columns: 'metrics', matrixify_rows: [createMetric('Revenue')], @@ -227,17 +222,16 @@ test('getMatrixifyValidationErrors should return empty array when properly confi test('getMatrixifyValidationErrors should return error when enabled but no configuration exists', () => { const formData = { viz_type: 'table', - matrixify_enable_vertical_layout: true, + matrixify_mode_rows: 'metrics', } as MatrixifyFormData; const errors = getMatrixifyValidationErrors(formData); - expect(errors).toContain('Please configure at least one row or column axis'); + expect(errors.length).toBeGreaterThan(0); }); test('getMatrixifyValidationErrors should return error when metrics mode has no metrics', () => { const formData = { viz_type: 'table', - matrixify_enable_vertical_layout: true, matrixify_mode_rows: 'metrics', matrixify_rows: [], matrixify_columns: [], @@ -260,19 +254,28 @@ test('should handle empty form data object', () => { expect(isMatrixifyEnabled(formData)).toBe(false); }); -test('isMatrixifyEnabled should return false when layout enabled but no axis modes configured', () => { +test('isMatrixifyEnabled should return false when no axis modes configured', () => { const formData = { viz_type: 'table', - matrixify_enable_vertical_layout: true, + matrixify_enable: true, // No matrixify_mode_rows or matrixify_mode_columns set } as MatrixifyFormData; expect(isMatrixifyEnabled(formData)).toBe(false); }); +test('isMatrixifyEnabled should return false when switch is off even with valid axis config', () => { + const formData = { + viz_type: 'table', + matrixify_enable: false, + matrixify_mode_rows: 'metrics', + matrixify_rows: [createMetric('Revenue')], + } as MatrixifyFormData; + expect(isMatrixifyEnabled(formData)).toBe(false); +}); + test('getMatrixifyValidationErrors should return dimension error for rows when dimension has no data', () => { const formData = { viz_type: 'table', - matrixify_enable_vertical_layout: true, matrixify_mode_rows: 'dimensions', // No matrixify_dimension_rows set matrixify_mode_columns: 'metrics', @@ -286,7 +289,6 @@ test('getMatrixifyValidationErrors should return dimension error for rows when d test('getMatrixifyValidationErrors should return metric error for columns when metrics array is empty', () => { const formData = { viz_type: 'table', - matrixify_enable_vertical_layout: true, matrixify_mode_rows: 'metrics', matrixify_rows: [createMetric('Revenue')], matrixify_mode_columns: 'metrics', @@ -300,7 +302,6 @@ test('getMatrixifyValidationErrors should return metric error for columns when m test('getMatrixifyValidationErrors should return dimension error for columns when no dimension data', () => { const formData = { viz_type: 'table', - matrixify_enable_vertical_layout: true, matrixify_mode_rows: 'metrics', matrixify_rows: [createMetric('Revenue')], matrixify_mode_columns: 'dimensions', @@ -311,10 +312,9 @@ test('getMatrixifyValidationErrors should return dimension error for columns whe expect(errors).toContain('Please select a dimension and values for columns'); }); -test('getMatrixifyValidationErrors skips row check when matrixify_mode_rows is not set (line 240 false, line 279 || false)', () => { +test('getMatrixifyValidationErrors skips row check when matrixify_mode_rows is not set', () => { const formData = { viz_type: 'table', - matrixify_enable_vertical_layout: true, // No matrixify_mode_rows — hasRowMode = false matrixify_mode_columns: 'metrics', matrixify_columns: [createMetric('Q1')], @@ -324,10 +324,9 @@ test('getMatrixifyValidationErrors skips row check when matrixify_mode_rows is n expect(errors).toEqual([]); }); -test('getMatrixifyValidationErrors evaluates full && expression when dimension is set but values are empty (lines 244, 264, 283, 291 true branches)', () => { +test('getMatrixifyValidationErrors evaluates full && expression when dimension is set but values are empty', () => { const formData = { viz_type: 'table', - matrixify_enable_vertical_layout: true, matrixify_mode_rows: 'dimensions', matrixify_dimension_rows: { dimension: 'country', values: [] }, matrixify_mode_columns: 'dimensions', @@ -345,7 +344,7 @@ test('getMatrixifyValidationErrors evaluates full && expression when dimension i test('should handle partial configuration with one axis only', () => { const formData = { viz_type: 'table', - matrixify_enable_vertical_layout: true, + matrixify_enable: true, matrixify_mode_rows: 'metrics', matrixify_rows: [createMetric('Revenue')], // No columns configuration diff --git a/superset-frontend/packages/superset-ui-core/src/chart/types/matrixify.ts b/superset-frontend/packages/superset-ui-core/src/chart/types/matrixify.ts index 8c01651ca60..e43dbe31406 100644 --- a/superset-frontend/packages/superset-ui-core/src/chart/types/matrixify.ts +++ b/superset-frontend/packages/superset-ui-core/src/chart/types/matrixify.ts @@ -23,6 +23,7 @@ import { AdhocMetric } from '../../query'; * Constants for Matrixify filter generation * These match the literal types used in Filter.ts */ + export const MatrixifyFilterConstants = { // Filter expression types ExpressionType: { @@ -46,12 +47,12 @@ export const MatrixifyFilterConstants = { /** * Mode for selecting matrix axis values */ -export type MatrixifyMode = 'metrics' | 'dimensions'; +export type MatrixifyMode = 'disabled' | 'metrics' | 'dimensions'; /** * Selection method for dimension values */ -export type MatrixifySelectionMode = 'members' | 'topn'; +export type MatrixifySelectionMode = 'members' | 'topn' | 'all'; /** * Sort order for top N selection @@ -96,18 +97,18 @@ export interface MatrixifyAxisConfig { * Complete Matrixify configuration in form data */ export interface MatrixifyFormData { - // Layout enable controls - matrixify_enable_vertical_layout?: boolean; - matrixify_enable_horizontal_layout?: boolean; + // Global enable switch + matrixify_enable?: boolean; - // Row axis configuration + // Row axis configuration (mode 'disabled' means axis is off) matrixify_mode_rows?: MatrixifyMode; matrixify_rows?: AdhocMetric[]; matrixify_dimension_selection_mode_rows?: MatrixifySelectionMode; matrixify_dimension_rows?: MatrixifyDimensionValue; matrixify_topn_value_rows?: number; matrixify_topn_metric_rows?: AdhocMetric; - matrixify_topn_order_rows?: MatrixifySortOrder; + matrixify_topn_order_rows?: boolean; + matrixify_all_sort_by_rows?: 'a_to_z' | 'z_to_a' | 'metric'; // Column axis configuration matrixify_mode_columns?: MatrixifyMode; @@ -116,7 +117,8 @@ export interface MatrixifyFormData { matrixify_dimension_columns?: MatrixifyDimensionValue; matrixify_topn_value_columns?: number; matrixify_topn_metric_columns?: AdhocMetric; - matrixify_topn_order_columns?: MatrixifySortOrder; + matrixify_topn_order_columns?: boolean; + matrixify_all_sort_by_columns?: 'a_to_z' | 'z_to_a' | 'metric'; // Grid layout configuration matrixify_row_height?: number; @@ -139,75 +141,85 @@ export interface MatrixifyConfig { columns: MatrixifyAxisConfig; } +/** + * Check if a given axis mode is active (not disabled) + */ +function isAxisEnabled(mode?: MatrixifyMode): boolean { + return mode === 'metrics' || mode === 'dimensions'; +} + /** * Helper function to extract Matrixify configuration from form data */ export function getMatrixifyConfig( formData: MatrixifyFormData & any, ): MatrixifyConfig | null { - const hasRowConfig = formData.matrixify_mode_rows; - const hasColumnConfig = formData.matrixify_mode_columns; + const rowEnabled = isAxisEnabled(formData.matrixify_mode_rows); + const colEnabled = isAxisEnabled(formData.matrixify_mode_columns); - if (!hasRowConfig && !hasColumnConfig) { + if (!rowEnabled && !colEnabled) { return null; } return { rows: { - mode: formData.matrixify_mode_rows || 'metrics', + mode: formData.matrixify_mode_rows || 'disabled', metrics: formData.matrixify_rows, selectionMode: formData.matrixify_dimension_selection_mode_rows, dimension: formData.matrixify_dimension_rows, topnValue: formData.matrixify_topn_value_rows, topnMetric: formData.matrixify_topn_metric_rows, - topnOrder: formData.matrixify_topn_order_rows, + topnOrder: formData.matrixify_topn_order_rows === false ? 'asc' : 'desc', }, columns: { - mode: formData.matrixify_mode_columns || 'metrics', + mode: formData.matrixify_mode_columns || 'disabled', metrics: formData.matrixify_columns, selectionMode: formData.matrixify_dimension_selection_mode_columns, dimension: formData.matrixify_dimension_columns, topnValue: formData.matrixify_topn_value_columns, topnMetric: formData.matrixify_topn_metric_columns, - topnOrder: formData.matrixify_topn_order_columns, + topnOrder: + formData.matrixify_topn_order_columns === false ? 'asc' : 'desc', }, }; } -/** - * Check if Matrixify is enabled and properly configured - */ export function isMatrixifyEnabled(formData: MatrixifyFormData): boolean { - // Check if either vertical or horizontal layout is enabled - const hasVerticalLayout = formData.matrixify_enable_vertical_layout === true; - const hasHorizontalLayout = - formData.matrixify_enable_horizontal_layout === true; - - if (!hasVerticalLayout && !hasHorizontalLayout) { + if (formData.matrixify_enable !== true) { + return false; + } + + const rowEnabled = isAxisEnabled(formData.matrixify_mode_rows); + const colEnabled = isAxisEnabled(formData.matrixify_mode_columns); + + if (!rowEnabled && !colEnabled) { return false; } - // Then validate that we have proper configuration const config = getMatrixifyConfig(formData); if (!config) { return false; } const hasRowData = - config.rows.mode === 'metrics' + rowEnabled && + (config.rows.mode === 'metrics' ? config.rows.metrics && config.rows.metrics.length > 0 : config.rows.dimension?.dimension && (config.rows.selectionMode === 'topn' || + config.rows.selectionMode === 'all' || (config.rows.dimension.values && - config.rows.dimension.values.length > 0)); + config.rows.dimension.values.length > 0))); const hasColumnData = - config.columns.mode === 'metrics' + colEnabled && + (config.columns.mode === 'metrics' ? config.columns.metrics && config.columns.metrics.length > 0 : config.columns.dimension?.dimension && (config.columns.selectionMode === 'topn' || + config.columns.selectionMode === 'all' || (config.columns.dimension.values && - config.columns.dimension.values.length > 0)); + config.columns.dimension.values.length > 0))); return Boolean(hasRowData || hasColumnData); } @@ -220,12 +232,10 @@ export function getMatrixifyValidationErrors( ): string[] { const errors: string[] = []; - // Only validate if matrixify is enabled - const hasVerticalLayout = formData.matrixify_enable_vertical_layout === true; - const hasHorizontalLayout = - formData.matrixify_enable_horizontal_layout === true; + const rowEnabled = isAxisEnabled(formData.matrixify_mode_rows); + const colEnabled = isAxisEnabled(formData.matrixify_mode_columns); - if (!hasVerticalLayout && !hasHorizontalLayout) { + if (!rowEnabled && !colEnabled) { return errors; } @@ -243,6 +253,7 @@ export function getMatrixifyValidationErrors( ? config.rows.metrics && config.rows.metrics.length > 0 : config.rows.dimension?.dimension && (config.rows.selectionMode === 'topn' || + config.rows.selectionMode === 'all' || (config.rows.dimension.values && config.rows.dimension.values.length > 0)); @@ -263,6 +274,7 @@ export function getMatrixifyValidationErrors( ? config.columns.metrics && config.columns.metrics.length > 0 : config.columns.dimension?.dimension && (config.columns.selectionMode === 'topn' || + config.columns.selectionMode === 'all' || (config.columns.dimension.values && config.columns.dimension.values.length > 0)); @@ -281,6 +293,7 @@ export function getMatrixifyValidationErrors( ? config.rows.metrics && config.rows.metrics.length > 0 : config.rows.dimension?.dimension && (config.rows.selectionMode === 'topn' || + config.rows.selectionMode === 'all' || (config.rows.dimension.values && config.rows.dimension.values.length > 0)); @@ -289,6 +302,7 @@ export function getMatrixifyValidationErrors( ? config.columns.metrics && config.columns.metrics.length > 0 : config.columns.dimension?.dimension && (config.columns.selectionMode === 'topn' || + config.columns.selectionMode === 'all' || (config.columns.dimension.values && config.columns.dimension.values.length > 0)); diff --git a/superset-frontend/src/components/Chart/ChartContextMenu/ChartContextMenu.tsx b/superset-frontend/src/components/Chart/ChartContextMenu/ChartContextMenu.tsx index ecb52a7d87d..cdab2f54af5 100644 --- a/superset-frontend/src/components/Chart/ChartContextMenu/ChartContextMenu.tsx +++ b/superset-frontend/src/components/Chart/ChartContextMenu/ChartContextMenu.tsx @@ -187,8 +187,10 @@ const ChartContextMenu = ( canDrillBy && isDisplayed(ContextMenuItem.DrillBy) && !( - formData.matrixify_enable_vertical_layout === true || - formData.matrixify_enable_horizontal_layout === true + (formData.matrixify_mode_rows !== undefined && + formData.matrixify_mode_rows !== 'disabled') || + (formData.matrixify_mode_columns !== undefined && + formData.matrixify_mode_columns !== 'disabled') ); // Disable drill by when matrixify is enabled const datasetResource = useDatasetDrillInfo( diff --git a/superset-frontend/src/components/Chart/ChartRenderer.test.tsx b/superset-frontend/src/components/Chart/ChartRenderer.test.tsx index 1c35af4efc8..9fd400fd562 100644 --- a/superset-frontend/src/components/Chart/ChartRenderer.test.tsx +++ b/superset-frontend/src/components/Chart/ChartRenderer.test.tsx @@ -163,7 +163,7 @@ test('should detect changes in matrixify properties', () => { ...requiredProps.formData, datasource: '', viz_type: VizType.Table, - matrixify_enable_vertical_layout: true, + matrixify_mode_rows: 'metrics', matrixify_dimension_x: { dimension: 'country', values: ['USA'] }, matrixify_dimension_y: { dimension: 'category', values: ['Tech'] }, matrixify_charts_per_row: 3, @@ -177,9 +177,9 @@ test('should detect changes in matrixify properties', () => { // Since we can't directly test shouldComponentUpdate, we verify the component // correctly identifies matrixify-related properties by checking the implementation - expect( - (initialProps.formData as JsonObject).matrixify_enable_vertical_layout, - ).toBe(true); + expect((initialProps.formData as JsonObject).matrixify_mode_rows).toBe( + 'metrics', + ); expect((initialProps.formData as JsonObject).matrixify_dimension_x).toEqual({ dimension: 'country', values: ['USA'], @@ -212,7 +212,7 @@ test('should identify matrixify property changes correctly', () => { formData: { datasource: '', viz_type: VizType.Table, - matrixify_enable_vertical_layout: true, + matrixify_mode_rows: 'metrics', matrixify_dimension_x: { dimension: 'country', values: ['USA'] }, matrixify_charts_per_row: 3, }, @@ -234,7 +234,7 @@ test('should identify matrixify property changes correctly', () => { formData: { datasource: '', viz_type: VizType.Table, - matrixify_enable_vertical_layout: true, + matrixify_mode_rows: 'metrics', matrixify_dimension_x: { dimension: 'country', values: ['USA', 'Canada'], // Changed @@ -277,7 +277,7 @@ test('should handle matrixify-related form data changes', () => { formData: { datasource: '', viz_type: VizType.Table, - matrixify_enable_vertical_layout: true, // This is a significant change + matrixify_mode_rows: 'metrics', // This is a significant change regular_control: 'value1', }, }; @@ -296,7 +296,7 @@ test('should detect matrixify property addition', () => { formData: { datasource: '', viz_type: VizType.Table, - matrixify_enable_vertical_layout: true, + matrixify_mode_rows: 'metrics', // No matrixify_dimension_x initially }, queriesResponse: [{ data: 'current' } as unknown as JsonObject], @@ -317,7 +317,7 @@ test('should detect matrixify property addition', () => { formData: { datasource: '', viz_type: VizType.Table, - matrixify_enable_vertical_layout: true, + matrixify_mode_rows: 'metrics', matrixify_dimension_x: { dimension: 'country', values: ['USA'] }, // Added }, }; @@ -336,7 +336,7 @@ test('should detect nested matrixify property changes', () => { formData: { datasource: '', viz_type: VizType.Table, - matrixify_enable_vertical_layout: true, + matrixify_mode_rows: 'metrics', matrixify_dimension_x: { dimension: 'country', values: ['USA'], @@ -361,7 +361,7 @@ test('should detect nested matrixify property changes', () => { formData: { datasource: '', viz_type: VizType.Table, - matrixify_enable_vertical_layout: true, + matrixify_mode_rows: 'metrics', matrixify_dimension_x: { dimension: 'country', values: ['USA'], diff --git a/superset-frontend/src/components/Chart/ChartRenderer.tsx b/superset-frontend/src/components/Chart/ChartRenderer.tsx index 33528f78a38..b58b3966b36 100644 --- a/superset-frontend/src/components/Chart/ChartRenderer.tsx +++ b/superset-frontend/src/components/Chart/ChartRenderer.tsx @@ -275,8 +275,10 @@ class ChartRenderer extends Component { const nextFormData = nextProps.formData as JsonObject; const currentFormData = this.props.formData as JsonObject; const isMatrixifyEnabled = - nextFormData.matrixify_enable_vertical_layout === true || - nextFormData.matrixify_enable_horizontal_layout === true; + (nextFormData.matrixify_mode_rows !== undefined && + nextFormData.matrixify_mode_rows !== 'disabled') || + (nextFormData.matrixify_mode_columns !== undefined && + nextFormData.matrixify_mode_columns !== 'disabled'); if (!isMatrixifyEnabled) return false; // Check all matrixify-related properties diff --git a/superset-frontend/src/components/Chart/DrillBy/DrillBySubmenu.test.tsx b/superset-frontend/src/components/Chart/DrillBy/DrillBySubmenu.test.tsx index 3b60e756911..1a7f23a9e4a 100644 --- a/superset-frontend/src/components/Chart/DrillBy/DrillBySubmenu.test.tsx +++ b/superset-frontend/src/components/Chart/DrillBy/DrillBySubmenu.test.tsx @@ -272,9 +272,9 @@ test('When menu item is clicked, call onSelection with clicked column and drill ); }); -test('matrixify_enable_vertical_layout should not render component', () => { +test('matrixify_mode_rows enabled should not render component', () => { const { container } = renderSubmenu({ - formData: { ...defaultFormData, matrixify_enable_vertical_layout: true }, + formData: { ...defaultFormData, matrixify_mode_rows: 'metrics' }, }); expect(container).toBeEmptyDOMElement(); }); diff --git a/superset-frontend/src/components/Chart/DrillBy/DrillBySubmenu.tsx b/superset-frontend/src/components/Chart/DrillBy/DrillBySubmenu.tsx index 8bb347e7dfa..40e18a264be 100644 --- a/superset-frontend/src/components/Chart/DrillBy/DrillBySubmenu.tsx +++ b/superset-frontend/src/components/Chart/DrillBy/DrillBySubmenu.tsx @@ -179,8 +179,10 @@ export const DrillBySubmenu = ({ } if ( - formData.matrixify_enable_vertical_layout === true || - formData.matrixify_enable_horizontal_layout === true + (formData.matrixify_mode_rows !== undefined && + formData.matrixify_mode_rows !== 'disabled') || + (formData.matrixify_mode_columns !== undefined && + formData.matrixify_mode_columns !== 'disabled') ) { return null; } diff --git a/superset-frontend/src/explore/components/ControlPanelsContainer.test.tsx b/superset-frontend/src/explore/components/ControlPanelsContainer.test.tsx index fe67e52eb6f..466c56ee1ac 100644 --- a/superset-frontend/src/explore/components/ControlPanelsContainer.test.tsx +++ b/superset-frontend/src/explore/components/ControlPanelsContainer.test.tsx @@ -307,7 +307,8 @@ describe('ControlPanelsContainer', () => { props.form_data = { ...props.form_data, viz_type: 'line', - matrixify_enable_vertical_layout: true, + matrixify_enable: true, + matrixify_mode_rows: 'metrics', }; const { rerender } = render(, { @@ -327,7 +328,8 @@ describe('ControlPanelsContainer', () => { form_data: { ...props.form_data, viz_type: 'line', - matrixify_enable_vertical_layout: true, + matrixify_enable: true, + matrixify_mode_rows: 'metrics', matrixify_dimension_columns: { dimension: 'country', values: ['USA', 'Canada'], @@ -381,7 +383,8 @@ describe('ControlPanelsContainer', () => { form_data: { ...props.form_data, viz_type: 'line', - matrixify_enable_horizontal_layout: true, + matrixify_enable: true, + matrixify_mode_columns: 'metrics', }, }; diff --git a/superset-frontend/src/explore/components/ControlPanelsContainer.tsx b/superset-frontend/src/explore/components/ControlPanelsContainer.tsx index fba0cc3c0fd..0a1c79ab9fe 100644 --- a/superset-frontend/src/explore/components/ControlPanelsContainer.tsx +++ b/superset-frontend/src/explore/components/ControlPanelsContainer.tsx @@ -816,8 +816,11 @@ export const ControlPanelsContainer = (props: ControlPanelsContainerProps) => { // Check if matrixify is enabled in form_data const matrixifyIsEnabled = - form_data.matrixify_enable_vertical_layout || - form_data.matrixify_enable_horizontal_layout; + form_data.matrixify_enable === true && + ((form_data.matrixify_mode_rows !== undefined && + form_data.matrixify_mode_rows !== 'disabled') || + (form_data.matrixify_mode_columns !== undefined && + form_data.matrixify_mode_columns !== 'disabled')); // Auto-switch to Matrixify tab when it's enabled useEffect(() => { diff --git a/superset-frontend/src/explore/components/ExploreChartHeader/ExploreChartHeader.test.tsx b/superset-frontend/src/explore/components/ExploreChartHeader/ExploreChartHeader.test.tsx index 3417dab6476..c060c193c3c 100644 --- a/superset-frontend/src/explore/components/ExploreChartHeader/ExploreChartHeader.test.tsx +++ b/superset-frontend/src/explore/components/ExploreChartHeader/ExploreChartHeader.test.tsx @@ -549,7 +549,7 @@ describe('ExploreChartHeader', () => { const props = createProps({ formData: { ...createProps().chart.latestQueryFormData, - matrixify_enable_vertical_layout: true, + matrixify_enable: true, matrixify_mode_rows: 'metrics', matrixify_rows: [{ label: 'COUNT(*)', expressionType: 'SIMPLE' }], }, diff --git a/superset-frontend/src/explore/components/ExploreChartPanel/index.tsx b/superset-frontend/src/explore/components/ExploreChartPanel/index.tsx index b7bd508f959..3160189c3fb 100644 --- a/superset-frontend/src/explore/components/ExploreChartPanel/index.tsx +++ b/superset-frontend/src/explore/components/ExploreChartPanel/index.tsx @@ -455,8 +455,10 @@ const ExploreChartPanel = ({ })} {...(chart.chartStatus && { chartStatus: chart.chartStatus })} hideRowCount={ - formData?.matrixify_enable_vertical_layout === true || - formData?.matrixify_enable_horizontal_layout === true + (formData?.matrixify_mode_rows !== undefined && + formData?.matrixify_mode_rows !== 'disabled') || + (formData?.matrixify_mode_columns !== undefined && + formData?.matrixify_mode_columns !== 'disabled') } formData={formData} /> @@ -488,8 +490,8 @@ const ExploreChartPanel = ({ chart.chartUpdateEndTime, refreshCachedQuery, formData?.row_limit, - formData?.matrixify_enable_vertical_layout, - formData?.matrixify_enable_horizontal_layout, + formData?.matrixify_mode_rows, + formData?.matrixify_mode_columns, renderChart, theme.sizeUnit, ], diff --git a/superset-frontend/src/explore/components/controls/MatrixifyDimensionControl.tsx b/superset-frontend/src/explore/components/controls/MatrixifyDimensionControl.tsx index 3832b84d871..d1417b0f020 100644 --- a/superset-frontend/src/explore/components/controls/MatrixifyDimensionControl.tsx +++ b/superset-frontend/src/explore/components/controls/MatrixifyDimensionControl.tsx @@ -32,6 +32,7 @@ export interface MatrixifyDimensionControlValue { dimension: string; values: any[]; topNValues?: TopNValue[]; // Store topN values with their metric values + totalValueCount?: number; // Total number of distinct values for this dimension } interface MatrixifyDimensionControlProps { @@ -42,10 +43,11 @@ interface MatrixifyDimensionControlProps { description?: string; hovered?: boolean; renderTrigger?: boolean; - selectionMode?: 'members' | 'topn'; + selectionMode?: 'members' | 'topn' | 'all'; topNMetric?: string; topNValue?: number; topNOrder?: 'ASC' | 'DESC'; + allSortBy?: 'a_to_z' | 'z_to_a' | 'metric'; formData?: any; // For access to filters and time range validationErrors?: string[]; } @@ -64,6 +66,7 @@ export default function MatrixifyDimensionControl( topNMetric, topNValue, topNOrder = 'DESC', + allSortBy = 'a_to_z', formData, validationErrors, } = props; @@ -109,15 +112,19 @@ export default function MatrixifyDimensionControl( } }, [datasource]); - // Load dimension values when dimension changes + // Load dimension values when dimension changes (members mode, or all mode with A-Z/Z-A sort) + const isAllWithMetric = selectionMode === 'all' && allSortBy === 'metric'; useEffect(() => { if ( !value?.dimension || !datasource || !datasource.id || - selectionMode !== 'members' + (selectionMode !== 'members' && selectionMode !== 'all') || + isAllWithMetric ) { - setValueOptions([]); + if (selectionMode !== 'members' && !isAllWithMetric) { + setValueOptions([]); + } return undefined; } @@ -142,13 +149,43 @@ export default function MatrixifyDimensionControl( signal, endpoint, }); - const values = json.result || []; + let values = json.result || []; + + // Sort alphabetically for 'all' mode + if (selectionMode === 'all') { + const descending = allSortBy === 'z_to_a'; + values = [...values].sort((a: any, b: any) => { + const strA = String(a).toLowerCase(); + const strB = String(b).toLowerCase(); + if (strA < strB) return descending ? 1 : -1; + if (strA > strB) return descending ? -1 : 1; + return 0; + }); + } + setValueOptions( values.map((v: any) => ({ label: optionLabel(v), value: v, })), ); + + if (!signal.aborted) { + const MAX_ALL_DIMENSION_VALUES = 25; + const allValues = + selectionMode === 'all' + ? values.slice(0, MAX_ALL_DIMENSION_VALUES) + : value.values || []; + const updatedValue: MatrixifyDimensionControlValue = { + dimension: value.dimension, + values: allValues, + totalValueCount: values.length, + }; + if (value.topNValues) { + updatedValue.topNValues = value.topNValues; + } + onChange(updatedValue); + } } catch (error) { setValueOptions([]); } finally { @@ -161,7 +198,7 @@ export default function MatrixifyDimensionControl( return () => { controller.abort(); }; - }, [value?.dimension, datasource, selectionMode]); + }, [value?.dimension, datasource, selectionMode, allSortBy]); // Convert topNValue to number for consistent comparison const topNValueNum = useMemo(() => { @@ -176,17 +213,27 @@ export default function MatrixifyDimensionControl( return typeof topNValue === 'number' ? topNValue : null; }, [topNValue]); - // Load TopN values when in TopN mode + // Load TopN values when in TopN mode, or All + Metric sort useEffect(() => { - if (!value?.dimension || !datasource || selectionMode !== 'topn') { + const isTopN = selectionMode === 'topn'; + const isAllMetric = selectionMode === 'all' && allSortBy === 'metric'; + + if (!value?.dimension || !datasource || (!isTopN && !isAllMetric)) { return undefined; } - // If we don't have the required topN parameters, just return without loading - if (!topNMetric || !topNValueNum || topNValueNum <= 0) { + if (!topNMetric) { return undefined; } + // For topn mode, also require a valid limit + if (isTopN && (!topNValueNum || topNValueNum <= 0)) { + return undefined; + } + + const MAX_ALL_DIMENSION_VALUES = 25; + const limit = isAllMetric ? MAX_ALL_DIMENSION_VALUES : topNValueNum!; + const controller = new AbortController(); const { signal } = controller; @@ -199,14 +246,13 @@ export default function MatrixifyDimensionControl( datasource: datasourceId, column: value.dimension, metric: topNMetric, - limit: topNValueNum, + limit, sortAscending: topNOrder === 'ASC', filters: formData?.adhoc_filters || [], timeRange: formData?.time_range, }); if (!signal.aborted) { - // Always update with the new topN values const dimensionValues = extractDimensionValues(values); onChange({ dimension: value.dimension, @@ -217,7 +263,6 @@ export default function MatrixifyDimensionControl( } catch (error: any) { if (!signal.aborted) { setTopNError(error.message || t('Failed to load top values')); - // Clear values on error onChange({ dimension: value.dimension, values: [], @@ -236,8 +281,9 @@ export default function MatrixifyDimensionControl( value?.dimension, datasource, selectionMode, + allSortBy, topNMetric, - topNValueNum, // Use the converted/validated number + topNValueNum, topNOrder, formData?.adhoc_filters, formData?.time_range, diff --git a/superset-frontend/src/explore/components/controls/SwitchControl.tsx b/superset-frontend/src/explore/components/controls/SwitchControl.tsx new file mode 100644 index 00000000000..7823dac011d --- /dev/null +++ b/superset-frontend/src/explore/components/controls/SwitchControl.tsx @@ -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 { type ReactNode } from 'react'; +import { css } from '@apache-superset/core/theme'; +import { Switch } from '@superset-ui/core/components'; +import ControlHeader from '../ControlHeader'; + +interface SwitchControlProps { + value?: boolean; + label?: ReactNode; + description?: ReactNode; + hovered?: boolean; + onChange?: (value: boolean) => void; + validationErrors?: string[]; +} + +export default function SwitchControl({ + value = false, + onChange, + ...props +}: SwitchControlProps) { + const handleChange = (checked: boolean) => { + onChange?.(checked); + }; + + const switchNode = ( + + ); + + if (props.label) { + return ( +
+ handleChange(!value)} + /> +
+ ); + } + return switchNode; +} diff --git a/superset-frontend/src/explore/components/controls/VerticalRadioControl.tsx b/superset-frontend/src/explore/components/controls/VerticalRadioControl.tsx new file mode 100644 index 00000000000..ee382d7faf9 --- /dev/null +++ b/superset-frontend/src/explore/components/controls/VerticalRadioControl.tsx @@ -0,0 +1,104 @@ +/** + * 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 { type ReactNode } from 'react'; +import { css, useTheme } from '@apache-superset/core/theme'; +import { JsonValue } from '@superset-ui/core'; +import { Radio } from '@superset-ui/core/components/Radio'; +import { Space } from '@superset-ui/core/components/Space'; +import { Tooltip } from '@superset-ui/core/components/Tooltip'; +import { Icons } from '@superset-ui/core/components/Icons'; +import ControlHeader from '../ControlHeader'; + +interface RadioOption { + value: JsonValue; + label: ReactNode; + disabled?: boolean; + tooltip?: string; +} + +type RadioOptionTuple = [JsonValue, ReactNode]; + +interface VerticalRadioControlProps { + value?: JsonValue; + label?: ReactNode; + description?: ReactNode; + hovered?: boolean; + options: (RadioOption | RadioOptionTuple)[]; + onChange: (value: JsonValue) => void; + validationErrors?: string[]; +} + +function normalizeOption(option: RadioOption | RadioOptionTuple): RadioOption { + if (Array.isArray(option)) { + return { value: option[0], label: option[1] }; + } + return option; +} + +export default function VerticalRadioControl({ + value: initialValue, + options, + onChange, + ...props +}: VerticalRadioControlProps) { + const theme = useTheme(); + const normalizedOptions = options.map(normalizeOption); + const currentValue = initialValue ?? normalizedOptions[0]?.value; + + return ( +
+ + onChange(e.target.value)} + > + + {normalizedOptions.map( + ({ value: val, label, disabled = false, tooltip }) => ( + + {label} + {tooltip && ( + + + + )} + + ), + )} + + +
+ ); +} diff --git a/superset-frontend/src/explore/components/controls/index.ts b/superset-frontend/src/explore/components/controls/index.ts index 326da43ce84..1550df945e7 100644 --- a/superset-frontend/src/explore/components/controls/index.ts +++ b/superset-frontend/src/explore/components/controls/index.ts @@ -60,6 +60,8 @@ import TimeRangeControl from './TimeRangeControl'; import ColorBreakpointsControl from './ColorBreakpointsControl'; import MatrixifyDimensionControl from './MatrixifyDimensionControl'; import JSEditorControl from './JSEditorControl'; +import SwitchControl from './SwitchControl'; +import VerticalRadioControl from './VerticalRadioControl'; const extensionsRegistry = getExtensionsRegistry(); const DateFilterControlExtension = extensionsRegistry.get( @@ -109,6 +111,8 @@ const controlMap = { NumberControl, TimeRangeControl, MatrixifyDimensionControl, + SwitchControl, + VerticalRadioControl, ...sharedControlComponents, }; export default controlMap; diff --git a/superset-frontend/src/explore/controlPanels/sections.tsx b/superset-frontend/src/explore/controlPanels/sections.tsx index 816d3e18604..bac8cddaf75 100644 --- a/superset-frontend/src/explore/controlPanels/sections.tsx +++ b/superset-frontend/src/explore/controlPanels/sections.tsx @@ -277,57 +277,77 @@ export const NVD3TimeSeries: ControlPanelSectionConfig[] = [ function buildMatrixifySection( axis: 'columns' | 'rows', ): ControlPanelSectionConfig { - const baseControls = [ - [`matrixify_mode_${axis}`], - [`matrixify_${axis}`], - [`matrixify_dimension_selection_mode_${axis}`], - [`matrixify_dimension_${axis}`], - [`matrixify_topn_dimension_${axis}`], - [`matrixify_topn_value_${axis}`], - [`matrixify_topn_metric_${axis}`], - [`matrixify_topn_order_${axis}`], - ]; - - // Add enable checkbox at the beginning of each section - const enableControl = + const customizationControls = axis === 'rows' - ? 'matrixify_enable_vertical_layout' - : 'matrixify_enable_horizontal_layout'; - - baseControls.unshift([enableControl]); - - // Add specific controls for each axis - if (axis === 'rows') { - // Add show row labels after enable - baseControls.splice(1, 0, ['matrixify_show_row_labels']); - } else if (axis === 'columns') { - // Add show column headers after enable - baseControls.splice(1, 0, ['matrixify_show_column_headers']); - } + ? ['matrixify_show_row_labels', 'matrixify_row_height'] + : ['matrixify_show_column_headers', 'matrixify_fit_columns_dynamically']; return { label: axis === 'columns' - ? t('Horizontal layout (columns)') - : t('Vertical layout (rows)'), + ? t('Columns (horizontal layout)') + : t('Rows (vertical layout)'), expanded: true, tabOverride: 'matrixify', - controlSetRows: baseControls, + visibility: ({ controls }) => controls?.matrixify_enable?.value === true, + controlSetRows: [ + [`matrixify_mode_${axis}`], + [`matrixify_${axis}`], + [`matrixify_dimension_selection_mode_${axis}`], + [`matrixify_dimension_${axis}`], + [`matrixify_topn_dimension_${axis}`], + [`matrixify_topn_value_${axis}`], + [`matrixify_all_sort_by_${axis}`], + [`matrixify_topn_metric_${axis}`], + [`matrixify_topn_order_${axis}`], + [ + + {t('Customization and styling')} + , + ], + customizationControls, + ], }; } export const matrixifyRows = buildMatrixifySection('rows'); export const matrixifyColumns = buildMatrixifySection('columns'); +export const matrixifyEnableSection: ControlPanelSectionConfig = { + label: t('Matrixify'), + expanded: true, + tabOverride: 'matrixify', + controlSetRows: [ + [ + { + name: 'matrixify_enable', + config: { + type: 'SwitchControl', + label: t('Enable matrixify'), + default: false, + renderTrigger: true, + }, + }, + ], + ], +}; + export const matrixifyCells: ControlPanelSectionConfig = { label: t('Cell layout & styling'), expanded: true, tabOverride: 'matrixify', - visibility: ({ controls }) => - controls?.matrixify_enable_vertical_layout?.value === true || - controls?.matrixify_enable_horizontal_layout?.value === true, + visibility: ({ controls }) => { + if (controls?.matrixify_enable?.value !== true) return false; + const rowMode = controls?.matrixify_mode_rows?.value; + const colMode = controls?.matrixify_mode_columns?.value; + return ( + rowMode === 'metrics' || + rowMode === 'dimensions' || + colMode === 'metrics' || + colMode === 'dimensions' + ); + }, controlSetRows: [ - ['matrixify_row_height', 'matrixify_fit_columns_dynamically'], ['matrixify_charts_per_row'], ['matrixify_cell_title_template'], ], diff --git a/superset-frontend/src/pages/ChartList/ChartList.testHelpers.tsx b/superset-frontend/src/pages/ChartList/ChartList.testHelpers.tsx index edd32b909f6..1a27d5affeb 100644 --- a/superset-frontend/src/pages/ChartList/ChartList.testHelpers.tsx +++ b/superset-frontend/src/pages/ChartList/ChartList.testHelpers.tsx @@ -65,7 +65,7 @@ export const mockCharts = [ // Add form_data with matrixify enabled form_data: { viz_type: 'table', - matrixify_enable_vertical_layout: true, + matrixify_enable: true, matrixify_mode_rows: 'metrics', matrixify_rows: [{ label: 'COUNT(*)', expressionType: 'SIMPLE' }], }, From 48220fb33f82d1a8bc5e1356b0949e65a16fb2f1 Mon Sep 17 00:00:00 2001 From: Amin Ghadersohi Date: Fri, 13 Mar 2026 22:02:04 +0100 Subject: [PATCH 48/58] feat(mcp): add save_sql_query tool for SQL Lab saved queries (#38414) Co-authored-by: Claude Opus 4.6 --- superset/mcp_service/app.py | 5 +- superset/mcp_service/sql_lab/schemas.py | 59 +++ superset/mcp_service/sql_lab/tool/__init__.py | 2 + .../sql_lab/tool/save_sql_query.py | 137 +++++ .../sql_lab/tool/test_save_sql_query.py | 467 ++++++++++++++++++ 5 files changed, 669 insertions(+), 1 deletion(-) create mode 100644 superset/mcp_service/sql_lab/tool/save_sql_query.py create mode 100644 tests/unit_tests/mcp_service/sql_lab/tool/test_save_sql_query.py diff --git a/superset/mcp_service/app.py b/superset/mcp_service/app.py index 409c7bb2e24..5f7ec6c64df 100644 --- a/superset/mcp_service/app.py +++ b/superset/mcp_service/app.py @@ -70,6 +70,7 @@ Chart Management: SQL Lab Integration: - execute_sql: Execute SQL queries and get results (requires database_id) +- save_sql_query: Save a SQL query to Saved Queries list - open_sql_lab_with_context: Generate SQL Lab URL with pre-filled sql Schema Discovery: @@ -105,7 +106,8 @@ To find your own charts/dashboards: To explore data with SQL: 1. list_datasets -> find a dataset and note its database_id 2. execute_sql(database_id, sql) -> run query -3. open_sql_lab_with_context(database_id) -> open SQL Lab UI +3. save_sql_query(database_id, label, sql) -> save query for later reuse +4. open_sql_lab_with_context(database_id) -> open SQL Lab UI generate_explore_link vs generate_chart: - Use generate_explore_link for exploration (no permanent chart created) @@ -415,6 +417,7 @@ from superset.mcp_service.explore.tool import ( # noqa: F401, E402 from superset.mcp_service.sql_lab.tool import ( # noqa: F401, E402 execute_sql, open_sql_lab_with_context, + save_sql_query, ) from superset.mcp_service.system import ( # noqa: F401, E402 prompts as system_prompts, diff --git a/superset/mcp_service/sql_lab/schemas.py b/superset/mcp_service/sql_lab/schemas.py index e55ca53130f..2e0268774cc 100644 --- a/superset/mcp_service/sql_lab/schemas.py +++ b/superset/mcp_service/sql_lab/schemas.py @@ -147,6 +147,65 @@ class ExecuteSqlResponse(BaseModel): ) +class SaveSqlQueryRequest(BaseModel): + """Request schema for saving a SQL query.""" + + database_id: int = Field( + ..., description="Database connection ID the query runs against" + ) + label: str = Field( + ..., + description="Name for the saved query (shown in Saved Queries list)", + min_length=1, + max_length=256, + ) + sql: str = Field( + ..., + description="SQL query text to save", + ) + schema_name: str | None = Field( + None, + description="Schema the query targets", + alias="schema", + ) + catalog: str | None = Field(None, description="Catalog name (if applicable)") + description: str | None = Field( + None, description="Optional description of the query" + ) + + @field_validator("sql") + @classmethod + def sql_not_empty(cls, v: str) -> str: + if not v or not v.strip(): + raise ValueError("SQL query cannot be empty") + return v.strip() + + @field_validator("label") + @classmethod + def label_not_empty(cls, v: str) -> str: + if not v or not v.strip(): + raise ValueError("Label cannot be empty") + return v.strip() + + +class SaveSqlQueryResponse(BaseModel): + """Response schema for a saved SQL query.""" + + id: int = Field(..., description="Saved query ID") + label: str = Field(..., description="Query name") + sql: str = Field(..., description="SQL query text") + database_id: int = Field(..., description="Database ID") + schema_name: str | None = Field(None, description="Schema name", alias="schema") + catalog: str | None = Field(None, description="Catalog name (if applicable)") + description: str | None = Field(None, description="Query description") + url: str = Field( + ..., + description=( + "URL to open this saved query in SQL Lab (e.g., /sqllab?savedQueryId=42)" + ), + ) + + class OpenSqlLabRequest(BaseModel): """Request schema for opening SQL Lab with context.""" diff --git a/superset/mcp_service/sql_lab/tool/__init__.py b/superset/mcp_service/sql_lab/tool/__init__.py index 0fc7a0dd89f..d4f75d99610 100644 --- a/superset/mcp_service/sql_lab/tool/__init__.py +++ b/superset/mcp_service/sql_lab/tool/__init__.py @@ -23,8 +23,10 @@ from superset.mcp_service.sql_lab.tool.execute_sql import execute_sql from superset.mcp_service.sql_lab.tool.open_sql_lab_with_context import ( open_sql_lab_with_context, ) +from superset.mcp_service.sql_lab.tool.save_sql_query import save_sql_query __all__ = [ "execute_sql", "open_sql_lab_with_context", + "save_sql_query", ] diff --git a/superset/mcp_service/sql_lab/tool/save_sql_query.py b/superset/mcp_service/sql_lab/tool/save_sql_query.py new file mode 100644 index 00000000000..f9777793085 --- /dev/null +++ b/superset/mcp_service/sql_lab/tool/save_sql_query.py @@ -0,0 +1,137 @@ +# 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. + +""" +Save SQL Query MCP Tool + +Tool for saving a SQL query as a named SavedQuery in Superset, +so it appears in SQL Lab's "Saved Queries" list and can be +reloaded/shared via URL. +""" + +from __future__ import annotations + +import logging + +from fastmcp import Context +from sqlalchemy.exc import SQLAlchemyError +from superset_core.mcp.decorators import tool + +from superset.errors import ErrorLevel, SupersetError, SupersetErrorType +from superset.exceptions import SupersetErrorException, SupersetSecurityException +from superset.extensions import event_logger +from superset.mcp_service.sql_lab.schemas import ( + SaveSqlQueryRequest, + SaveSqlQueryResponse, +) +from superset.mcp_service.utils.schema_utils import parse_request + +logger = logging.getLogger(__name__) + + +@tool(tags=["mutate"]) +@parse_request(SaveSqlQueryRequest) +async def save_sql_query( + request: SaveSqlQueryRequest, ctx: Context +) -> SaveSqlQueryResponse: + """Save a SQL query so it appears in SQL Lab's Saved Queries list. + + Creates a persistent SavedQuery that the user can reload from + SQL Lab, share via URL, and find in the Saved Queries page. + Requires a database_id, a label (name), and the SQL text. + """ + await ctx.info( + "Saving SQL query: database_id=%s, label=%r" + % (request.database_id, request.label) + ) + + try: + from flask import g + + from superset import db, security_manager + from superset.daos.query import SavedQueryDAO + from superset.mcp_service.utils.url_utils import get_superset_base_url + from superset.models.core import Database + + # 1. Validate database exists and user has access + with event_logger.log_context(action="mcp.save_sql_query.db_validation"): + database = ( + db.session.query(Database).filter_by(id=request.database_id).first() + ) + if not database: + raise SupersetErrorException( + SupersetError( + message=(f"Database with ID {request.database_id} not found"), + error_type=SupersetErrorType.DATABASE_NOT_FOUND_ERROR, + level=ErrorLevel.ERROR, + ) + ) + + if not security_manager.can_access_database(database): + raise SupersetSecurityException( + SupersetError( + message=(f"Access denied to database {database.database_name}"), + error_type=(SupersetErrorType.DATABASE_SECURITY_ACCESS_ERROR), + level=ErrorLevel.ERROR, + ) + ) + + # 2. Create the saved query + with event_logger.log_context(action="mcp.save_sql_query.create"): + saved_query = SavedQueryDAO.create( + attributes={ + "user_id": g.user.id, + "db_id": request.database_id, + "label": request.label, + "sql": request.sql, + "schema": request.schema_name or "", + "catalog": request.catalog, + "description": request.description or "", + } + ) + db.session.commit() # pylint: disable=consider-using-transaction + + # 3. Build response + base_url = get_superset_base_url() + saved_query_url = f"{base_url}/sqllab?savedQueryId={saved_query.id}" + + await ctx.info( + "Saved query created: id=%s, url=%s" % (saved_query.id, saved_query_url) + ) + + return SaveSqlQueryResponse( + id=saved_query.id, + label=saved_query.label, + sql=saved_query.sql, + database_id=request.database_id, + schema_name=request.schema_name, + catalog=getattr(saved_query, "catalog", None), + description=request.description, + url=saved_query_url, + ) + + except (SupersetErrorException, SupersetSecurityException): + raise + except SQLAlchemyError as e: + from superset import db + + db.session.rollback() + await ctx.error( + "Failed to save SQL query: error=%s, database_id=%s" + % (str(e), request.database_id) + ) + raise diff --git a/tests/unit_tests/mcp_service/sql_lab/tool/test_save_sql_query.py b/tests/unit_tests/mcp_service/sql_lab/tool/test_save_sql_query.py new file mode 100644 index 00000000000..469ca9fd43c --- /dev/null +++ b/tests/unit_tests/mcp_service/sql_lab/tool/test_save_sql_query.py @@ -0,0 +1,467 @@ +# 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. + +""" +Unit tests for save_sql_query MCP tool schemas and logic. +""" + +import importlib +import sys +import types +from unittest.mock import MagicMock, Mock, patch + +import pytest +from pydantic import ValidationError + +from superset.mcp_service.sql_lab.schemas import ( + SaveSqlQueryRequest, + SaveSqlQueryResponse, +) + + +class TestSaveSqlQueryRequest: + """Test SaveSqlQueryRequest schema validation.""" + + def test_valid_request(self) -> None: + req = SaveSqlQueryRequest( + database_id=1, + label="Revenue Query", + sql="SELECT SUM(revenue) FROM sales", + ) + assert req.database_id == 1 + assert req.label == "Revenue Query" + assert req.sql == "SELECT SUM(revenue) FROM sales" + + def test_with_optional_fields(self) -> None: + req = SaveSqlQueryRequest( + database_id=1, + label="Revenue Query", + sql="SELECT 1", + schema="public", + catalog="main", + description="Sums revenue", + ) + assert req.schema_name == "public" + assert req.catalog == "main" + assert req.description == "Sums revenue" + + def test_empty_sql_fails(self) -> None: + with pytest.raises(ValidationError, match="SQL query cannot be empty"): + SaveSqlQueryRequest(database_id=1, label="test", sql=" ") + + def test_empty_label_fails(self) -> None: + with pytest.raises(ValidationError, match="Label cannot be empty"): + SaveSqlQueryRequest(database_id=1, label=" ", sql="SELECT 1") + + def test_sql_is_stripped(self) -> None: + req = SaveSqlQueryRequest(database_id=1, label="test", sql=" SELECT 1 ") + assert req.sql == "SELECT 1" + + def test_label_is_stripped(self) -> None: + req = SaveSqlQueryRequest(database_id=1, label=" My Query ", sql="SELECT 1") + assert req.label == "My Query" + + def test_label_max_length(self) -> None: + with pytest.raises(ValidationError, match="String should have at most 256"): + SaveSqlQueryRequest(database_id=1, label="a" * 257, sql="SELECT 1") + + def test_schema_alias(self) -> None: + """The field accepts 'schema' as alias for 'schema_name'.""" + req = SaveSqlQueryRequest( + database_id=1, + label="test", + sql="SELECT 1", + schema="public", + ) + assert req.schema_name == "public" + + +class TestSaveSqlQueryResponse: + """Test SaveSqlQueryResponse schema.""" + + def test_response_fields(self) -> None: + resp = SaveSqlQueryResponse( + id=42, + label="Revenue", + sql="SELECT 1", + database_id=1, + url="/sqllab?savedQueryId=42", + ) + assert resp.id == 42 + assert resp.label == "Revenue" + assert resp.url == "/sqllab?savedQueryId=42" + + def test_response_with_optional_fields(self) -> None: + resp = SaveSqlQueryResponse( + id=42, + label="Revenue", + sql="SELECT 1", + database_id=1, + schema="public", + description="A query", + url="/sqllab?savedQueryId=42", + ) + assert resp.schema_name == "public" + assert resp.description == "A query" + + +def _force_passthrough_decorators(): + """Force superset_core MCP tool decorator to be a passthrough. + + In CI, superset_core is fully installed and the real @tool decorator + includes authentication middleware. For unit tests we want to bypass + auth and test the tool logic directly, so we always replace the + decorator with a passthrough regardless of installation state. + + Returns a dict of original sys.modules entries so they can be restored. + """ + + def _passthrough_tool(func=None, **kwargs): + if func is not None: + return func + return lambda f: f + + mock_mcp = MagicMock() + mock_mcp.tool = _passthrough_tool + + mock_decorators = MagicMock() + mock_decorators.tool = _passthrough_tool + + mock_api = MagicMock() + mock_api.mcp = mock_mcp + + # Save original modules so we can restore them later + saved_modules: dict[str, types.ModuleType] = {} + + # Only mock the specific decorator submodules, NOT the top-level + # superset_core package. Replacing sys.modules["superset_core"] with + # a MagicMock causes 'superset_core' is not a package errors for + # other submodules (queries, common) that are imported by sibling + # tool files during test collection. + mock_keys = [ + "superset_core.api", + "superset_core.api.mcp", + "superset_core.api.types", + "superset_core.mcp", + "superset_core.mcp.decorators", + ] + for key in mock_keys: + if key in sys.modules: + saved_modules[key] = sys.modules[key] + + sys.modules["superset_core.api"] = mock_api + sys.modules["superset_core.api.mcp"] = mock_mcp + sys.modules["superset_core.mcp"] = mock_mcp + sys.modules["superset_core.mcp.decorators"] = mock_decorators + sys.modules.setdefault("superset_core.api.types", MagicMock()) + + return saved_modules + + +def _restore_modules(saved_modules: dict[str, types.ModuleType]) -> None: + """Restore original sys.modules entries after passthrough mocking.""" + # Remove mock entries for decorator paths and tool modules imported + # under patched decorators. Do NOT remove the top-level superset_core + # package or unrelated submodules (queries, common, etc.). + mock_prefixes = ( + "superset_core.api", + "superset_core.mcp", + "superset.mcp_service.sql_lab.tool", + ) + for key in list(sys.modules.keys()): + if any(key.startswith(prefix) for prefix in mock_prefixes): + del sys.modules[key] + # Restore originals (including any previously-imported tool modules) + sys.modules.update(saved_modules) + + +def _get_tool_module(): + """Import save_sql_query with passthrough decorators (no auth). + + Returns (module, saved_modules) so callers can restore sys.modules. + """ + saved_modules = _force_passthrough_decorators() + # Clear cached module imports so we get a fresh import with mocked + # decorators. This is necessary because in CI the real @tool decorator + # may have been applied during a previous import. + mod_name = "superset.mcp_service.sql_lab.tool.save_sql_query" + saved_tool_modules: dict[str, object] = {} + for key in list(sys.modules.keys()): + if key.startswith("superset.mcp_service.sql_lab.tool"): + saved_tool_modules[key] = sys.modules.pop(key) + saved_modules.update(saved_tool_modules) + mod = importlib.import_module(mod_name) + return mod, saved_modules + + +def _make_mock_ctx(): + """Create a mock FastMCP Context with awaitable methods.""" + + async def _noop(*args, **kwargs): + pass + + ctx = MagicMock() + ctx.info = _noop + ctx.error = _noop + ctx.warning = _noop + return ctx + + +class TestSaveSqlQueryToolLogic: + """Test save_sql_query tool internal logic. + + The tool function uses lazy imports inside its body (from flask import g, + from superset import db, etc.). We patch at the import source so that + when the function runs, it picks up our mocks. + + The @parse_request decorator injects ctx via get_context() and strips + __wrapped__, so we mock get_context and call the decorated function + directly (without unwrapping). + """ + + @pytest.mark.anyio + async def test_save_query_creates_saved_query(self) -> None: + """Verify the tool calls SavedQueryDAO.create with correct attrs.""" + mod, saved = _get_tool_module() + try: + mock_ctx = _make_mock_ctx() + + mock_db_obj = MagicMock() + mock_db_obj.id = 1 + mock_db_obj.database_name = "test_db" + + mock_sq = MagicMock() + mock_sq.id = 42 + mock_sq.label = "Revenue Query" + mock_sq.sql = "SELECT SUM(revenue) FROM sales" + mock_sq.catalog = None + + request = SaveSqlQueryRequest( + database_id=1, + label="Revenue Query", + sql="SELECT SUM(revenue) FROM sales", + ) + + mock_db_session = MagicMock() + ( + mock_db_session.session.query.return_value.filter_by.return_value.first.return_value + ) = mock_db_obj + + mock_sm = MagicMock() + mock_sm.can_access_database.return_value = True + + mock_dao = MagicMock() + mock_dao.create.return_value = mock_sq + + mock_g = MagicMock() + mock_g.user = Mock(id=1) + + mock_event_logger = MagicMock() + mock_event_logger.log_context.return_value.__enter__ = Mock() + mock_event_logger.log_context.return_value.__exit__ = Mock( + return_value=False + ) + + with ( + patch( + "fastmcp.server.dependencies.get_context", + return_value=mock_ctx, + ), + patch("superset.db", mock_db_session), + patch("superset.security_manager", mock_sm), + patch("superset.daos.query.SavedQueryDAO", mock_dao), + patch( + "superset.mcp_service.utils.url_utils.get_superset_base_url", + return_value="http://localhost:8088", + ), + patch("flask.g", mock_g), + patch.object(mod, "event_logger", mock_event_logger), + ): + result = await mod.save_sql_query(request) + + assert result.id == 42 + assert result.label == "Revenue Query" + assert "savedQueryId=42" in result.url + mock_dao.create.assert_called_once() + call_attrs = mock_dao.create.call_args[1]["attributes"] + assert call_attrs["db_id"] == 1 + assert call_attrs["label"] == "Revenue Query" + assert call_attrs["sql"] == "SELECT SUM(revenue) FROM sales" + assert call_attrs["user_id"] == 1 + mock_db_session.session.commit.assert_called_once() + finally: + _restore_modules(saved) + + @pytest.mark.anyio + async def test_save_query_database_not_found(self) -> None: + mod, saved = _get_tool_module() + try: + mock_ctx = _make_mock_ctx() + + request = SaveSqlQueryRequest( + database_id=999, + label="Test", + sql="SELECT 1", + ) + + mock_db_session = MagicMock() + ( + mock_db_session.session.query.return_value.filter_by.return_value.first.return_value + ) = None + + mock_g = MagicMock() + mock_g.user = Mock(id=1) + + mock_event_logger = MagicMock() + mock_event_logger.log_context.return_value.__enter__ = Mock() + mock_event_logger.log_context.return_value.__exit__ = Mock( + return_value=False + ) + + with ( + patch( + "fastmcp.server.dependencies.get_context", + return_value=mock_ctx, + ), + patch("superset.db", mock_db_session), + patch("flask.g", mock_g), + patch.object(mod, "event_logger", mock_event_logger), + ): + from superset.exceptions import SupersetErrorException + + with pytest.raises(SupersetErrorException, match="not found"): + await mod.save_sql_query(request) + finally: + _restore_modules(saved) + + @pytest.mark.anyio + async def test_save_query_access_denied(self) -> None: + mod, saved = _get_tool_module() + try: + mock_ctx = _make_mock_ctx() + + mock_db_obj = MagicMock() + mock_db_obj.id = 1 + mock_db_obj.database_name = "test_db" + + request = SaveSqlQueryRequest( + database_id=1, + label="Test", + sql="SELECT 1", + ) + + mock_db_session = MagicMock() + ( + mock_db_session.session.query.return_value.filter_by.return_value.first.return_value + ) = mock_db_obj + + mock_sm = MagicMock() + mock_sm.can_access_database.return_value = False + + mock_g = MagicMock() + mock_g.user = Mock(id=1) + + mock_event_logger = MagicMock() + mock_event_logger.log_context.return_value.__enter__ = Mock() + mock_event_logger.log_context.return_value.__exit__ = Mock( + return_value=False + ) + + with ( + patch( + "fastmcp.server.dependencies.get_context", + return_value=mock_ctx, + ), + patch("superset.db", mock_db_session), + patch("superset.security_manager", mock_sm), + patch("flask.g", mock_g), + patch.object(mod, "event_logger", mock_event_logger), + ): + from superset.exceptions import SupersetSecurityException + + with pytest.raises(SupersetSecurityException, match="Access denied"): + await mod.save_sql_query(request) + finally: + _restore_modules(saved) + + @pytest.mark.anyio + async def test_save_query_with_schema_and_description(self) -> None: + mod, saved = _get_tool_module() + try: + mock_ctx = _make_mock_ctx() + + mock_db_obj = MagicMock() + mock_db_obj.id = 1 + mock_db_obj.database_name = "test_db" + + mock_sq = MagicMock() + mock_sq.id = 10 + mock_sq.label = "Test" + mock_sq.sql = "SELECT 1" + mock_sq.catalog = None + + request = SaveSqlQueryRequest( + database_id=1, + label="Test", + sql="SELECT 1", + schema="public", + description="A test query", + ) + + mock_db_session = MagicMock() + ( + mock_db_session.session.query.return_value.filter_by.return_value.first.return_value + ) = mock_db_obj + + mock_sm = MagicMock() + mock_sm.can_access_database.return_value = True + + mock_dao = MagicMock() + mock_dao.create.return_value = mock_sq + + mock_g = MagicMock() + mock_g.user = Mock(id=1) + + mock_event_logger = MagicMock() + mock_event_logger.log_context.return_value.__enter__ = Mock() + mock_event_logger.log_context.return_value.__exit__ = Mock( + return_value=False + ) + + with ( + patch( + "fastmcp.server.dependencies.get_context", + return_value=mock_ctx, + ), + patch("superset.db", mock_db_session), + patch("superset.security_manager", mock_sm), + patch("superset.daos.query.SavedQueryDAO", mock_dao), + patch( + "superset.mcp_service.utils.url_utils.get_superset_base_url", + return_value="http://localhost:8088", + ), + patch("flask.g", mock_g), + patch.object(mod, "event_logger", mock_event_logger), + ): + result = await mod.save_sql_query(request) + + assert result.id == 10 + call_attrs = mock_dao.create.call_args[1]["attributes"] + assert call_attrs["schema"] == "public" + assert call_attrs["description"] == "A test query" + finally: + _restore_modules(saved) From 68e38c889361d2fe2dd9618371cef931b07c11ae Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 14 Mar 2026 11:13:50 +0700 Subject: [PATCH 49/58] chore(deps): bump undici from 7.22.0 to 7.24.1 in /superset-frontend (#38642) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- superset-frontend/package-lock.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index 869e3f07d4d..0f748a6a8cd 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -47969,9 +47969,9 @@ "license": "MIT" }, "node_modules/undici": { - "version": "7.22.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz", - "integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==", + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.1.tgz", + "integrity": "sha512-5xoBibbmnjlcR3jdqtY2Lnx7WbrD/tHlT01TmvqZUFVc9Q1w4+j5hbnapTqbcXITMH1ovjq/W7BkqBilHiVAaA==", "dev": true, "license": "MIT", "engines": { @@ -51269,7 +51269,7 @@ "react-js-cron": "^5.2.0", "react-markdown": "^8.0.7", "react-resize-detector": "^7.1.2", - "react-syntax-highlighter": "^16.1.0", + "react-syntax-highlighter": "^16.1.1", "react-ultimate-pagination": "^1.3.2", "regenerator-runtime": "^0.14.1", "rehype-raw": "^7.0.0", From 176bf00c16963f905025b4a6e04512b1ee7db1ef Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 14 Mar 2026 12:28:47 +0700 Subject: [PATCH 50/58] chore(deps-dev): bump baseline-browser-mapping from 2.10.0 to 2.10.7 + sync lockfile + run `npm audit fix` in /superset-frontend (#38621) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: dependabot[bot] Signed-off-by: hainenber Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Đỗ Trọng Hải <41283691+hainenber@users.noreply.github.com> Co-authored-by: hainenber --- superset-frontend/package-lock.json | 68 +++++++++-------------------- superset-frontend/package.json | 2 +- 2 files changed, 22 insertions(+), 48 deletions(-) diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index 0f748a6a8cd..e3a986c973f 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -224,7 +224,7 @@ "babel-plugin-dynamic-import-node": "^2.3.3", "babel-plugin-jsx-remove-data-test-id": "^3.0.0", "babel-plugin-lodash": "^3.3.4", - "baseline-browser-mapping": "^2.10.0", + "baseline-browser-mapping": "^2.10.7", "cheerio": "1.2.0", "concurrently": "^9.2.1", "copy-webpack-plugin": "^13.0.1", @@ -18417,9 +18417,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", - "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", + "version": "2.10.7", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.7.tgz", + "integrity": "sha512-1ghYO3HnxGec0TCGBXiDLVns4eCSx4zJpxnHrlqFQajmhfKMQBzUGDdkMK7fUW7PTHTeLf+j87aTuKuuwWzMGw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -23099,11 +23099,10 @@ } }, "node_modules/dompurify": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz", - "integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz", + "integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==", "license": "(MPL-2.0 OR Apache-2.0)", - "optional": true, "optionalDependencies": { "@types/trusted-types": "^2.0.7" } @@ -25312,9 +25311,9 @@ "license": "MIT" }, "node_modules/fast-xml-parser": { - "version": "4.5.3", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.3.tgz", - "integrity": "sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig==", + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.4.tgz", + "integrity": "sha512-jE8ugADnYOBsu1uaoayVl1tVKAMNOXyjwvv2U6udEA2ORBhDooJDWoGxTkhd4Qn4yh59JVVt/pKXtjPwx9OguQ==", "funding": [ { "type": "github", @@ -25323,7 +25322,7 @@ ], "license": "MIT", "dependencies": { - "strnum": "^1.1.1" + "strnum": "^1.0.5" }, "bin": { "fxparser": "src/cli/cli.js" @@ -25853,9 +25852,9 @@ } }, "node_modules/flatted": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.2.tgz", - "integrity": "sha512-AiwGJM8YcNOaobumgtng+6NHuOqC3A7MixFeDafM3X9cIUM+xUXoS5Vfgf+OihAYe20fxqNM9yPBXJzRtZ/4eA==", + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz", + "integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==", "dev": true, "license": "ISC" }, @@ -44683,14 +44682,14 @@ } }, "node_modules/simple-git": { - "version": "3.27.0", - "resolved": "https://registry.npmjs.org/simple-git/-/simple-git-3.27.0.tgz", - "integrity": "sha512-ivHoFS9Yi9GY49ogc6/YAi3Fl9ROnF4VyubNylgCkA+RVqLaKWnDSzXOVzya8csELIaWaYNutsEuAhZrtOjozA==", + "version": "3.33.0", + "resolved": "https://registry.npmjs.org/simple-git/-/simple-git-3.33.0.tgz", + "integrity": "sha512-D4V/tGC2sjsoNhoMybKyGoE+v8A60hRawKQ1iFRA1zwuDgGZCBJ4ByOzZ5J8joBbi4Oam0qiPH+GhzmSBwbJng==", "license": "MIT", "dependencies": { "@kwsites/file-exists": "^1.1.1", "@kwsites/promise-deferred": "^1.1.1", - "debug": "^4.3.5" + "debug": "^4.4.0" }, "funding": { "type": "github", @@ -47962,10 +47961,9 @@ } }, "node_modules/underscore": { - "version": "1.13.7", - "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.7.tgz", - "integrity": "sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g==", - "dev": true, + "version": "1.13.8", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz", + "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==", "license": "MIT" }, "node_modules/undici": { @@ -51357,15 +51355,6 @@ "node": ">=12" } }, - "packages/superset-ui-core/node_modules/dompurify": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz", - "integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==", - "license": "(MPL-2.0 OR Apache-2.0)", - "optionalDependencies": { - "@types/trusted-types": "^2.0.7" - } - }, "packages/superset-ui-core/node_modules/escape-string-regexp": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", @@ -52609,12 +52598,6 @@ "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==", "license": "ISC" }, - "plugins/legacy-preset-chart-deckgl/node_modules/underscore": { - "version": "1.13.8", - "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz", - "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==", - "license": "MIT" - }, "plugins/legacy-preset-chart-nvd3": { "name": "@superset-ui/legacy-preset-chart-nvd3", "version": "0.20.3", @@ -52637,15 +52620,6 @@ "react": "^17.0.2" } }, - "plugins/legacy-preset-chart-nvd3/node_modules/dompurify": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz", - "integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==", - "license": "(MPL-2.0 OR Apache-2.0)", - "optionalDependencies": { - "@types/trusted-types": "^2.0.7" - } - }, "plugins/plugin-chart-ag-grid-table": { "name": "@superset-ui/plugin-chart-ag-grid-table", "version": "0.20.3", diff --git a/superset-frontend/package.json b/superset-frontend/package.json index 7006f45b582..b1a82dd245e 100644 --- a/superset-frontend/package.json +++ b/superset-frontend/package.json @@ -305,7 +305,7 @@ "babel-plugin-dynamic-import-node": "^2.3.3", "babel-plugin-jsx-remove-data-test-id": "^3.0.0", "babel-plugin-lodash": "^3.3.4", - "baseline-browser-mapping": "^2.10.0", + "baseline-browser-mapping": "^2.10.7", "cheerio": "1.2.0", "concurrently": "^9.2.1", "copy-webpack-plugin": "^13.0.1", From 39cd1cdd43617854e2a0e836a2004862523c98a8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 14 Mar 2026 12:40:25 +0700 Subject: [PATCH 51/58] chore(deps): bump dawidd6/action-download-artifact from 16 to 17 (#38620) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/superset-docs-deploy.yml | 4 ++-- .github/workflows/superset-docs-verify.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/superset-docs-deploy.yml b/.github/workflows/superset-docs-deploy.yml index 12ae2a095da..c7dc57b4426 100644 --- a/.github/workflows/superset-docs-deploy.yml +++ b/.github/workflows/superset-docs-deploy.yml @@ -68,7 +68,7 @@ jobs: yarn install --check-cache - name: Download database diagnostics (if triggered by integration tests) if: github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success' - uses: dawidd6/action-download-artifact@v16 + uses: dawidd6/action-download-artifact@v17 continue-on-error: true with: workflow: superset-python-integrationtest.yml @@ -77,7 +77,7 @@ jobs: path: docs/src/data/ - name: Try to download latest diagnostics (for push/dispatch triggers) if: github.event_name != 'workflow_run' - uses: dawidd6/action-download-artifact@v16 + uses: dawidd6/action-download-artifact@v17 continue-on-error: true with: workflow: superset-python-integrationtest.yml diff --git a/.github/workflows/superset-docs-verify.yml b/.github/workflows/superset-docs-verify.yml index 613b3ec3660..52271f0a4a6 100644 --- a/.github/workflows/superset-docs-verify.yml +++ b/.github/workflows/superset-docs-verify.yml @@ -111,7 +111,7 @@ jobs: run: | yarn install --check-cache - name: Download database diagnostics from integration tests - uses: dawidd6/action-download-artifact@v16 + uses: dawidd6/action-download-artifact@v17 with: workflow: superset-python-integrationtest.yml run_id: ${{ github.event.workflow_run.id }} From cc066b35761098d1ec557b9149b855dc6ad2f530 Mon Sep 17 00:00:00 2001 From: Mayank Aggarwal Date: Sat, 14 Mar 2026 21:20:14 +0530 Subject: [PATCH 52/58] fix(docs): use absolute API doc links in developer docs (#38649) --- docs/developer_docs/api.mdx | 484 ++++++++++++++-------------- docs/scripts/generate-api-index.mjs | 6 +- 2 files changed, 245 insertions(+), 245 deletions(-) diff --git a/docs/developer_docs/api.mdx b/docs/developer_docs/api.mdx index 09f3843087f..c97f8fb5648 100644 --- a/docs/developer_docs/api.mdx +++ b/docs/developer_docs/api.mdx @@ -47,10 +47,10 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ | Method | Endpoint | Description | |--------|----------|-------------| -| `GET` | [Get the CSRF token](./api/get-the-csrf-token) | `/api/v1/security/csrf_token/` | -| `POST` | [Get a guest token](./api/get-a-guest-token) | `/api/v1/security/guest_token/` | -| `POST` | [Create security login](./api/create-security-login) | `/api/v1/security/login` | -| `POST` | [Create security refresh](./api/create-security-refresh) | `/api/v1/security/refresh` | +| `GET` | [Get the CSRF token](/developer-docs/api/get-the-csrf-token) | `/api/v1/security/csrf_token/` | +| `POST` | [Get a guest token](/developer-docs/api/get-a-guest-token) | `/api/v1/security/guest_token/` | +| `POST` | [Create security login](/developer-docs/api/create-security-login) | `/api/v1/security/login` | +| `POST` | [Create security refresh](/developer-docs/api/create-security-refresh) | `/api/v1/security/refresh` | --- @@ -63,32 +63,32 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ | Method | Endpoint | Description | |--------|----------|-------------| -| `DELETE` | [Bulk delete dashboards](./api/bulk-delete-dashboards) | `/api/v1/dashboard/` | -| `GET` | [Get a list of dashboards](./api/get-a-list-of-dashboards) | `/api/v1/dashboard/` | -| `POST` | [Create a new dashboard](./api/create-a-new-dashboard) | `/api/v1/dashboard/` | -| `GET` | [Get metadata information about this API resource (dashboard--info)](./api/get-metadata-information-about-this-api-resource-dashboard-info) | `/api/v1/dashboard/_info` | -| `GET` | [Get a dashboard detail information](./api/get-a-dashboard-detail-information) | `/api/v1/dashboard/{id_or_slug}` | -| `GET` | [Get a dashboard's chart definitions.](./api/get-a-dashboard-s-chart-definitions) | `/api/v1/dashboard/{id_or_slug}/charts` | -| `POST` | [Create a copy of an existing dashboard](./api/create-a-copy-of-an-existing-dashboard) | `/api/v1/dashboard/{id_or_slug}/copy/` | -| `GET` | [Get dashboard's datasets](./api/get-dashboard-s-datasets) | `/api/v1/dashboard/{id_or_slug}/datasets` | -| `DELETE` | [Delete a dashboard's embedded configuration](./api/delete-a-dashboard-s-embedded-configuration) | `/api/v1/dashboard/{id_or_slug}/embedded` | -| `GET` | [Get the dashboard's embedded configuration](./api/get-the-dashboard-s-embedded-configuration) | `/api/v1/dashboard/{id_or_slug}/embedded` | -| `POST` | [Set a dashboard's embedded configuration](./api/set-a-dashboard-s-embedded-configuration) | `/api/v1/dashboard/{id_or_slug}/embedded` | -| `PUT` | [Update dashboard by id_or_slug embedded](./api/update-dashboard-by-id-or-slug-embedded) | `/api/v1/dashboard/{id_or_slug}/embedded` | -| `GET` | [Get dashboard's tabs](./api/get-dashboard-s-tabs) | `/api/v1/dashboard/{id_or_slug}/tabs` | -| `DELETE` | [Delete a dashboard](./api/delete-a-dashboard) | `/api/v1/dashboard/{pk}` | -| `PUT` | [Update a dashboard](./api/update-a-dashboard) | `/api/v1/dashboard/{pk}` | -| `POST` | [Compute and cache a screenshot (dashboard-pk-cache-dashboard-screenshot)](./api/compute-and-cache-a-screenshot-dashboard-pk-cache-dashboard-screenshot) | `/api/v1/dashboard/{pk}/cache_dashboard_screenshot/` | -| `PUT` | [Update colors configuration for a dashboard.](./api/update-colors-configuration-for-a-dashboard) | `/api/v1/dashboard/{pk}/colors` | -| `DELETE` | [Remove the dashboard from the user favorite list](./api/remove-the-dashboard-from-the-user-favorite-list) | `/api/v1/dashboard/{pk}/favorites/` | -| `POST` | [Mark the dashboard as favorite for the current user](./api/mark-the-dashboard-as-favorite-for-the-current-user) | `/api/v1/dashboard/{pk}/favorites/` | -| `PUT` | [Update native filters configuration for a dashboard.](./api/update-native-filters-configuration-for-a-dashboard) | `/api/v1/dashboard/{pk}/filters` | -| `GET` | [Get a computed screenshot from cache (dashboard-pk-screenshot-digest)](./api/get-a-computed-screenshot-from-cache-dashboard-pk-screenshot-digest) | `/api/v1/dashboard/{pk}/screenshot/{digest}/` | -| `GET` | [Get dashboard's thumbnail](./api/get-dashboard-s-thumbnail) | `/api/v1/dashboard/{pk}/thumbnail/{digest}/` | -| `GET` | [Download multiple dashboards as YAML files](./api/download-multiple-dashboards-as-yaml-files) | `/api/v1/dashboard/export/` | -| `GET` | [Check favorited dashboards for current user](./api/check-favorited-dashboards-for-current-user) | `/api/v1/dashboard/favorite_status/` | -| `POST` | [Import dashboard(s) with associated charts/datasets/databases](./api/import-dashboard-s-with-associated-charts-datasets-databases) | `/api/v1/dashboard/import/` | -| `GET` | [Get related fields data (dashboard-related-column-name)](./api/get-related-fields-data-dashboard-related-column-name) | `/api/v1/dashboard/related/{column_name}` | +| `DELETE` | [Bulk delete dashboards](/developer-docs/api/bulk-delete-dashboards) | `/api/v1/dashboard/` | +| `GET` | [Get a list of dashboards](/developer-docs/api/get-a-list-of-dashboards) | `/api/v1/dashboard/` | +| `POST` | [Create a new dashboard](/developer-docs/api/create-a-new-dashboard) | `/api/v1/dashboard/` | +| `GET` | [Get metadata information about this API resource (dashboard--info)](/developer-docs/api/get-metadata-information-about-this-api-resource-dashboard-info) | `/api/v1/dashboard/_info` | +| `GET` | [Get a dashboard detail information](/developer-docs/api/get-a-dashboard-detail-information) | `/api/v1/dashboard/{id_or_slug}` | +| `GET` | [Get a dashboard's chart definitions.](/developer-docs/api/get-a-dashboard-s-chart-definitions) | `/api/v1/dashboard/{id_or_slug}/charts` | +| `POST` | [Create a copy of an existing dashboard](/developer-docs/api/create-a-copy-of-an-existing-dashboard) | `/api/v1/dashboard/{id_or_slug}/copy/` | +| `GET` | [Get dashboard's datasets](/developer-docs/api/get-dashboard-s-datasets) | `/api/v1/dashboard/{id_or_slug}/datasets` | +| `DELETE` | [Delete a dashboard's embedded configuration](/developer-docs/api/delete-a-dashboard-s-embedded-configuration) | `/api/v1/dashboard/{id_or_slug}/embedded` | +| `GET` | [Get the dashboard's embedded configuration](/developer-docs/api/get-the-dashboard-s-embedded-configuration) | `/api/v1/dashboard/{id_or_slug}/embedded` | +| `POST` | [Set a dashboard's embedded configuration](/developer-docs/api/set-a-dashboard-s-embedded-configuration) | `/api/v1/dashboard/{id_or_slug}/embedded` | +| `PUT` | [Update dashboard by id_or_slug embedded](/developer-docs/api/update-dashboard-by-id-or-slug-embedded) | `/api/v1/dashboard/{id_or_slug}/embedded` | +| `GET` | [Get dashboard's tabs](/developer-docs/api/get-dashboard-s-tabs) | `/api/v1/dashboard/{id_or_slug}/tabs` | +| `DELETE` | [Delete a dashboard](/developer-docs/api/delete-a-dashboard) | `/api/v1/dashboard/{pk}` | +| `PUT` | [Update a dashboard](/developer-docs/api/update-a-dashboard) | `/api/v1/dashboard/{pk}` | +| `POST` | [Compute and cache a screenshot (dashboard-pk-cache-dashboard-screenshot)](/developer-docs/api/compute-and-cache-a-screenshot-dashboard-pk-cache-dashboard-screenshot) | `/api/v1/dashboard/{pk}/cache_dashboard_screenshot/` | +| `PUT` | [Update colors configuration for a dashboard.](/developer-docs/api/update-colors-configuration-for-a-dashboard) | `/api/v1/dashboard/{pk}/colors` | +| `DELETE` | [Remove the dashboard from the user favorite list](/developer-docs/api/remove-the-dashboard-from-the-user-favorite-list) | `/api/v1/dashboard/{pk}/favorites/` | +| `POST` | [Mark the dashboard as favorite for the current user](/developer-docs/api/mark-the-dashboard-as-favorite-for-the-current-user) | `/api/v1/dashboard/{pk}/favorites/` | +| `PUT` | [Update native filters configuration for a dashboard.](/developer-docs/api/update-native-filters-configuration-for-a-dashboard) | `/api/v1/dashboard/{pk}/filters` | +| `GET` | [Get a computed screenshot from cache (dashboard-pk-screenshot-digest)](/developer-docs/api/get-a-computed-screenshot-from-cache-dashboard-pk-screenshot-digest) | `/api/v1/dashboard/{pk}/screenshot/{digest}/` | +| `GET` | [Get dashboard's thumbnail](/developer-docs/api/get-dashboard-s-thumbnail) | `/api/v1/dashboard/{pk}/thumbnail/{digest}/` | +| `GET` | [Download multiple dashboards as YAML files](/developer-docs/api/download-multiple-dashboards-as-yaml-files) | `/api/v1/dashboard/export/` | +| `GET` | [Check favorited dashboards for current user](/developer-docs/api/check-favorited-dashboards-for-current-user) | `/api/v1/dashboard/favorite_status/` | +| `POST` | [Import dashboard(s) with associated charts/datasets/databases](/developer-docs/api/import-dashboard-s-with-associated-charts-datasets-databases) | `/api/v1/dashboard/import/` | +| `GET` | [Get related fields data (dashboard-related-column-name)](/developer-docs/api/get-related-fields-data-dashboard-related-column-name) | `/api/v1/dashboard/related/{column_name}` | @@ -97,26 +97,26 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ | Method | Endpoint | Description | |--------|----------|-------------| -| `DELETE` | [Bulk delete charts](./api/bulk-delete-charts) | `/api/v1/chart/` | -| `GET` | [Get a list of charts](./api/get-a-list-of-charts) | `/api/v1/chart/` | -| `POST` | [Create a new chart](./api/create-a-new-chart) | `/api/v1/chart/` | -| `GET` | [Get metadata information about this API resource (chart--info)](./api/get-metadata-information-about-this-api-resource-chart-info) | `/api/v1/chart/_info` | -| `DELETE` | [Delete a chart](./api/delete-a-chart) | `/api/v1/chart/{pk}` | -| `GET` | [Get a chart detail information](./api/get-a-chart-detail-information) | `/api/v1/chart/{pk}` | -| `PUT` | [Update a chart](./api/update-a-chart) | `/api/v1/chart/{pk}` | -| `GET` | [Compute and cache a screenshot (chart-pk-cache-screenshot)](./api/compute-and-cache-a-screenshot-chart-pk-cache-screenshot) | `/api/v1/chart/{pk}/cache_screenshot/` | -| `GET` | [Return payload data response for a chart](./api/return-payload-data-response-for-a-chart) | `/api/v1/chart/{pk}/data/` | -| `DELETE` | [Remove the chart from the user favorite list](./api/remove-the-chart-from-the-user-favorite-list) | `/api/v1/chart/{pk}/favorites/` | -| `POST` | [Mark the chart as favorite for the current user](./api/mark-the-chart-as-favorite-for-the-current-user) | `/api/v1/chart/{pk}/favorites/` | -| `GET` | [Get a computed screenshot from cache (chart-pk-screenshot-digest)](./api/get-a-computed-screenshot-from-cache-chart-pk-screenshot-digest) | `/api/v1/chart/{pk}/screenshot/{digest}/` | -| `GET` | [Get chart thumbnail](./api/get-chart-thumbnail) | `/api/v1/chart/{pk}/thumbnail/{digest}/` | -| `POST` | [Return payload data response for the given query (chart-data)](./api/return-payload-data-response-for-the-given-query-chart-data) | `/api/v1/chart/data` | -| `GET` | [Return payload data response for the given query (chart-data-cache-key)](./api/return-payload-data-response-for-the-given-query-chart-data-cache-key) | `/api/v1/chart/data/{cache_key}` | -| `GET` | [Download multiple charts as YAML files](./api/download-multiple-charts-as-yaml-files) | `/api/v1/chart/export/` | -| `GET` | [Check favorited charts for current user](./api/check-favorited-charts-for-current-user) | `/api/v1/chart/favorite_status/` | -| `POST` | [Import chart(s) with associated datasets and databases](./api/import-chart-s-with-associated-datasets-and-databases) | `/api/v1/chart/import/` | -| `GET` | [Get related fields data (chart-related-column-name)](./api/get-related-fields-data-chart-related-column-name) | `/api/v1/chart/related/{column_name}` | -| `PUT` | [Warm up the cache for the chart](./api/warm-up-the-cache-for-the-chart) | `/api/v1/chart/warm_up_cache` | +| `DELETE` | [Bulk delete charts](/developer-docs/api/bulk-delete-charts) | `/api/v1/chart/` | +| `GET` | [Get a list of charts](/developer-docs/api/get-a-list-of-charts) | `/api/v1/chart/` | +| `POST` | [Create a new chart](/developer-docs/api/create-a-new-chart) | `/api/v1/chart/` | +| `GET` | [Get metadata information about this API resource (chart--info)](/developer-docs/api/get-metadata-information-about-this-api-resource-chart-info) | `/api/v1/chart/_info` | +| `DELETE` | [Delete a chart](/developer-docs/api/delete-a-chart) | `/api/v1/chart/{pk}` | +| `GET` | [Get a chart detail information](/developer-docs/api/get-a-chart-detail-information) | `/api/v1/chart/{pk}` | +| `PUT` | [Update a chart](/developer-docs/api/update-a-chart) | `/api/v1/chart/{pk}` | +| `GET` | [Compute and cache a screenshot (chart-pk-cache-screenshot)](/developer-docs/api/compute-and-cache-a-screenshot-chart-pk-cache-screenshot) | `/api/v1/chart/{pk}/cache_screenshot/` | +| `GET` | [Return payload data response for a chart](/developer-docs/api/return-payload-data-response-for-a-chart) | `/api/v1/chart/{pk}/data/` | +| `DELETE` | [Remove the chart from the user favorite list](/developer-docs/api/remove-the-chart-from-the-user-favorite-list) | `/api/v1/chart/{pk}/favorites/` | +| `POST` | [Mark the chart as favorite for the current user](/developer-docs/api/mark-the-chart-as-favorite-for-the-current-user) | `/api/v1/chart/{pk}/favorites/` | +| `GET` | [Get a computed screenshot from cache (chart-pk-screenshot-digest)](/developer-docs/api/get-a-computed-screenshot-from-cache-chart-pk-screenshot-digest) | `/api/v1/chart/{pk}/screenshot/{digest}/` | +| `GET` | [Get chart thumbnail](/developer-docs/api/get-chart-thumbnail) | `/api/v1/chart/{pk}/thumbnail/{digest}/` | +| `POST` | [Return payload data response for the given query (chart-data)](/developer-docs/api/return-payload-data-response-for-the-given-query-chart-data) | `/api/v1/chart/data` | +| `GET` | [Return payload data response for the given query (chart-data-cache-key)](/developer-docs/api/return-payload-data-response-for-the-given-query-chart-data-cache-key) | `/api/v1/chart/data/{cache_key}` | +| `GET` | [Download multiple charts as YAML files](/developer-docs/api/download-multiple-charts-as-yaml-files) | `/api/v1/chart/export/` | +| `GET` | [Check favorited charts for current user](/developer-docs/api/check-favorited-charts-for-current-user) | `/api/v1/chart/favorite_status/` | +| `POST` | [Import chart(s) with associated datasets and databases](/developer-docs/api/import-chart-s-with-associated-datasets-and-databases) | `/api/v1/chart/import/` | +| `GET` | [Get related fields data (chart-related-column-name)](/developer-docs/api/get-related-fields-data-chart-related-column-name) | `/api/v1/chart/related/{column_name}` | +| `PUT` | [Warm up the cache for the chart](/developer-docs/api/warm-up-the-cache-for-the-chart) | `/api/v1/chart/warm_up_cache` | @@ -125,24 +125,24 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ | Method | Endpoint | Description | |--------|----------|-------------| -| `DELETE` | [Bulk delete datasets](./api/bulk-delete-datasets) | `/api/v1/dataset/` | -| `GET` | [Get a list of datasets](./api/get-a-list-of-datasets) | `/api/v1/dataset/` | -| `POST` | [Create a new dataset](./api/create-a-new-dataset) | `/api/v1/dataset/` | -| `GET` | [Get metadata information about this API resource (dataset--info)](./api/get-metadata-information-about-this-api-resource-dataset-info) | `/api/v1/dataset/_info` | -| `DELETE` | [Delete a dataset](./api/delete-a-dataset) | `/api/v1/dataset/{pk}` | -| `GET` | [Get a dataset](./api/get-a-dataset) | `/api/v1/dataset/{pk}` | -| `PUT` | [Update a dataset](./api/update-a-dataset) | `/api/v1/dataset/{pk}` | -| `DELETE` | [Delete a dataset column](./api/delete-a-dataset-column) | `/api/v1/dataset/{pk}/column/{column_id}` | -| `DELETE` | [Delete a dataset metric](./api/delete-a-dataset-metric) | `/api/v1/dataset/{pk}/metric/{metric_id}` | -| `PUT` | [Refresh and update columns of a dataset](./api/refresh-and-update-columns-of-a-dataset) | `/api/v1/dataset/{pk}/refresh` | -| `GET` | [Get charts and dashboards count associated to a dataset](./api/get-charts-and-dashboards-count-associated-to-a-dataset) | `/api/v1/dataset/{pk}/related_objects` | -| `GET` | [Get distinct values from field data (dataset-distinct-column-name)](./api/get-distinct-values-from-field-data-dataset-distinct-column-name) | `/api/v1/dataset/distinct/{column_name}` | -| `POST` | [Duplicate a dataset](./api/duplicate-a-dataset) | `/api/v1/dataset/duplicate` | -| `GET` | [Download multiple datasets as YAML files](./api/download-multiple-datasets-as-yaml-files) | `/api/v1/dataset/export/` | -| `POST` | [Retrieve a table by name, or create it if it does not exist](./api/retrieve-a-table-by-name-or-create-it-if-it-does-not-exist) | `/api/v1/dataset/get_or_create/` | -| `POST` | [Import dataset(s) with associated databases](./api/import-dataset-s-with-associated-databases) | `/api/v1/dataset/import/` | -| `GET` | [Get related fields data (dataset-related-column-name)](./api/get-related-fields-data-dataset-related-column-name) | `/api/v1/dataset/related/{column_name}` | -| `PUT` | [Warm up the cache for each chart powered by the given table](./api/warm-up-the-cache-for-each-chart-powered-by-the-given-table) | `/api/v1/dataset/warm_up_cache` | +| `DELETE` | [Bulk delete datasets](/developer-docs/api/bulk-delete-datasets) | `/api/v1/dataset/` | +| `GET` | [Get a list of datasets](/developer-docs/api/get-a-list-of-datasets) | `/api/v1/dataset/` | +| `POST` | [Create a new dataset](/developer-docs/api/create-a-new-dataset) | `/api/v1/dataset/` | +| `GET` | [Get metadata information about this API resource (dataset--info)](/developer-docs/api/get-metadata-information-about-this-api-resource-dataset-info) | `/api/v1/dataset/_info` | +| `DELETE` | [Delete a dataset](/developer-docs/api/delete-a-dataset) | `/api/v1/dataset/{pk}` | +| `GET` | [Get a dataset](/developer-docs/api/get-a-dataset) | `/api/v1/dataset/{pk}` | +| `PUT` | [Update a dataset](/developer-docs/api/update-a-dataset) | `/api/v1/dataset/{pk}` | +| `DELETE` | [Delete a dataset column](/developer-docs/api/delete-a-dataset-column) | `/api/v1/dataset/{pk}/column/{column_id}` | +| `DELETE` | [Delete a dataset metric](/developer-docs/api/delete-a-dataset-metric) | `/api/v1/dataset/{pk}/metric/{metric_id}` | +| `PUT` | [Refresh and update columns of a dataset](/developer-docs/api/refresh-and-update-columns-of-a-dataset) | `/api/v1/dataset/{pk}/refresh` | +| `GET` | [Get charts and dashboards count associated to a dataset](/developer-docs/api/get-charts-and-dashboards-count-associated-to-a-dataset) | `/api/v1/dataset/{pk}/related_objects` | +| `GET` | [Get distinct values from field data (dataset-distinct-column-name)](/developer-docs/api/get-distinct-values-from-field-data-dataset-distinct-column-name) | `/api/v1/dataset/distinct/{column_name}` | +| `POST` | [Duplicate a dataset](/developer-docs/api/duplicate-a-dataset) | `/api/v1/dataset/duplicate` | +| `GET` | [Download multiple datasets as YAML files](/developer-docs/api/download-multiple-datasets-as-yaml-files) | `/api/v1/dataset/export/` | +| `POST` | [Retrieve a table by name, or create it if it does not exist](/developer-docs/api/retrieve-a-table-by-name-or-create-it-if-it-does-not-exist) | `/api/v1/dataset/get_or_create/` | +| `POST` | [Import dataset(s) with associated databases](/developer-docs/api/import-dataset-s-with-associated-databases) | `/api/v1/dataset/import/` | +| `GET` | [Get related fields data (dataset-related-column-name)](/developer-docs/api/get-related-fields-data-dataset-related-column-name) | `/api/v1/dataset/related/{column_name}` | +| `PUT` | [Warm up the cache for each chart powered by the given table](/developer-docs/api/warm-up-the-cache-for-each-chart-powered-by-the-given-table) | `/api/v1/dataset/warm_up_cache` | @@ -151,37 +151,37 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ | Method | Endpoint | Description | |--------|----------|-------------| -| `GET` | [Get a list of databases](./api/get-a-list-of-databases) | `/api/v1/database/` | -| `POST` | [Create a new database](./api/create-a-new-database) | `/api/v1/database/` | -| `GET` | [Get metadata information about this API resource (database--info)](./api/get-metadata-information-about-this-api-resource-database-info) | `/api/v1/database/_info` | -| `DELETE` | [Delete a database](./api/delete-a-database) | `/api/v1/database/{pk}` | -| `GET` | [Get a database](./api/get-a-database) | `/api/v1/database/{pk}` | -| `PUT` | [Change a database](./api/change-a-database) | `/api/v1/database/{pk}` | -| `GET` | [Get all catalogs from a database](./api/get-all-catalogs-from-a-database) | `/api/v1/database/{pk}/catalogs/` | -| `GET` | [Get a database connection info](./api/get-a-database-connection-info) | `/api/v1/database/{pk}/connection` | -| `GET` | [Get function names supported by a database](./api/get-function-names-supported-by-a-database) | `/api/v1/database/{pk}/function_names/` | -| `GET` | [Get charts and dashboards count associated to a database](./api/get-charts-and-dashboards-count-associated-to-a-database) | `/api/v1/database/{pk}/related_objects/` | -| `GET` | [The list of the database schemas where to upload information](./api/the-list-of-the-database-schemas-where-to-upload-information) | `/api/v1/database/{pk}/schemas_access_for_file_upload/` | -| `GET` | [Get all schemas from a database](./api/get-all-schemas-from-a-database) | `/api/v1/database/{pk}/schemas/` | -| `GET` | [Get database select star for table (database-pk-select-star-table-name)](./api/get-database-select-star-for-table-database-pk-select-star-table-name) | `/api/v1/database/{pk}/select_star/{table_name}/` | -| `GET` | [Get database select star for table (database-pk-select-star-table-name-schema-name)](./api/get-database-select-star-for-table-database-pk-select-star-table-name-schema-name) | `/api/v1/database/{pk}/select_star/{table_name}/{schema_name}/` | -| `DELETE` | [Delete a SSH tunnel](./api/delete-a-ssh-tunnel) | `/api/v1/database/{pk}/ssh_tunnel/` | -| `POST` | [Re-sync all permissions for a database connection](./api/re-sync-all-permissions-for-a-database-connection) | `/api/v1/database/{pk}/sync_permissions/` | -| `GET` | [Get table extra metadata (database-pk-table-extra-table-name-schema-name)](./api/get-table-extra-metadata-database-pk-table-extra-table-name-schema-name) | `/api/v1/database/{pk}/table_extra/{table_name}/{schema_name}/` | -| `GET` | [Get table metadata](./api/get-table-metadata) | `/api/v1/database/{pk}/table_metadata/` | -| `GET` | [Get table extra metadata (database-pk-table-metadata-extra)](./api/get-table-extra-metadata-database-pk-table-metadata-extra) | `/api/v1/database/{pk}/table_metadata/extra/` | -| `GET` | [Get database table metadata](./api/get-database-table-metadata) | `/api/v1/database/{pk}/table/{table_name}/{schema_name}/` | -| `GET` | [Get a list of tables for given database](./api/get-a-list-of-tables-for-given-database) | `/api/v1/database/{pk}/tables/` | -| `POST` | [Upload a file to a database table](./api/upload-a-file-to-a-database-table) | `/api/v1/database/{pk}/upload/` | -| `POST` | [Validate arbitrary SQL](./api/validate-arbitrary-sql) | `/api/v1/database/{pk}/validate_sql/` | -| `GET` | [Get names of databases currently available](./api/get-names-of-databases-currently-available) | `/api/v1/database/available/` | -| `GET` | [Download database(s) and associated dataset(s) as a zip file](./api/download-database-s-and-associated-dataset-s-as-a-zip-file) | `/api/v1/database/export/` | -| `POST` | [Import database(s) with associated datasets](./api/import-database-s-with-associated-datasets) | `/api/v1/database/import/` | -| `GET` | [Receive personal access tokens from OAuth2](./api/receive-personal-access-tokens-from-oauth2) | `/api/v1/database/oauth2/` | -| `GET` | [Get related fields data (database-related-column-name)](./api/get-related-fields-data-database-related-column-name) | `/api/v1/database/related/{column_name}` | -| `POST` | [Test a database connection](./api/test-a-database-connection) | `/api/v1/database/test_connection/` | -| `POST` | [Upload a file and returns file metadata](./api/upload-a-file-and-returns-file-metadata) | `/api/v1/database/upload_metadata/` | -| `POST` | [Validate database connection parameters](./api/validate-database-connection-parameters) | `/api/v1/database/validate_parameters/` | +| `GET` | [Get a list of databases](/developer-docs/api/get-a-list-of-databases) | `/api/v1/database/` | +| `POST` | [Create a new database](/developer-docs/api/create-a-new-database) | `/api/v1/database/` | +| `GET` | [Get metadata information about this API resource (database--info)](/developer-docs/api/get-metadata-information-about-this-api-resource-database-info) | `/api/v1/database/_info` | +| `DELETE` | [Delete a database](/developer-docs/api/delete-a-database) | `/api/v1/database/{pk}` | +| `GET` | [Get a database](/developer-docs/api/get-a-database) | `/api/v1/database/{pk}` | +| `PUT` | [Change a database](/developer-docs/api/change-a-database) | `/api/v1/database/{pk}` | +| `GET` | [Get all catalogs from a database](/developer-docs/api/get-all-catalogs-from-a-database) | `/api/v1/database/{pk}/catalogs/` | +| `GET` | [Get a database connection info](/developer-docs/api/get-a-database-connection-info) | `/api/v1/database/{pk}/connection` | +| `GET` | [Get function names supported by a database](/developer-docs/api/get-function-names-supported-by-a-database) | `/api/v1/database/{pk}/function_names/` | +| `GET` | [Get charts and dashboards count associated to a database](/developer-docs/api/get-charts-and-dashboards-count-associated-to-a-database) | `/api/v1/database/{pk}/related_objects/` | +| `GET` | [The list of the database schemas where to upload information](/developer-docs/api/the-list-of-the-database-schemas-where-to-upload-information) | `/api/v1/database/{pk}/schemas_access_for_file_upload/` | +| `GET` | [Get all schemas from a database](/developer-docs/api/get-all-schemas-from-a-database) | `/api/v1/database/{pk}/schemas/` | +| `GET` | [Get database select star for table (database-pk-select-star-table-name)](/developer-docs/api/get-database-select-star-for-table-database-pk-select-star-table-name) | `/api/v1/database/{pk}/select_star/{table_name}/` | +| `GET` | [Get database select star for table (database-pk-select-star-table-name-schema-name)](/developer-docs/api/get-database-select-star-for-table-database-pk-select-star-table-name-schema-name) | `/api/v1/database/{pk}/select_star/{table_name}/{schema_name}/` | +| `DELETE` | [Delete a SSH tunnel](/developer-docs/api/delete-a-ssh-tunnel) | `/api/v1/database/{pk}/ssh_tunnel/` | +| `POST` | [Re-sync all permissions for a database connection](/developer-docs/api/re-sync-all-permissions-for-a-database-connection) | `/api/v1/database/{pk}/sync_permissions/` | +| `GET` | [Get table extra metadata (database-pk-table-extra-table-name-schema-name)](/developer-docs/api/get-table-extra-metadata-database-pk-table-extra-table-name-schema-name) | `/api/v1/database/{pk}/table_extra/{table_name}/{schema_name}/` | +| `GET` | [Get table metadata](/developer-docs/api/get-table-metadata) | `/api/v1/database/{pk}/table_metadata/` | +| `GET` | [Get table extra metadata (database-pk-table-metadata-extra)](/developer-docs/api/get-table-extra-metadata-database-pk-table-metadata-extra) | `/api/v1/database/{pk}/table_metadata/extra/` | +| `GET` | [Get database table metadata](/developer-docs/api/get-database-table-metadata) | `/api/v1/database/{pk}/table/{table_name}/{schema_name}/` | +| `GET` | [Get a list of tables for given database](/developer-docs/api/get-a-list-of-tables-for-given-database) | `/api/v1/database/{pk}/tables/` | +| `POST` | [Upload a file to a database table](/developer-docs/api/upload-a-file-to-a-database-table) | `/api/v1/database/{pk}/upload/` | +| `POST` | [Validate arbitrary SQL](/developer-docs/api/validate-arbitrary-sql) | `/api/v1/database/{pk}/validate_sql/` | +| `GET` | [Get names of databases currently available](/developer-docs/api/get-names-of-databases-currently-available) | `/api/v1/database/available/` | +| `GET` | [Download database(s) and associated dataset(s) as a zip file](/developer-docs/api/download-database-s-and-associated-dataset-s-as-a-zip-file) | `/api/v1/database/export/` | +| `POST` | [Import database(s) with associated datasets](/developer-docs/api/import-database-s-with-associated-datasets) | `/api/v1/database/import/` | +| `GET` | [Receive personal access tokens from OAuth2](/developer-docs/api/receive-personal-access-tokens-from-oauth2) | `/api/v1/database/oauth2/` | +| `GET` | [Get related fields data (database-related-column-name)](/developer-docs/api/get-related-fields-data-database-related-column-name) | `/api/v1/database/related/{column_name}` | +| `POST` | [Test a database connection](/developer-docs/api/test-a-database-connection) | `/api/v1/database/test_connection/` | +| `POST` | [Upload a file and returns file metadata](/developer-docs/api/upload-a-file-and-returns-file-metadata) | `/api/v1/database/upload_metadata/` | +| `POST` | [Validate database connection parameters](/developer-docs/api/validate-database-connection-parameters) | `/api/v1/database/validate_parameters/` | @@ -192,7 +192,7 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ | Method | Endpoint | Description | |--------|----------|-------------| -| `GET` | [Assemble Explore related information in a single endpoint](./api/assemble-explore-related-information-in-a-single-endpoint) | `/api/v1/explore/` | +| `GET` | [Assemble Explore related information in a single endpoint](/developer-docs/api/assemble-explore-related-information-in-a-single-endpoint) | `/api/v1/explore/` | @@ -201,12 +201,12 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ | Method | Endpoint | Description | |--------|----------|-------------| -| `GET` | [Get the bootstrap data for SqlLab page](./api/get-the-bootstrap-data-for-sqllab-page) | `/api/v1/sqllab/` | -| `POST` | [Estimate the SQL query execution cost](./api/estimate-the-sql-query-execution-cost) | `/api/v1/sqllab/estimate/` | -| `POST` | [Execute a SQL query](./api/execute-a-sql-query) | `/api/v1/sqllab/execute/` | -| `GET` | [Export the SQL query results to a CSV](./api/export-the-sql-query-results-to-a-csv) | `/api/v1/sqllab/export/{client_id}/` | -| `POST` | [Format SQL code](./api/format-sql-code) | `/api/v1/sqllab/format_sql/` | -| `GET` | [Get the result of a SQL query execution](./api/get-the-result-of-a-sql-query-execution) | `/api/v1/sqllab/results/` | +| `GET` | [Get the bootstrap data for SqlLab page](/developer-docs/api/get-the-bootstrap-data-for-sqllab-page) | `/api/v1/sqllab/` | +| `POST` | [Estimate the SQL query execution cost](/developer-docs/api/estimate-the-sql-query-execution-cost) | `/api/v1/sqllab/estimate/` | +| `POST` | [Execute a SQL query](/developer-docs/api/execute-a-sql-query) | `/api/v1/sqllab/execute/` | +| `GET` | [Export the SQL query results to a CSV](/developer-docs/api/export-the-sql-query-results-to-a-csv) | `/api/v1/sqllab/export/{client_id}/` | +| `POST` | [Format SQL code](/developer-docs/api/format-sql-code) | `/api/v1/sqllab/format_sql/` | +| `GET` | [Get the result of a SQL query execution](/developer-docs/api/get-the-result-of-a-sql-query-execution) | `/api/v1/sqllab/results/` | @@ -215,23 +215,23 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ | Method | Endpoint | Description | |--------|----------|-------------| -| `GET` | [Get a list of queries](./api/get-a-list-of-queries) | `/api/v1/query/` | -| `GET` | [Get query detail information](./api/get-query-detail-information) | `/api/v1/query/{pk}` | -| `GET` | [Get distinct values from field data (query-distinct-column-name)](./api/get-distinct-values-from-field-data-query-distinct-column-name) | `/api/v1/query/distinct/{column_name}` | -| `GET` | [Get related fields data (query-related-column-name)](./api/get-related-fields-data-query-related-column-name) | `/api/v1/query/related/{column_name}` | -| `POST` | [Manually stop a query with client_id](./api/manually-stop-a-query-with-client-id) | `/api/v1/query/stop` | -| `GET` | [Get a list of queries that changed after last_updated_ms](./api/get-a-list-of-queries-that-changed-after-last-updated-ms) | `/api/v1/query/updated_since` | -| `DELETE` | [Bulk delete saved queries](./api/bulk-delete-saved-queries) | `/api/v1/saved_query/` | -| `GET` | [Get a list of saved queries](./api/get-a-list-of-saved-queries) | `/api/v1/saved_query/` | -| `POST` | [Create a saved query](./api/create-a-saved-query) | `/api/v1/saved_query/` | -| `GET` | [Get metadata information about this API resource (saved-query--info)](./api/get-metadata-information-about-this-api-resource-saved-query-info) | `/api/v1/saved_query/_info` | -| `DELETE` | [Delete a saved query](./api/delete-a-saved-query) | `/api/v1/saved_query/{pk}` | -| `GET` | [Get a saved query](./api/get-a-saved-query) | `/api/v1/saved_query/{pk}` | -| `PUT` | [Update a saved query](./api/update-a-saved-query) | `/api/v1/saved_query/{pk}` | -| `GET` | [Get distinct values from field data (saved-query-distinct-column-name)](./api/get-distinct-values-from-field-data-saved-query-distinct-column-name) | `/api/v1/saved_query/distinct/{column_name}` | -| `GET` | [Download multiple saved queries as YAML files](./api/download-multiple-saved-queries-as-yaml-files) | `/api/v1/saved_query/export/` | -| `POST` | [Import saved queries with associated databases](./api/import-saved-queries-with-associated-databases) | `/api/v1/saved_query/import/` | -| `GET` | [Get related fields data (saved-query-related-column-name)](./api/get-related-fields-data-saved-query-related-column-name) | `/api/v1/saved_query/related/{column_name}` | +| `GET` | [Get a list of queries](/developer-docs/api/get-a-list-of-queries) | `/api/v1/query/` | +| `GET` | [Get query detail information](/developer-docs/api/get-query-detail-information) | `/api/v1/query/{pk}` | +| `GET` | [Get distinct values from field data (query-distinct-column-name)](/developer-docs/api/get-distinct-values-from-field-data-query-distinct-column-name) | `/api/v1/query/distinct/{column_name}` | +| `GET` | [Get related fields data (query-related-column-name)](/developer-docs/api/get-related-fields-data-query-related-column-name) | `/api/v1/query/related/{column_name}` | +| `POST` | [Manually stop a query with client_id](/developer-docs/api/manually-stop-a-query-with-client-id) | `/api/v1/query/stop` | +| `GET` | [Get a list of queries that changed after last_updated_ms](/developer-docs/api/get-a-list-of-queries-that-changed-after-last-updated-ms) | `/api/v1/query/updated_since` | +| `DELETE` | [Bulk delete saved queries](/developer-docs/api/bulk-delete-saved-queries) | `/api/v1/saved_query/` | +| `GET` | [Get a list of saved queries](/developer-docs/api/get-a-list-of-saved-queries) | `/api/v1/saved_query/` | +| `POST` | [Create a saved query](/developer-docs/api/create-a-saved-query) | `/api/v1/saved_query/` | +| `GET` | [Get metadata information about this API resource (saved-query--info)](/developer-docs/api/get-metadata-information-about-this-api-resource-saved-query-info) | `/api/v1/saved_query/_info` | +| `DELETE` | [Delete a saved query](/developer-docs/api/delete-a-saved-query) | `/api/v1/saved_query/{pk}` | +| `GET` | [Get a saved query](/developer-docs/api/get-a-saved-query) | `/api/v1/saved_query/{pk}` | +| `PUT` | [Update a saved query](/developer-docs/api/update-a-saved-query) | `/api/v1/saved_query/{pk}` | +| `GET` | [Get distinct values from field data (saved-query-distinct-column-name)](/developer-docs/api/get-distinct-values-from-field-data-saved-query-distinct-column-name) | `/api/v1/saved_query/distinct/{column_name}` | +| `GET` | [Download multiple saved queries as YAML files](/developer-docs/api/download-multiple-saved-queries-as-yaml-files) | `/api/v1/saved_query/export/` | +| `POST` | [Import saved queries with associated databases](/developer-docs/api/import-saved-queries-with-associated-databases) | `/api/v1/saved_query/import/` | +| `GET` | [Get related fields data (saved-query-related-column-name)](/developer-docs/api/get-related-fields-data-saved-query-related-column-name) | `/api/v1/saved_query/related/{column_name}` | @@ -240,7 +240,7 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ | Method | Endpoint | Description | |--------|----------|-------------| -| `GET` | [Get possible values for a datasource column](./api/get-possible-values-for-a-datasource-column) | `/api/v1/datasource/{datasource_type}/{datasource_id}/column/{column_name}/values/` | +| `GET` | [Get possible values for a datasource column](/developer-docs/api/get-possible-values-for-a-datasource-column) | `/api/v1/datasource/{datasource_type}/{datasource_id}/column/{column_name}/values/` | @@ -249,8 +249,8 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ | Method | Endpoint | Description | |--------|----------|-------------| -| `GET` | [Return an AdvancedDataTypeResponse](./api/return-an-advanceddatatyperesponse) | `/api/v1/advanced_data_type/convert` | -| `GET` | [Return a list of available advanced data types](./api/return-a-list-of-available-advanced-data-types) | `/api/v1/advanced_data_type/types` | +| `GET` | [Return an AdvancedDataTypeResponse](/developer-docs/api/return-an-advanceddatatyperesponse) | `/api/v1/advanced_data_type/convert` | +| `GET` | [Return a list of available advanced data types](/developer-docs/api/return-a-list-of-available-advanced-data-types) | `/api/v1/advanced_data_type/types` | @@ -261,21 +261,21 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ | Method | Endpoint | Description | |--------|----------|-------------| -| `DELETE` | [Bulk delete tags](./api/bulk-delete-tags) | `/api/v1/tag/` | -| `GET` | [Get a list of tags](./api/get-a-list-of-tags) | `/api/v1/tag/` | -| `POST` | [Create a tag](./api/create-a-tag) | `/api/v1/tag/` | -| `GET` | [Get metadata information about tag API endpoints](./api/get-metadata-information-about-tag-api-endpoints) | `/api/v1/tag/_info` | -| `POST` | [Add tags to an object](./api/add-tags-to-an-object) | `/api/v1/tag/{object_type}/{object_id}/` | -| `DELETE` | [Delete a tagged object](./api/delete-a-tagged-object) | `/api/v1/tag/{object_type}/{object_id}/{tag}/` | -| `DELETE` | [Delete a tag](./api/delete-a-tag) | `/api/v1/tag/{pk}` | -| `GET` | [Get a tag detail information](./api/get-a-tag-detail-information) | `/api/v1/tag/{pk}` | -| `PUT` | [Update a tag](./api/update-a-tag) | `/api/v1/tag/{pk}` | -| `DELETE` | [Delete tag by pk favorites](./api/delete-tag-by-pk-favorites) | `/api/v1/tag/{pk}/favorites/` | -| `POST` | [Create tag by pk favorites](./api/create-tag-by-pk-favorites) | `/api/v1/tag/{pk}/favorites/` | -| `POST` | [Bulk create tags and tagged objects](./api/bulk-create-tags-and-tagged-objects) | `/api/v1/tag/bulk_create` | -| `GET` | [Get tag favorite status](./api/get-tag-favorite-status) | `/api/v1/tag/favorite_status/` | -| `GET` | [Get all objects associated with a tag](./api/get-all-objects-associated-with-a-tag) | `/api/v1/tag/get_objects/` | -| `GET` | [Get related fields data (tag-related-column-name)](./api/get-related-fields-data-tag-related-column-name) | `/api/v1/tag/related/{column_name}` | +| `DELETE` | [Bulk delete tags](/developer-docs/api/bulk-delete-tags) | `/api/v1/tag/` | +| `GET` | [Get a list of tags](/developer-docs/api/get-a-list-of-tags) | `/api/v1/tag/` | +| `POST` | [Create a tag](/developer-docs/api/create-a-tag) | `/api/v1/tag/` | +| `GET` | [Get metadata information about tag API endpoints](/developer-docs/api/get-metadata-information-about-tag-api-endpoints) | `/api/v1/tag/_info` | +| `POST` | [Add tags to an object](/developer-docs/api/add-tags-to-an-object) | `/api/v1/tag/{object_type}/{object_id}/` | +| `DELETE` | [Delete a tagged object](/developer-docs/api/delete-a-tagged-object) | `/api/v1/tag/{object_type}/{object_id}/{tag}/` | +| `DELETE` | [Delete a tag](/developer-docs/api/delete-a-tag) | `/api/v1/tag/{pk}` | +| `GET` | [Get a tag detail information](/developer-docs/api/get-a-tag-detail-information) | `/api/v1/tag/{pk}` | +| `PUT` | [Update a tag](/developer-docs/api/update-a-tag) | `/api/v1/tag/{pk}` | +| `DELETE` | [Delete tag by pk favorites](/developer-docs/api/delete-tag-by-pk-favorites) | `/api/v1/tag/{pk}/favorites/` | +| `POST` | [Create tag by pk favorites](/developer-docs/api/create-tag-by-pk-favorites) | `/api/v1/tag/{pk}/favorites/` | +| `POST` | [Bulk create tags and tagged objects](/developer-docs/api/bulk-create-tags-and-tagged-objects) | `/api/v1/tag/bulk_create` | +| `GET` | [Get tag favorite status](/developer-docs/api/get-tag-favorite-status) | `/api/v1/tag/favorite_status/` | +| `GET` | [Get all objects associated with a tag](/developer-docs/api/get-all-objects-associated-with-a-tag) | `/api/v1/tag/get_objects/` | +| `GET` | [Get related fields data (tag-related-column-name)](/developer-docs/api/get-related-fields-data-tag-related-column-name) | `/api/v1/tag/related/{column_name}` | @@ -284,20 +284,20 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ | Method | Endpoint | Description | |--------|----------|-------------| -| `DELETE` | [Delete multiple annotation layers in a bulk operation](./api/delete-multiple-annotation-layers-in-a-bulk-operation) | `/api/v1/annotation_layer/` | -| `GET` | [Get a list of annotation layers (annotation-layer)](./api/get-a-list-of-annotation-layers-annotation-layer) | `/api/v1/annotation_layer/` | -| `POST` | [Create an annotation layer (annotation-layer)](./api/create-an-annotation-layer-annotation-layer) | `/api/v1/annotation_layer/` | -| `GET` | [Get metadata information about this API resource (annotation-layer--info)](./api/get-metadata-information-about-this-api-resource-annotation-layer-info) | `/api/v1/annotation_layer/_info` | -| `DELETE` | [Delete annotation layer (annotation-layer-pk)](./api/delete-annotation-layer-annotation-layer-pk) | `/api/v1/annotation_layer/{pk}` | -| `GET` | [Get an annotation layer (annotation-layer-pk)](./api/get-an-annotation-layer-annotation-layer-pk) | `/api/v1/annotation_layer/{pk}` | -| `PUT` | [Update an annotation layer (annotation-layer-pk)](./api/update-an-annotation-layer-annotation-layer-pk) | `/api/v1/annotation_layer/{pk}` | -| `DELETE` | [Bulk delete annotation layers](./api/bulk-delete-annotation-layers) | `/api/v1/annotation_layer/{pk}/annotation/` | -| `GET` | [Get a list of annotation layers (annotation-layer-pk-annotation)](./api/get-a-list-of-annotation-layers-annotation-layer-pk-annotation) | `/api/v1/annotation_layer/{pk}/annotation/` | -| `POST` | [Create an annotation layer (annotation-layer-pk-annotation)](./api/create-an-annotation-layer-annotation-layer-pk-annotation) | `/api/v1/annotation_layer/{pk}/annotation/` | -| `DELETE` | [Delete annotation layer (annotation-layer-pk-annotation-annotation-id)](./api/delete-annotation-layer-annotation-layer-pk-annotation-annotation-id) | `/api/v1/annotation_layer/{pk}/annotation/{annotation_id}` | -| `GET` | [Get an annotation layer (annotation-layer-pk-annotation-annotation-id)](./api/get-an-annotation-layer-annotation-layer-pk-annotation-annotation-id) | `/api/v1/annotation_layer/{pk}/annotation/{annotation_id}` | -| `PUT` | [Update an annotation layer (annotation-layer-pk-annotation-annotation-id)](./api/update-an-annotation-layer-annotation-layer-pk-annotation-annotation-id) | `/api/v1/annotation_layer/{pk}/annotation/{annotation_id}` | -| `GET` | [Get related fields data (annotation-layer-related-column-name)](./api/get-related-fields-data-annotation-layer-related-column-name) | `/api/v1/annotation_layer/related/{column_name}` | +| `DELETE` | [Delete multiple annotation layers in a bulk operation](/developer-docs/api/delete-multiple-annotation-layers-in-a-bulk-operation) | `/api/v1/annotation_layer/` | +| `GET` | [Get a list of annotation layers (annotation-layer)](/developer-docs/api/get-a-list-of-annotation-layers-annotation-layer) | `/api/v1/annotation_layer/` | +| `POST` | [Create an annotation layer (annotation-layer)](/developer-docs/api/create-an-annotation-layer-annotation-layer) | `/api/v1/annotation_layer/` | +| `GET` | [Get metadata information about this API resource (annotation-layer--info)](/developer-docs/api/get-metadata-information-about-this-api-resource-annotation-layer-info) | `/api/v1/annotation_layer/_info` | +| `DELETE` | [Delete annotation layer (annotation-layer-pk)](/developer-docs/api/delete-annotation-layer-annotation-layer-pk) | `/api/v1/annotation_layer/{pk}` | +| `GET` | [Get an annotation layer (annotation-layer-pk)](/developer-docs/api/get-an-annotation-layer-annotation-layer-pk) | `/api/v1/annotation_layer/{pk}` | +| `PUT` | [Update an annotation layer (annotation-layer-pk)](/developer-docs/api/update-an-annotation-layer-annotation-layer-pk) | `/api/v1/annotation_layer/{pk}` | +| `DELETE` | [Bulk delete annotation layers](/developer-docs/api/bulk-delete-annotation-layers) | `/api/v1/annotation_layer/{pk}/annotation/` | +| `GET` | [Get a list of annotation layers (annotation-layer-pk-annotation)](/developer-docs/api/get-a-list-of-annotation-layers-annotation-layer-pk-annotation) | `/api/v1/annotation_layer/{pk}/annotation/` | +| `POST` | [Create an annotation layer (annotation-layer-pk-annotation)](/developer-docs/api/create-an-annotation-layer-annotation-layer-pk-annotation) | `/api/v1/annotation_layer/{pk}/annotation/` | +| `DELETE` | [Delete annotation layer (annotation-layer-pk-annotation-annotation-id)](/developer-docs/api/delete-annotation-layer-annotation-layer-pk-annotation-annotation-id) | `/api/v1/annotation_layer/{pk}/annotation/{annotation_id}` | +| `GET` | [Get an annotation layer (annotation-layer-pk-annotation-annotation-id)](/developer-docs/api/get-an-annotation-layer-annotation-layer-pk-annotation-annotation-id) | `/api/v1/annotation_layer/{pk}/annotation/{annotation_id}` | +| `PUT` | [Update an annotation layer (annotation-layer-pk-annotation-annotation-id)](/developer-docs/api/update-an-annotation-layer-annotation-layer-pk-annotation-annotation-id) | `/api/v1/annotation_layer/{pk}/annotation/{annotation_id}` | +| `GET` | [Get related fields data (annotation-layer-related-column-name)](/developer-docs/api/get-related-fields-data-annotation-layer-related-column-name) | `/api/v1/annotation_layer/related/{column_name}` | @@ -306,14 +306,14 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ | Method | Endpoint | Description | |--------|----------|-------------| -| `DELETE` | [Bulk delete CSS templates](./api/bulk-delete-css-templates) | `/api/v1/css_template/` | -| `GET` | [Get a list of CSS templates](./api/get-a-list-of-css-templates) | `/api/v1/css_template/` | -| `POST` | [Create a CSS template](./api/create-a-css-template) | `/api/v1/css_template/` | -| `GET` | [Get metadata information about this API resource (css-template--info)](./api/get-metadata-information-about-this-api-resource-css-template-info) | `/api/v1/css_template/_info` | -| `DELETE` | [Delete a CSS template](./api/delete-a-css-template) | `/api/v1/css_template/{pk}` | -| `GET` | [Get a CSS template](./api/get-a-css-template) | `/api/v1/css_template/{pk}` | -| `PUT` | [Update a CSS template](./api/update-a-css-template) | `/api/v1/css_template/{pk}` | -| `GET` | [Get related fields data (css-template-related-column-name)](./api/get-related-fields-data-css-template-related-column-name) | `/api/v1/css_template/related/{column_name}` | +| `DELETE` | [Bulk delete CSS templates](/developer-docs/api/bulk-delete-css-templates) | `/api/v1/css_template/` | +| `GET` | [Get a list of CSS templates](/developer-docs/api/get-a-list-of-css-templates) | `/api/v1/css_template/` | +| `POST` | [Create a CSS template](/developer-docs/api/create-a-css-template) | `/api/v1/css_template/` | +| `GET` | [Get metadata information about this API resource (css-template--info)](/developer-docs/api/get-metadata-information-about-this-api-resource-css-template-info) | `/api/v1/css_template/_info` | +| `DELETE` | [Delete a CSS template](/developer-docs/api/delete-a-css-template) | `/api/v1/css_template/{pk}` | +| `GET` | [Get a CSS template](/developer-docs/api/get-a-css-template) | `/api/v1/css_template/{pk}` | +| `PUT` | [Update a CSS template](/developer-docs/api/update-a-css-template) | `/api/v1/css_template/{pk}` | +| `GET` | [Get related fields data (css-template-related-column-name)](/developer-docs/api/get-related-fields-data-css-template-related-column-name) | `/api/v1/css_template/related/{column_name}` | @@ -324,8 +324,8 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ | Method | Endpoint | Description | |--------|----------|-------------| -| `POST` | [Create a new dashboard's permanent link](./api/create-a-new-dashboard-s-permanent-link) | `/api/v1/dashboard/{pk}/permalink` | -| `GET` | [Get dashboard's permanent link state](./api/get-dashboard-s-permanent-link-state) | `/api/v1/dashboard/permalink/{key}` | +| `POST` | [Create a new dashboard's permanent link](/developer-docs/api/create-a-new-dashboard-s-permanent-link) | `/api/v1/dashboard/{pk}/permalink` | +| `GET` | [Get dashboard's permanent link state](/developer-docs/api/get-dashboard-s-permanent-link-state) | `/api/v1/dashboard/permalink/{key}` | @@ -334,8 +334,8 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ | Method | Endpoint | Description | |--------|----------|-------------| -| `POST` | [Create a new permanent link (explore-permalink)](./api/create-a-new-permanent-link-explore-permalink) | `/api/v1/explore/permalink` | -| `GET` | [Get chart's permanent link state](./api/get-chart-s-permanent-link-state) | `/api/v1/explore/permalink/{key}` | +| `POST` | [Create a new permanent link (explore-permalink)](/developer-docs/api/create-a-new-permanent-link-explore-permalink) | `/api/v1/explore/permalink` | +| `GET` | [Get chart's permanent link state](/developer-docs/api/get-chart-s-permanent-link-state) | `/api/v1/explore/permalink/{key}` | @@ -344,8 +344,8 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ | Method | Endpoint | Description | |--------|----------|-------------| -| `POST` | [Create a new permanent link (sqllab-permalink)](./api/create-a-new-permanent-link-sqllab-permalink) | `/api/v1/sqllab/permalink` | -| `GET` | [Get permanent link state for SQLLab editor.](./api/get-permanent-link-state-for-sqllab-editor) | `/api/v1/sqllab/permalink/{key}` | +| `POST` | [Create a new permanent link (sqllab-permalink)](/developer-docs/api/create-a-new-permanent-link-sqllab-permalink) | `/api/v1/sqllab/permalink` | +| `GET` | [Get permanent link state for SQLLab editor.](/developer-docs/api/get-permanent-link-state-for-sqllab-editor) | `/api/v1/sqllab/permalink/{key}` | @@ -354,7 +354,7 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ | Method | Endpoint | Description | |--------|----------|-------------| -| `GET` | [Get a report schedule log (embedded-dashboard-uuid)](./api/get-a-report-schedule-log-embedded-dashboard-uuid) | `/api/v1/embedded_dashboard/{uuid}` | +| `GET` | [Get a report schedule log (embedded-dashboard-uuid)](/developer-docs/api/get-a-report-schedule-log-embedded-dashboard-uuid) | `/api/v1/embedded_dashboard/{uuid}` | @@ -363,10 +363,10 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ | Method | Endpoint | Description | |--------|----------|-------------| -| `POST` | [Create a dashboard's filter state](./api/create-a-dashboard-s-filter-state) | `/api/v1/dashboard/{pk}/filter_state` | -| `DELETE` | [Delete a dashboard's filter state value](./api/delete-a-dashboard-s-filter-state-value) | `/api/v1/dashboard/{pk}/filter_state/{key}` | -| `GET` | [Get a dashboard's filter state value](./api/get-a-dashboard-s-filter-state-value) | `/api/v1/dashboard/{pk}/filter_state/{key}` | -| `PUT` | [Update a dashboard's filter state value](./api/update-a-dashboard-s-filter-state-value) | `/api/v1/dashboard/{pk}/filter_state/{key}` | +| `POST` | [Create a dashboard's filter state](/developer-docs/api/create-a-dashboard-s-filter-state) | `/api/v1/dashboard/{pk}/filter_state` | +| `DELETE` | [Delete a dashboard's filter state value](/developer-docs/api/delete-a-dashboard-s-filter-state-value) | `/api/v1/dashboard/{pk}/filter_state/{key}` | +| `GET` | [Get a dashboard's filter state value](/developer-docs/api/get-a-dashboard-s-filter-state-value) | `/api/v1/dashboard/{pk}/filter_state/{key}` | +| `PUT` | [Update a dashboard's filter state value](/developer-docs/api/update-a-dashboard-s-filter-state-value) | `/api/v1/dashboard/{pk}/filter_state/{key}` | @@ -375,10 +375,10 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ | Method | Endpoint | Description | |--------|----------|-------------| -| `POST` | [Create a new form_data](./api/create-a-new-form-data) | `/api/v1/explore/form_data` | -| `DELETE` | [Delete a form_data](./api/delete-a-form-data) | `/api/v1/explore/form_data/{key}` | -| `GET` | [Get a form_data](./api/get-a-form-data) | `/api/v1/explore/form_data/{key}` | -| `PUT` | [Update an existing form_data](./api/update-an-existing-form-data) | `/api/v1/explore/form_data/{key}` | +| `POST` | [Create a new form_data](/developer-docs/api/create-a-new-form-data) | `/api/v1/explore/form_data` | +| `DELETE` | [Delete a form_data](/developer-docs/api/delete-a-form-data) | `/api/v1/explore/form_data/{key}` | +| `GET` | [Get a form_data](/developer-docs/api/get-a-form-data) | `/api/v1/explore/form_data/{key}` | +| `PUT` | [Update an existing form_data](/developer-docs/api/update-an-existing-form-data) | `/api/v1/explore/form_data/{key}` | @@ -389,17 +389,17 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ | Method | Endpoint | Description | |--------|----------|-------------| -| `DELETE` | [Bulk delete report schedules](./api/bulk-delete-report-schedules) | `/api/v1/report/` | -| `GET` | [Get a list of report schedules](./api/get-a-list-of-report-schedules) | `/api/v1/report/` | -| `POST` | [Create a report schedule](./api/create-a-report-schedule) | `/api/v1/report/` | -| `GET` | [Get metadata information about this API resource (report--info)](./api/get-metadata-information-about-this-api-resource-report-info) | `/api/v1/report/_info` | -| `DELETE` | [Delete a report schedule](./api/delete-a-report-schedule) | `/api/v1/report/{pk}` | -| `GET` | [Get a report schedule](./api/get-a-report-schedule) | `/api/v1/report/{pk}` | -| `PUT` | [Update a report schedule](./api/update-a-report-schedule) | `/api/v1/report/{pk}` | -| `GET` | [Get a list of report schedule logs](./api/get-a-list-of-report-schedule-logs) | `/api/v1/report/{pk}/log/` | -| `GET` | [Get a report schedule log (report-pk-log-log-id)](./api/get-a-report-schedule-log-report-pk-log-log-id) | `/api/v1/report/{pk}/log/{log_id}` | -| `GET` | [Get related fields data (report-related-column-name)](./api/get-related-fields-data-report-related-column-name) | `/api/v1/report/related/{column_name}` | -| `GET` | [Get slack channels](./api/get-slack-channels) | `/api/v1/report/slack_channels/` | +| `DELETE` | [Bulk delete report schedules](/developer-docs/api/bulk-delete-report-schedules) | `/api/v1/report/` | +| `GET` | [Get a list of report schedules](/developer-docs/api/get-a-list-of-report-schedules) | `/api/v1/report/` | +| `POST` | [Create a report schedule](/developer-docs/api/create-a-report-schedule) | `/api/v1/report/` | +| `GET` | [Get metadata information about this API resource (report--info)](/developer-docs/api/get-metadata-information-about-this-api-resource-report-info) | `/api/v1/report/_info` | +| `DELETE` | [Delete a report schedule](/developer-docs/api/delete-a-report-schedule) | `/api/v1/report/{pk}` | +| `GET` | [Get a report schedule](/developer-docs/api/get-a-report-schedule) | `/api/v1/report/{pk}` | +| `PUT` | [Update a report schedule](/developer-docs/api/update-a-report-schedule) | `/api/v1/report/{pk}` | +| `GET` | [Get a list of report schedule logs](/developer-docs/api/get-a-list-of-report-schedule-logs) | `/api/v1/report/{pk}/log/` | +| `GET` | [Get a report schedule log (report-pk-log-log-id)](/developer-docs/api/get-a-report-schedule-log-report-pk-log-log-id) | `/api/v1/report/{pk}/log/{log_id}` | +| `GET` | [Get related fields data (report-related-column-name)](/developer-docs/api/get-related-fields-data-report-related-column-name) | `/api/v1/report/related/{column_name}` | +| `GET` | [Get slack channels](/developer-docs/api/get-slack-channels) | `/api/v1/report/slack_channels/` | @@ -410,16 +410,16 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ | Method | Endpoint | Description | |--------|----------|-------------| -| `GET` | [Get security roles](./api/get-security-roles) | `/api/v1/security/roles/` | -| `POST` | [Create security roles](./api/create-security-roles) | `/api/v1/security/roles/` | -| `GET` | [Get security roles info](./api/get-security-roles-info) | `/api/v1/security/roles/_info` | -| `DELETE` | [Delete security roles by pk](./api/delete-security-roles-by-pk) | `/api/v1/security/roles/{pk}` | -| `GET` | [Get security roles by pk](./api/get-security-roles-by-pk) | `/api/v1/security/roles/{pk}` | -| `PUT` | [Update security roles by pk](./api/update-security-roles-by-pk) | `/api/v1/security/roles/{pk}` | -| `POST` | [Create security roles by role_id permissions](./api/create-security-roles-by-role-id-permissions) | `/api/v1/security/roles/{role_id}/permissions` | -| `GET` | [Get security roles by role_id permissions](./api/get-security-roles-by-role-id-permissions) | `/api/v1/security/roles/{role_id}/permissions/` | -| `PUT` | [Update security roles by role_id users](./api/update-security-roles-by-role-id-users) | `/api/v1/security/roles/{role_id}/users` | -| `GET` | [List roles](./api/list-roles) | `/api/v1/security/roles/search/` | +| `GET` | [Get security roles](/developer-docs/api/get-security-roles) | `/api/v1/security/roles/` | +| `POST` | [Create security roles](/developer-docs/api/create-security-roles) | `/api/v1/security/roles/` | +| `GET` | [Get security roles info](/developer-docs/api/get-security-roles-info) | `/api/v1/security/roles/_info` | +| `DELETE` | [Delete security roles by pk](/developer-docs/api/delete-security-roles-by-pk) | `/api/v1/security/roles/{pk}` | +| `GET` | [Get security roles by pk](/developer-docs/api/get-security-roles-by-pk) | `/api/v1/security/roles/{pk}` | +| `PUT` | [Update security roles by pk](/developer-docs/api/update-security-roles-by-pk) | `/api/v1/security/roles/{pk}` | +| `POST` | [Create security roles by role_id permissions](/developer-docs/api/create-security-roles-by-role-id-permissions) | `/api/v1/security/roles/{role_id}/permissions` | +| `GET` | [Get security roles by role_id permissions](/developer-docs/api/get-security-roles-by-role-id-permissions) | `/api/v1/security/roles/{role_id}/permissions/` | +| `PUT` | [Update security roles by role_id users](/developer-docs/api/update-security-roles-by-role-id-users) | `/api/v1/security/roles/{role_id}/users` | +| `GET` | [List roles](/developer-docs/api/list-roles) | `/api/v1/security/roles/search/` | @@ -428,12 +428,12 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ | Method | Endpoint | Description | |--------|----------|-------------| -| `GET` | [Get security users](./api/get-security-users) | `/api/v1/security/users/` | -| `POST` | [Create security users](./api/create-security-users) | `/api/v1/security/users/` | -| `GET` | [Get security users info](./api/get-security-users-info) | `/api/v1/security/users/_info` | -| `DELETE` | [Delete security users by pk](./api/delete-security-users-by-pk) | `/api/v1/security/users/{pk}` | -| `GET` | [Get security users by pk](./api/get-security-users-by-pk) | `/api/v1/security/users/{pk}` | -| `PUT` | [Update security users by pk](./api/update-security-users-by-pk) | `/api/v1/security/users/{pk}` | +| `GET` | [Get security users](/developer-docs/api/get-security-users) | `/api/v1/security/users/` | +| `POST` | [Create security users](/developer-docs/api/create-security-users) | `/api/v1/security/users/` | +| `GET` | [Get security users info](/developer-docs/api/get-security-users-info) | `/api/v1/security/users/_info` | +| `DELETE` | [Delete security users by pk](/developer-docs/api/delete-security-users-by-pk) | `/api/v1/security/users/{pk}` | +| `GET` | [Get security users by pk](/developer-docs/api/get-security-users-by-pk) | `/api/v1/security/users/{pk}` | +| `PUT` | [Update security users by pk](/developer-docs/api/update-security-users-by-pk) | `/api/v1/security/users/{pk}` | @@ -442,9 +442,9 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ | Method | Endpoint | Description | |--------|----------|-------------| -| `GET` | [Get security permissions](./api/get-security-permissions) | `/api/v1/security/permissions/` | -| `GET` | [Get security permissions info](./api/get-security-permissions-info) | `/api/v1/security/permissions/_info` | -| `GET` | [Get security permissions by pk](./api/get-security-permissions-by-pk) | `/api/v1/security/permissions/{pk}` | +| `GET` | [Get security permissions](/developer-docs/api/get-security-permissions) | `/api/v1/security/permissions/` | +| `GET` | [Get security permissions info](/developer-docs/api/get-security-permissions-info) | `/api/v1/security/permissions/_info` | +| `GET` | [Get security permissions by pk](/developer-docs/api/get-security-permissions-by-pk) | `/api/v1/security/permissions/{pk}` | @@ -453,12 +453,12 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ | Method | Endpoint | Description | |--------|----------|-------------| -| `GET` | [Get security resources](./api/get-security-resources) | `/api/v1/security/resources/` | -| `POST` | [Create security resources](./api/create-security-resources) | `/api/v1/security/resources/` | -| `GET` | [Get security resources info](./api/get-security-resources-info) | `/api/v1/security/resources/_info` | -| `DELETE` | [Delete security resources by pk](./api/delete-security-resources-by-pk) | `/api/v1/security/resources/{pk}` | -| `GET` | [Get security resources by pk](./api/get-security-resources-by-pk) | `/api/v1/security/resources/{pk}` | -| `PUT` | [Update security resources by pk](./api/update-security-resources-by-pk) | `/api/v1/security/resources/{pk}` | +| `GET` | [Get security resources](/developer-docs/api/get-security-resources) | `/api/v1/security/resources/` | +| `POST` | [Create security resources](/developer-docs/api/create-security-resources) | `/api/v1/security/resources/` | +| `GET` | [Get security resources info](/developer-docs/api/get-security-resources-info) | `/api/v1/security/resources/_info` | +| `DELETE` | [Delete security resources by pk](/developer-docs/api/delete-security-resources-by-pk) | `/api/v1/security/resources/{pk}` | +| `GET` | [Get security resources by pk](/developer-docs/api/get-security-resources-by-pk) | `/api/v1/security/resources/{pk}` | +| `PUT` | [Update security resources by pk](/developer-docs/api/update-security-resources-by-pk) | `/api/v1/security/resources/{pk}` | @@ -467,12 +467,12 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ | Method | Endpoint | Description | |--------|----------|-------------| -| `GET` | [Get security permissions resources](./api/get-security-permissions-resources) | `/api/v1/security/permissions-resources/` | -| `POST` | [Create security permissions resources](./api/create-security-permissions-resources) | `/api/v1/security/permissions-resources/` | -| `GET` | [Get security permissions resources info](./api/get-security-permissions-resources-info) | `/api/v1/security/permissions-resources/_info` | -| `DELETE` | [Delete security permissions resources by pk](./api/delete-security-permissions-resources-by-pk) | `/api/v1/security/permissions-resources/{pk}` | -| `GET` | [Get security permissions resources by pk](./api/get-security-permissions-resources-by-pk) | `/api/v1/security/permissions-resources/{pk}` | -| `PUT` | [Update security permissions resources by pk](./api/update-security-permissions-resources-by-pk) | `/api/v1/security/permissions-resources/{pk}` | +| `GET` | [Get security permissions resources](/developer-docs/api/get-security-permissions-resources) | `/api/v1/security/permissions-resources/` | +| `POST` | [Create security permissions resources](/developer-docs/api/create-security-permissions-resources) | `/api/v1/security/permissions-resources/` | +| `GET` | [Get security permissions resources info](/developer-docs/api/get-security-permissions-resources-info) | `/api/v1/security/permissions-resources/_info` | +| `DELETE` | [Delete security permissions resources by pk](/developer-docs/api/delete-security-permissions-resources-by-pk) | `/api/v1/security/permissions-resources/{pk}` | +| `GET` | [Get security permissions resources by pk](/developer-docs/api/get-security-permissions-resources-by-pk) | `/api/v1/security/permissions-resources/{pk}` | +| `PUT` | [Update security permissions resources by pk](/developer-docs/api/update-security-permissions-resources-by-pk) | `/api/v1/security/permissions-resources/{pk}` | @@ -481,14 +481,14 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ | Method | Endpoint | Description | |--------|----------|-------------| -| `DELETE` | [Bulk delete RLS rules](./api/bulk-delete-rls-rules) | `/api/v1/rowlevelsecurity/` | -| `GET` | [Get a list of RLS](./api/get-a-list-of-rls) | `/api/v1/rowlevelsecurity/` | -| `POST` | [Create a new RLS rule](./api/create-a-new-rls-rule) | `/api/v1/rowlevelsecurity/` | -| `GET` | [Get metadata information about this API resource (rowlevelsecurity--info)](./api/get-metadata-information-about-this-api-resource-rowlevelsecurity-info) | `/api/v1/rowlevelsecurity/_info` | -| `DELETE` | [Delete an RLS](./api/delete-an-rls) | `/api/v1/rowlevelsecurity/{pk}` | -| `GET` | [Get an RLS](./api/get-an-rls) | `/api/v1/rowlevelsecurity/{pk}` | -| `PUT` | [Update an RLS rule](./api/update-an-rls-rule) | `/api/v1/rowlevelsecurity/{pk}` | -| `GET` | [Get related fields data (rowlevelsecurity-related-column-name)](./api/get-related-fields-data-rowlevelsecurity-related-column-name) | `/api/v1/rowlevelsecurity/related/{column_name}` | +| `DELETE` | [Bulk delete RLS rules](/developer-docs/api/bulk-delete-rls-rules) | `/api/v1/rowlevelsecurity/` | +| `GET` | [Get a list of RLS](/developer-docs/api/get-a-list-of-rls) | `/api/v1/rowlevelsecurity/` | +| `POST` | [Create a new RLS rule](/developer-docs/api/create-a-new-rls-rule) | `/api/v1/rowlevelsecurity/` | +| `GET` | [Get metadata information about this API resource (rowlevelsecurity--info)](/developer-docs/api/get-metadata-information-about-this-api-resource-rowlevelsecurity-info) | `/api/v1/rowlevelsecurity/_info` | +| `DELETE` | [Delete an RLS](/developer-docs/api/delete-an-rls) | `/api/v1/rowlevelsecurity/{pk}` | +| `GET` | [Get an RLS](/developer-docs/api/get-an-rls) | `/api/v1/rowlevelsecurity/{pk}` | +| `PUT` | [Update an RLS rule](/developer-docs/api/update-an-rls-rule) | `/api/v1/rowlevelsecurity/{pk}` | +| `GET` | [Get related fields data (rowlevelsecurity-related-column-name)](/developer-docs/api/get-related-fields-data-rowlevelsecurity-related-column-name) | `/api/v1/rowlevelsecurity/related/{column_name}` | @@ -499,8 +499,8 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ | Method | Endpoint | Description | |--------|----------|-------------| -| `GET` | [Export all assets](./api/export-all-assets) | `/api/v1/assets/export/` | -| `POST` | [Import multiple assets](./api/import-multiple-assets) | `/api/v1/assets/import/` | +| `GET` | [Export all assets](/developer-docs/api/export-all-assets) | `/api/v1/assets/export/` | +| `POST` | [Import multiple assets](/developer-docs/api/import-multiple-assets) | `/api/v1/assets/import/` | @@ -509,7 +509,7 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ | Method | Endpoint | Description | |--------|----------|-------------| -| `POST` | [Invalidate cache records and remove the database records](./api/invalidate-cache-records-and-remove-the-database-records) | `/api/v1/cachekey/invalidate` | +| `POST` | [Invalidate cache records and remove the database records](/developer-docs/api/invalidate-cache-records-and-remove-the-database-records) | `/api/v1/cachekey/invalidate` | @@ -518,10 +518,10 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ | Method | Endpoint | Description | |--------|----------|-------------| -| `GET` | [Get a list of logs](./api/get-a-list-of-logs) | `/api/v1/log/` | -| `POST` | [Create log](./api/create-log) | `/api/v1/log/` | -| `GET` | [Get a log detail information](./api/get-a-log-detail-information) | `/api/v1/log/{pk}` | -| `GET` | [Get recent activity data for a user](./api/get-recent-activity-data-for-a-user) | `/api/v1/log/recent_activity/` | +| `GET` | [Get a list of logs](/developer-docs/api/get-a-list-of-logs) | `/api/v1/log/` | +| `POST` | [Create log](/developer-docs/api/create-log) | `/api/v1/log/` | +| `GET` | [Get a log detail information](/developer-docs/api/get-a-log-detail-information) | `/api/v1/log/{pk}` | +| `GET` | [Get recent activity data for a user](/developer-docs/api/get-recent-activity-data-for-a-user) | `/api/v1/log/recent_activity/` | @@ -532,8 +532,8 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ | Method | Endpoint | Description | |--------|----------|-------------| -| `GET` | [Get the user object](./api/get-the-user-object) | `/api/v1/me/` | -| `GET` | [Get the user roles](./api/get-the-user-roles) | `/api/v1/me/roles/` | +| `GET` | [Get the user object](/developer-docs/api/get-the-user-object) | `/api/v1/me/` | +| `GET` | [Get the user roles](/developer-docs/api/get-the-user-roles) | `/api/v1/me/roles/` | @@ -542,7 +542,7 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ | Method | Endpoint | Description | |--------|----------|-------------| -| `GET` | [Get the user avatar](./api/get-the-user-avatar) | `/api/v1/user/{user_id}/avatar.png` | +| `GET` | [Get the user avatar](/developer-docs/api/get-the-user-avatar) | `/api/v1/user/{user_id}/avatar.png` | @@ -551,7 +551,7 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ | Method | Endpoint | Description | |--------|----------|-------------| -| `GET` | [Get menu](./api/get-menu) | `/api/v1/menu/` | +| `GET` | [Get menu](/developer-docs/api/get-menu) | `/api/v1/menu/` | @@ -560,7 +560,7 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ | Method | Endpoint | Description | |--------|----------|-------------| -| `GET` | [Get all available domains](./api/get-all-available-domains) | `/api/v1/available_domains/` | +| `GET` | [Get all available domains](/developer-docs/api/get-all-available-domains) | `/api/v1/available_domains/` | @@ -569,7 +569,7 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ | Method | Endpoint | Description | |--------|----------|-------------| -| `GET` | [Read off of the Redis events stream](./api/read-off-of-the-redis-events-stream) | `/api/v1/async_event/` | +| `GET` | [Read off of the Redis events stream](/developer-docs/api/read-off-of-the-redis-events-stream) | `/api/v1/async_event/` | @@ -578,7 +578,7 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ | Method | Endpoint | Description | |--------|----------|-------------| -| `GET` | [Get api by version openapi](./api/get-api-by-version-openapi) | `/api/{version}/_openapi` | +| `GET` | [Get api by version openapi](/developer-docs/api/get-api-by-version-openapi) | `/api/{version}/_openapi` | diff --git a/docs/scripts/generate-api-index.mjs b/docs/scripts/generate-api-index.mjs index e5f6a7e6934..2fd9fddac63 100644 --- a/docs/scripts/generate-api-index.mjs +++ b/docs/scripts/generate-api-index.mjs @@ -202,7 +202,7 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \\ mdx += `| Method | Endpoint | Description |\n`; mdx += `|--------|----------|-------------|\n`; for (const ep of tagEndpoints['Security']) { - mdx += `| \`${ep.method}\` | [${ep.summary}](./api/${ep.slug}) | \`${ep.path}\` |\n`; + mdx += `| \`${ep.method}\` | [${ep.summary}](/developer-docs/api/${ep.slug}) | \`${ep.path}\` |\n`; } mdx += '\n'; renderedTags.add('Security'); @@ -229,7 +229,7 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \\ mdx += `|--------|----------|-------------|\n`; for (const ep of endpoints) { - mdx += `| \`${ep.method}\` | [${ep.summary}](./api/${ep.slug}) | \`${ep.path}\` |\n`; + mdx += `| \`${ep.method}\` | [${ep.summary}](/developer-docs/api/${ep.slug}) | \`${ep.path}\` |\n`; } mdx += `\n\n\n`; @@ -252,7 +252,7 @@ curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \\ mdx += `|--------|----------|-------------|\n`; for (const ep of endpoints) { - mdx += `| \`${ep.method}\` | [${ep.summary}](./api/${ep.slug}) | \`${ep.path}\` |\n`; + mdx += `| \`${ep.method}\` | [${ep.summary}](/developer-docs/api/${ep.slug}) | \`${ep.path}\` |\n`; } mdx += `\n\n\n`; From afe093f1ca5fc7b35254f95f545f1770a4cc11d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C4=90=E1=BB=97=20Tr=E1=BB=8Dng=20H=E1=BA=A3i?= <41283691+hainenber@users.noreply.github.com> Date: Mon, 16 Mar 2026 20:00:52 +0700 Subject: [PATCH 53/58] fix(FilterBar): reduce padded space between filter header and first filter (#38646) Signed-off-by: hainenber --- .../components/nativeFilters/FilterBar/Header/index.tsx | 6 +++--- .../components/nativeFilters/FilterBar/Vertical.tsx | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/Header/index.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/Header/index.tsx index 6fae1d5c6ed..10650a79ade 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/Header/index.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/Header/index.tsx @@ -33,6 +33,7 @@ const TitleArea = styled.div` justify-content: space-between; margin: 0; padding: 0 ${theme.sizeUnit * 2}px ${theme.sizeUnit * 2}px; + padding-bottom: 0; /* Works with other changes in PR https://github.com/apache/superset/pull/38646 to reduces space between filter header and 1st filter */ & > span { font-size: ${theme.fontSizeLG}px; @@ -56,9 +57,8 @@ const HeaderButton = styled(Button)` const Wrapper = styled.div` ${({ theme }) => ` - padding: ${theme.sizeUnit * 3}px ${theme.sizeUnit * 2}px ${ - theme.sizeUnit - }px; + padding: ${theme.sizeUnit * 3}px ${theme.sizeUnit * 2}px; + padding-bottom: 0; /* Works with other changes in PR https://github.com/apache/superset/pull/38646 to reduces space between filter header and 1st filter */ `} `; diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/Vertical.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/Vertical.tsx index 9db9b8b0579..fa9ab3c4183 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/Vertical.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/Vertical.tsx @@ -119,6 +119,7 @@ const FilterControlsWrapper = styled.div` flex-direction: column; gap: ${theme.sizeUnit * 2}px; padding: ${theme.sizeUnit * 4}px; + padding-top: 0; /* Works with other changes in PR https://github.com/apache/superset/pull/38646 to reduces space between filter header and 1st filter */ // 108px padding to make room for buttons with position: absolute padding-bottom: ${theme.sizeUnit * 27}px; `} From dcb414aa0640498e55735716ecb0d94e25bcc5c4 Mon Sep 17 00:00:00 2001 From: Ville Brofeldt <33317356+villebro@users.noreply.github.com> Date: Mon, 16 Mar 2026 07:02:42 -0700 Subject: [PATCH 54/58] feat(extensions): add update command to extensions cli (#38651) --- docs/developer_docs/extensions/development.md | 6 +- requirements/development.txt | 2 + superset-extensions-cli/pyproject.toml | 1 + .../src/superset_extensions_cli/cli.py | 161 ++++++++++++++++ .../src/superset_extensions_cli/utils.py | 10 + superset-extensions-cli/tests/conftest.py | 68 +++++++ .../tests/test_cli_build.py | 4 +- .../tests/test_cli_update.py | 172 ++++++++++++++++++ .../tests/test_cli_validate.py | 63 +++++++ superset-extensions-cli/tests/test_utils.py | 31 +++- 10 files changed, 514 insertions(+), 4 deletions(-) create mode 100644 superset-extensions-cli/tests/test_cli_update.py diff --git a/docs/developer_docs/extensions/development.md b/docs/developer_docs/extensions/development.md index cd38f1e47d2..a10f7743110 100644 --- a/docs/developer_docs/extensions/development.md +++ b/docs/developer_docs/extensions/development.md @@ -39,7 +39,11 @@ superset-extensions bundle: Packages the extension into a .supx file. superset-extensions dev: Automatically rebuilds the extension as files change. -superset-extensions validate: Validates the extension structure and metadata. +superset-extensions validate: Validates the extension structure and metadata consistency. + +superset-extensions update: Updates derived and generated files in the extension project. + Use --version [] to update the version (prompts if no value given). + Use --license [] to update the license (prompts if no value given). ``` When creating a new extension with `superset-extensions init`, the CLI generates a standardized folder structure: diff --git a/requirements/development.txt b/requirements/development.txt index 296fe15d820..2b9fffd474b 100644 --- a/requirements/development.txt +++ b/requirements/development.txt @@ -996,6 +996,8 @@ tabulate==0.9.0 # via # -c requirements/base-constraint.txt # apache-superset +tomli-w==1.2.0 + # via apache-superset-extensions-cli tomlkit==0.13.3 # via pylint tqdm==4.67.1 diff --git a/superset-extensions-cli/pyproject.toml b/superset-extensions-cli/pyproject.toml index 3f88e880997..8f853a86e36 100644 --- a/superset-extensions-cli/pyproject.toml +++ b/superset-extensions-cli/pyproject.toml @@ -49,6 +49,7 @@ dependencies = [ "jinja2>=3.1.6", "semver>=3.0.4", "tomli>=2.2.1; python_version < '3.11'", + "tomli-w>=1.2.0", "watchdog>=6.0.0", ] diff --git a/superset-extensions-cli/src/superset_extensions_cli/cli.py b/superset-extensions-cli/src/superset_extensions_cli/cli.py index 079f4e639a3..feab2ab5d8a 100644 --- a/superset-extensions-cli/src/superset_extensions_cli/cli.py +++ b/superset-extensions-cli/src/superset_extensions_cli/cli.py @@ -50,6 +50,8 @@ from superset_extensions_cli.utils import ( validate_display_name, validate_publisher, validate_technical_name, + write_json, + write_toml, ) REMOTE_ENTRY_REGEX = re.compile(r"^remoteEntry\..+\.js$") @@ -292,6 +294,7 @@ def app() -> None: @app.command() def validate() -> None: + """Validate the extension structure and metadata consistency.""" validate_npm() cwd = Path.cwd() @@ -372,12 +375,167 @@ def validate() -> None: click.secho(" Convention requires: frontend/src/index.tsx", fg="yellow") sys.exit(1) + # Validate version and license consistency across extension.json, frontend, and backend + mismatches: list[str] = [] + frontend_pkg_path = cwd / "frontend" / "package.json" + frontend_pkg = None + if frontend_pkg_path.is_file(): + frontend_pkg = read_json(frontend_pkg_path) + if frontend_pkg: + if frontend_pkg.get("version") != extension.version: + mismatches.append( + f" frontend/package.json version: {frontend_pkg.get('version')} " + f"(expected {extension.version})" + ) + if extension.license and frontend_pkg.get("license") != extension.license: + mismatches.append( + f" frontend/package.json license: {frontend_pkg.get('license')} " + f"(expected {extension.license})" + ) + + backend_pyproject_path = cwd / "backend" / "pyproject.toml" + if backend_pyproject_path.is_file(): + backend_pyproject = read_toml(backend_pyproject_path) + if backend_pyproject: + project = backend_pyproject.get("project", {}) + if project.get("version") != extension.version: + mismatches.append( + f" backend/pyproject.toml version: {project.get('version')} " + f"(expected {extension.version})" + ) + if extension.license and project.get("license") != extension.license: + mismatches.append( + f" backend/pyproject.toml license: {project.get('license')} " + f"(expected {extension.license})" + ) + + if mismatches: + click.secho("❌ Metadata mismatch detected:", err=True, fg="red") + for mismatch in mismatches: + click.secho(mismatch, err=True, fg="red") + click.secho( + "Run `superset-extensions update` to sync from extension.json.", + fg="yellow", + ) + sys.exit(1) + click.secho("✅ Validation successful", fg="green") +@app.command() +@click.option( + "--version", + "version_opt", + is_flag=False, + flag_value="__prompt__", + default=None, + help="Set a new version. Prompts for value if none given.", +) +@click.option( + "--license", + "license_opt", + is_flag=False, + flag_value="__prompt__", + default=None, + help="Set a new license. Prompts for value if none given.", +) +def update(version_opt: str | None, license_opt: str | None) -> None: + """Update derived and generated files in the extension project.""" + cwd = Path.cwd() + + extension_json_path = cwd / "extension.json" + extension_data = read_json(extension_json_path) + if not extension_data: + click.secho("❌ extension.json not found.", err=True, fg="red") + sys.exit(1) + + try: + extension = ExtensionConfig.model_validate(extension_data) + except Exception as e: + click.secho(f"❌ Invalid extension.json: {e}", err=True, fg="red") + sys.exit(1) + + # Resolve version: prompt if flag used without value + if version_opt == "__prompt__": + version_opt = click.prompt("Version", default=extension.version) + target_version = ( + version_opt + if version_opt and version_opt != extension.version + else extension.version + ) + + # Resolve license: prompt if flag used without value + if license_opt == "__prompt__": + license_opt = click.prompt("License", default=extension.license or "") + target_license = ( + license_opt + if license_opt and license_opt != extension.license + else extension.license + ) + + updated: list[str] = [] + + # Update extension.json if version or license changed + ext_changed = False + if version_opt and version_opt != extension.version: + extension_data["version"] = target_version + ext_changed = True + if license_opt and license_opt != extension.license: + extension_data["license"] = target_license + ext_changed = True + if ext_changed: + try: + ExtensionConfig.model_validate(extension_data) + except Exception as e: + click.secho(f"❌ Invalid value: {e}", err=True, fg="red") + sys.exit(1) + write_json(extension_json_path, extension_data) + updated.append("extension.json") + + # Update frontend/package.json + frontend_pkg_path = cwd / "frontend" / "package.json" + if frontend_pkg_path.is_file(): + frontend_pkg = read_json(frontend_pkg_path) + if frontend_pkg: + pkg_changed = False + if frontend_pkg.get("version") != target_version: + frontend_pkg["version"] = target_version + pkg_changed = True + if target_license and frontend_pkg.get("license") != target_license: + frontend_pkg["license"] = target_license + pkg_changed = True + if pkg_changed: + write_json(frontend_pkg_path, frontend_pkg) + updated.append("frontend/package.json") + + # Update backend/pyproject.toml + backend_pyproject_path = cwd / "backend" / "pyproject.toml" + if backend_pyproject_path.is_file(): + backend_pyproject = read_toml(backend_pyproject_path) + if backend_pyproject: + project = backend_pyproject.setdefault("project", {}) + toml_changed = False + if project.get("version") != target_version: + project["version"] = target_version + toml_changed = True + if target_license and project.get("license") != target_license: + project["license"] = target_license + toml_changed = True + if toml_changed: + write_toml(backend_pyproject_path, backend_pyproject) + updated.append("backend/pyproject.toml") + + if updated: + for path in updated: + click.secho(f"✅ Updated {path}", fg="green") + else: + click.secho("✅ All files already up to date.", fg="green") + + @app.command() @click.pass_context def build(ctx: click.Context) -> None: + """Build extension assets.""" ctx.invoke(validate) cwd = Path.cwd() frontend_dir = cwd / "frontend" @@ -413,6 +571,7 @@ def build(ctx: click.Context) -> None: ) @click.pass_context def bundle(ctx: click.Context, output: Path | None) -> None: + """Package the extension into a .supx file.""" ctx.invoke(build) cwd = Path.cwd() @@ -453,6 +612,7 @@ def bundle(ctx: click.Context, output: Path | None) -> None: @app.command() @click.pass_context def dev(ctx: click.Context) -> None: + """Automatically rebuild the extension as files change.""" cwd = Path.cwd() frontend_dir = cwd / "frontend" backend_dir = cwd / "backend" @@ -647,6 +807,7 @@ def init( frontend_opt: bool | None, backend_opt: bool | None, ) -> None: + """Scaffold a new extension project.""" # Get extension names with graceful validation names = prompt_for_extension_info(display_name_opt, publisher_opt, name_opt) diff --git a/superset-extensions-cli/src/superset_extensions_cli/utils.py b/superset-extensions-cli/src/superset_extensions_cli/utils.py index bda0f3c4a80..c4fae04e6ec 100644 --- a/superset-extensions-cli/src/superset_extensions_cli/utils.py +++ b/superset-extensions-cli/src/superset_extensions_cli/utils.py @@ -21,6 +21,8 @@ import sys from pathlib import Path from typing import Any +import tomli_w + from superset_core.extensions.constants import ( DISPLAY_NAME_PATTERN, PUBLISHER_PATTERN, @@ -109,6 +111,14 @@ def read_json(path: Path) -> dict[str, Any] | None: return json.loads(path.read_text()) +def write_json(path: Path, data: dict[str, Any]) -> None: + path.write_text(json.dumps(data, indent=2) + "\n") + + +def write_toml(path: Path, data: dict[str, Any]) -> None: + path.write_text(tomli_w.dumps(data)) + + def _normalize_for_identifiers(name: str) -> str: """ Normalize display name to clean lowercase words. diff --git a/superset-extensions-cli/tests/conftest.py b/superset-extensions-cli/tests/conftest.py index 8c223be5051..a4cc7f7521c 100644 --- a/superset-extensions-cli/tests/conftest.py +++ b/superset-extensions-cli/tests/conftest.py @@ -17,10 +17,12 @@ from __future__ import annotations +import json import os from pathlib import Path import pytest +import tomli_w from click.testing import CliRunner @@ -138,3 +140,69 @@ def extension_setup_for_bundling(): (backend_dir / "__init__.py").write_text("# init") return _setup + + +@pytest.fixture +def extension_with_versions(): + """Create an extension directory structure with configurable versions and licenses.""" + + def _create( + base_path: Path, + ext_version: str = "1.0.0", + frontend_version: str | None = None, + backend_version: str | None = None, + ext_license: str | None = "Apache-2.0", + frontend_license: str | None = None, + backend_license: str | None = None, + ) -> None: + extension_json = { + "publisher": "test-org", + "name": "test-extension", + "displayName": "Test Extension", + "version": ext_version, + "permissions": [], + } + if ext_license is not None: + extension_json["license"] = ext_license + (base_path / "extension.json").write_text(json.dumps(extension_json)) + + if frontend_version is not None: + frontend_dir = base_path / "frontend" + frontend_dir.mkdir(exist_ok=True) + (frontend_dir / "src").mkdir(exist_ok=True) + (frontend_dir / "src" / "index.tsx").write_text("// entry") + pkg = { + "name": "@test-org/test-extension", + "version": frontend_version, + } + if frontend_license is not None: + pkg["license"] = frontend_license + elif ext_license is not None: + pkg["license"] = ext_license + (frontend_dir / "package.json").write_text(json.dumps(pkg, indent=2)) + + if backend_version is not None: + backend_dir = base_path / "backend" + backend_dir.mkdir(exist_ok=True) + src_dir = backend_dir / "src" / "test_org" / "test_extension" + src_dir.mkdir(parents=True, exist_ok=True) + (src_dir / "entrypoint.py").write_text("# entry") + project = { + "name": "test-org-test-extension", + "version": backend_version, + } + if backend_license is not None: + project["license"] = backend_license + elif ext_license is not None: + project["license"] = ext_license + pyproject = { + "project": project, + "tool": { + "apache_superset_extensions": { + "build": {"include": ["src/**/*.py"]} + } + }, + } + (backend_dir / "pyproject.toml").write_text(tomli_w.dumps(pyproject)) + + return _create diff --git a/superset-extensions-cli/tests/test_cli_build.py b/superset-extensions-cli/tests/test_cli_build.py index 90f63fdd5f5..5753eff4892 100644 --- a/superset-extensions-cli/tests/test_cli_build.py +++ b/superset-extensions-cli/tests/test_cli_build.py @@ -121,7 +121,7 @@ def test_build_command_success_flow( # Setup mocks mock_rebuild_frontend.return_value = "remoteEntry.abc123.js" mock_read_toml.return_value = { - "project": {"name": "test"}, + "project": {"name": "test", "version": "1.0.0"}, "tool": { "apache_superset_extensions": { "build": {"include": ["src/test_org/test_extension/**/*.py"]} @@ -162,7 +162,7 @@ def test_build_command_handles_frontend_build_failure( # Setup mocks mock_rebuild_frontend.return_value = None # Indicates failure mock_read_toml.return_value = { - "project": {"name": "test"}, + "project": {"name": "test", "version": "1.0.0"}, "tool": { "apache_superset_extensions": { "build": {"include": ["src/test_org/test_extension/**/*.py"]} diff --git a/superset-extensions-cli/tests/test_cli_update.py b/superset-extensions-cli/tests/test_cli_update.py new file mode 100644 index 00000000000..36b965fb54d --- /dev/null +++ b/superset-extensions-cli/tests/test_cli_update.py @@ -0,0 +1,172 @@ +# 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 __future__ import annotations + +import pytest +from superset_extensions_cli.cli import app +from superset_extensions_cli.utils import read_json, read_toml + + +@pytest.mark.cli +def test_update_syncs_versions( + cli_runner, isolated_filesystem, extension_with_versions +): + """Test update syncs frontend and backend versions from extension.json.""" + extension_with_versions( + isolated_filesystem, + ext_version="2.0.0", + frontend_version="1.0.0", + backend_version="1.0.0", + ) + + result = cli_runner.invoke(app, ["update"]) + + assert result.exit_code == 0 + assert "Updated frontend/package.json" in result.output + assert "Updated backend/pyproject.toml" in result.output + + frontend_pkg = read_json(isolated_filesystem / "frontend" / "package.json") + assert frontend_pkg["version"] == "2.0.0" + + backend_pyproject = read_toml(isolated_filesystem / "backend" / "pyproject.toml") + assert backend_pyproject["project"]["version"] == "2.0.0" + + +@pytest.mark.cli +def test_update_noop_when_all_match( + cli_runner, isolated_filesystem, extension_with_versions +): + """Test update reports no changes when everything already matches.""" + extension_with_versions( + isolated_filesystem, + ext_version="1.0.0", + frontend_version="1.0.0", + backend_version="1.0.0", + ) + + result = cli_runner.invoke(app, ["update"]) + + assert result.exit_code == 0 + assert "All files already up to date" in result.output + + +@pytest.mark.cli +def test_update_fails_without_extension_json(cli_runner, isolated_filesystem): + """Test update fails when extension.json is missing.""" + result = cli_runner.invoke(app, ["update"]) + + assert result.exit_code != 0 + assert "extension.json not found" in result.output + + +@pytest.mark.cli +def test_update_with_version_flag( + cli_runner, isolated_filesystem, extension_with_versions +): + """Test --version updates extension.json first, then syncs all files.""" + extension_with_versions( + isolated_filesystem, + ext_version="1.0.0", + frontend_version="1.0.0", + backend_version="1.0.0", + ) + + result = cli_runner.invoke(app, ["update", "--version", "3.0.0"]) + + assert result.exit_code == 0 + assert "Updated extension.json" in result.output + assert "Updated frontend/package.json" in result.output + assert "Updated backend/pyproject.toml" in result.output + + ext = read_json(isolated_filesystem / "extension.json") + assert ext["version"] == "3.0.0" + + frontend_pkg = read_json(isolated_filesystem / "frontend" / "package.json") + assert frontend_pkg["version"] == "3.0.0" + + backend_pyproject = read_toml(isolated_filesystem / "backend" / "pyproject.toml") + assert backend_pyproject["project"]["version"] == "3.0.0" + + +@pytest.mark.cli +def test_update_with_license_flag( + cli_runner, isolated_filesystem, extension_with_versions +): + """Test --license updates license across all files.""" + extension_with_versions( + isolated_filesystem, + ext_version="1.0.0", + frontend_version="1.0.0", + backend_version="1.0.0", + ext_license="Apache-2.0", + ) + + result = cli_runner.invoke(app, ["update", "--license", "MIT"]) + + assert result.exit_code == 0 + assert "Updated extension.json" in result.output + assert "Updated frontend/package.json" in result.output + assert "Updated backend/pyproject.toml" in result.output + + ext = read_json(isolated_filesystem / "extension.json") + assert ext["license"] == "MIT" + + frontend_pkg = read_json(isolated_filesystem / "frontend" / "package.json") + assert frontend_pkg["license"] == "MIT" + + backend_pyproject = read_toml(isolated_filesystem / "backend" / "pyproject.toml") + assert backend_pyproject["project"]["license"] == "MIT" + + +@pytest.mark.cli +def test_update_version_prompt_default( + cli_runner, isolated_filesystem, extension_with_versions +): + """Test --version without value prompts with current version as default.""" + extension_with_versions( + isolated_filesystem, + ext_version="1.0.0", + frontend_version="1.0.0", + backend_version="1.0.0", + ) + + # Hit enter to accept default — nothing should change + result = cli_runner.invoke(app, ["update", "--version"], input="\n") + + assert result.exit_code == 0 + assert "All files already up to date" in result.output + + +@pytest.mark.cli +def test_update_rejects_invalid_version( + cli_runner, isolated_filesystem, extension_with_versions +): + """Test --version with an invalid semver string exits with error.""" + extension_with_versions( + isolated_filesystem, + ext_version="1.0.0", + ) + + result = cli_runner.invoke(app, ["update", "--version", "not-a-version"]) + + assert result.exit_code != 0 + assert "Invalid value" in result.output + + # Verify extension.json was not modified + ext = read_json(isolated_filesystem / "extension.json") + assert ext["version"] == "1.0.0" diff --git a/superset-extensions-cli/tests/test_cli_validate.py b/superset-extensions-cli/tests/test_cli_validate.py index 970a2ce13ce..b796aedd930 100644 --- a/superset-extensions-cli/tests/test_cli_validate.py +++ b/superset-extensions-cli/tests/test_cli_validate.py @@ -207,3 +207,66 @@ def test_validate_npm_with_empty_version_output_raises_error(mock_run, mock_whic # semver.compare will raise ValueError for empty version with pytest.raises(ValueError): validate_npm() + + +# Version Consistency Tests +@pytest.mark.cli +def test_validate_fails_on_version_mismatch( + cli_runner, isolated_filesystem, extension_with_versions +): + """Test validate fails when frontend/backend versions differ from extension.json.""" + extension_with_versions( + isolated_filesystem, + ext_version="2.0.0", + frontend_version="1.0.0", + backend_version="1.0.0", + ) + + with patch("superset_extensions_cli.cli.validate_npm"): + result = cli_runner.invoke(app, ["validate"]) + + assert result.exit_code != 0 + assert "Metadata mismatch" in result.output + assert "superset-extensions update" in result.output + + +@pytest.mark.cli +def test_validate_passes_with_matching_versions( + cli_runner, isolated_filesystem, extension_with_versions +): + """Test validate passes when all versions match extension.json.""" + extension_with_versions( + isolated_filesystem, + ext_version="1.0.0", + frontend_version="1.0.0", + backend_version="1.0.0", + ) + + with patch("superset_extensions_cli.cli.validate_npm"): + result = cli_runner.invoke(app, ["validate"]) + + assert result.exit_code == 0 + assert "Validation successful" in result.output + + +@pytest.mark.cli +def test_validate_fails_on_license_mismatch( + cli_runner, isolated_filesystem, extension_with_versions +): + """Test validate fails when frontend/backend licenses differ from extension.json.""" + extension_with_versions( + isolated_filesystem, + ext_version="1.0.0", + frontend_version="1.0.0", + backend_version="1.0.0", + ext_license="Apache-2.0", + frontend_license="MIT", + backend_license="MIT", + ) + + with patch("superset_extensions_cli.cli.validate_npm"): + result = cli_runner.invoke(app, ["validate"]) + + assert result.exit_code != 0 + assert "Metadata mismatch" in result.output + assert "license" in result.output diff --git a/superset-extensions-cli/tests/test_utils.py b/superset-extensions-cli/tests/test_utils.py index 1df07c72a60..4e87eaf9303 100644 --- a/superset-extensions-cli/tests/test_utils.py +++ b/superset-extensions-cli/tests/test_utils.py @@ -20,7 +20,7 @@ from __future__ import annotations import json import pytest -from superset_extensions_cli.utils import read_json, read_toml +from superset_extensions_cli.utils import read_json, read_toml, write_json, write_toml # Read JSON Tests @@ -269,3 +269,32 @@ def test_read_toml_with_permission_denied(isolated_filesystem): toml_file.chmod(0o644) except (OSError, PermissionError): pass + + +# Write JSON Tests +@pytest.mark.unit +def test_write_json_round_trip(isolated_filesystem): + """Test write_json then read_json round-trip preserves content.""" + data = {"name": "test-extension", "version": "2.0.0", "nested": {"key": "value"}} + json_file = isolated_filesystem / "output.json" + + write_json(json_file, data) + result = read_json(json_file) + + assert result == data + + +# Write TOML Tests +@pytest.mark.unit +def test_write_toml_round_trip(isolated_filesystem): + """Test write_toml then read_toml round-trip preserves content.""" + data = { + "project": {"name": "test-package", "version": "1.0.0"}, + "tool": {"apache_superset_extensions": {"build": {"include": ["src/**/*.py"]}}}, + } + toml_file = isolated_filesystem / "output.toml" + + write_toml(toml_file, data) + result = read_toml(toml_file) + + assert result == data From aa5adb0fce1a8bfe5834331f18b902685a03d485 Mon Sep 17 00:00:00 2001 From: Joe Li Date: Mon, 16 Mar 2026 09:33:33 -0700 Subject: [PATCH 55/58] fix(embedded): default to light theme instead of system preference (#38644) Co-authored-by: Claude Opus 4.6 --- .../packages/superset-core/src/theme/types.ts | 1 + .../src/embedded/EmbeddedContextProviders.tsx | 3 +- .../src/theme/ThemeController.ts | 11 ++ .../src/theme/tests/ThemeController.test.ts | 112 ++++++++++++++++++ 4 files changed, 126 insertions(+), 1 deletion(-) diff --git a/superset-frontend/packages/superset-core/src/theme/types.ts b/superset-frontend/packages/superset-core/src/theme/types.ts index a0b30ca5956..87bd226f874 100644 --- a/superset-frontend/packages/superset-core/src/theme/types.ts +++ b/superset-frontend/packages/superset-core/src/theme/types.ts @@ -426,6 +426,7 @@ export interface ThemeControllerOptions { canUpdateTheme?: () => boolean; canUpdateMode?: () => boolean; isGlobalContext?: boolean; + initialMode?: ThemeMode; } export interface ThemeContextType { diff --git a/superset-frontend/src/embedded/EmbeddedContextProviders.tsx b/superset-frontend/src/embedded/EmbeddedContextProviders.tsx index 9ea9c0744ab..0a832ed294a 100644 --- a/superset-frontend/src/embedded/EmbeddedContextProviders.tsx +++ b/superset-frontend/src/embedded/EmbeddedContextProviders.tsx @@ -26,7 +26,7 @@ import { DynamicPluginProvider } from 'src/components'; import { EmbeddedUiConfigProvider } from 'src/components/UiConfigContext'; import { SupersetThemeProvider } from 'src/theme/ThemeProvider'; import { ThemeController } from 'src/theme/ThemeController'; -import { type ThemeStorage } from '@apache-superset/core/theme'; +import { type ThemeStorage, ThemeMode } from '@apache-superset/core/theme'; import { store } from 'src/views/store'; import querystring from 'query-string'; @@ -52,6 +52,7 @@ class ThemeMemoryStorageAdapter implements ThemeStorage { const themeController = new ThemeController({ storage: new ThemeMemoryStorageAdapter(), + initialMode: ThemeMode.DEFAULT, }); export const getThemeController = (): ThemeController => themeController; diff --git a/superset-frontend/src/theme/ThemeController.ts b/superset-frontend/src/theme/ThemeController.ts index fafbe784914..5234ff19aa5 100644 --- a/superset-frontend/src/theme/ThemeController.ts +++ b/superset-frontend/src/theme/ThemeController.ts @@ -102,15 +102,19 @@ export class ThemeController { // Track loaded font URLs to avoid duplicate injections private loadedFontUrls: Set = new Set(); + private initialMode: ThemeMode | undefined; + constructor({ storage = new LocalStorageAdapter(), modeStorageKey = STORAGE_KEYS.THEME_MODE, themeObject = supersetThemeObject, defaultTheme = (supersetThemeObject.theme as AnyThemeConfig) ?? {}, onChange = undefined, + initialMode = undefined, }: ThemeControllerOptions = {}) { this.storage = storage; this.modeStorageKey = modeStorageKey; + this.initialMode = initialMode; // Controller creates and owns the global theme this.globalTheme = themeObject; @@ -743,6 +747,13 @@ export class ThemeController { return ThemeMode.DEFAULT; } + // Use explicit initial mode if provided (e.g. embedded dashboards default to light) + if ( + this.initialMode !== undefined && + this.isValidThemeMode(this.initialMode) + ) + return this.initialMode; + // Default to system preference when both themes are available return ThemeMode.SYSTEM; } diff --git a/superset-frontend/src/theme/tests/ThemeController.test.ts b/superset-frontend/src/theme/tests/ThemeController.test.ts index b6a68a3a11a..e905ed5e7c3 100644 --- a/superset-frontend/src/theme/tests/ThemeController.test.ts +++ b/superset-frontend/src/theme/tests/ThemeController.test.ts @@ -1686,3 +1686,115 @@ test('font loading: adds new font URLs when switching themes', () => { .querySelectorAll('style[data-superset-fonts]') .forEach(el => el.remove()); }); + +test('ThemeController uses initialMode when provided and no saved mode exists', () => { + mockGetBootstrapData.mockReturnValue( + createMockBootstrapData({ + default: DEFAULT_THEME, + dark: DARK_THEME, + }), + ); + + const controller = createController({ initialMode: ThemeMode.DEFAULT }); + + expect(controller.getCurrentMode()).toBe(ThemeMode.DEFAULT); +}); + +test('ThemeController defaults to SYSTEM when initialMode is not provided', () => { + mockGetBootstrapData.mockReturnValue( + createMockBootstrapData({ + default: DEFAULT_THEME, + dark: DARK_THEME, + }), + ); + + const controller = createController(); + + expect(controller.getCurrentMode()).toBe(ThemeMode.SYSTEM); +}); + +test('ThemeController saved mode takes precedence over initialMode', () => { + mockGetBootstrapData.mockReturnValue( + createMockBootstrapData({ + default: DEFAULT_THEME, + dark: DARK_THEME, + }), + ); + + mockLocalStorage.getItem.mockReturnValue(ThemeMode.DARK); + + const controller = createController({ initialMode: ThemeMode.DEFAULT }); + + expect(controller.getCurrentMode()).toBe(ThemeMode.DARK); +}); + +test('ThemeController with initialMode DEFAULT applies light theme even when system prefers dark', () => { + mockGetBootstrapData.mockReturnValue( + createMockBootstrapData({ + default: DEFAULT_THEME, + dark: DARK_THEME, + }), + ); + + mockMatchMedia.mockReturnValue({ + matches: true, // system prefers dark + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + }); + + const controller = createController({ initialMode: ThemeMode.DEFAULT }); + + expect(controller.getCurrentMode()).toBe(ThemeMode.DEFAULT); + const lastCall = + mockSetConfig.mock.calls[mockSetConfig.mock.calls.length - 1][0]; + expect(lastCall.token.colorBgBase).toBe(DEFAULT_THEME.token!.colorBgBase); +}); + +test('ThemeController with initialMode still allows setThemeMode after init', () => { + mockGetBootstrapData.mockReturnValue( + createMockBootstrapData({ + default: DEFAULT_THEME, + dark: DARK_THEME, + }), + ); + + const controller = createController({ initialMode: ThemeMode.DEFAULT }); + expect(controller.getCurrentMode()).toBe(ThemeMode.DEFAULT); + + controller.setThemeMode(ThemeMode.DARK); + expect(controller.getCurrentMode()).toBe(ThemeMode.DARK); + + controller.setThemeMode(ThemeMode.SYSTEM); + expect(controller.getCurrentMode()).toBe(ThemeMode.SYSTEM); +}); + +test('ThemeController initialMode is ignored when no dark theme exists', () => { + mockGetBootstrapData.mockReturnValue( + createMockBootstrapData({ + default: DEFAULT_THEME, + dark: {}, + }), + ); + + const controller = createController({ initialMode: ThemeMode.SYSTEM }); + + // Should still be DEFAULT because there's no dark theme available + expect(controller.getCurrentMode()).toBe(ThemeMode.DEFAULT); +}); + +test('ThemeController invalid initialMode falls back to SYSTEM', () => { + mockGetBootstrapData.mockReturnValue( + createMockBootstrapData({ + default: DEFAULT_THEME, + dark: DARK_THEME, + }), + ); + + const controller = createController({ + initialMode: 'invalid' as ThemeMode, + }); + + // Invalid initialMode should be rejected by isValidThemeMode, + // falling through to the default SYSTEM mode + expect(controller.getCurrentMode()).toBe(ThemeMode.SYSTEM); +}); From 7909095ff3463653fdd4801a1492b00573d55d16 Mon Sep 17 00:00:00 2001 From: endimonan <65144790+endimonan@users.noreply.github.com> Date: Tue, 17 Mar 2026 01:11:53 +0100 Subject: [PATCH 56/58] feat(native-filters): add configurable LIKE/ILIKE operators to Select filter (#38470) Co-authored-by: Evan Rusackas Co-authored-by: Richard Fogaca Nienkotter <63572350+richardfogaca@users.noreply.github.com> --- .../src/query/types/Operator.ts | 1 + .../FiltersConfigForm/FiltersConfigForm.tsx | 111 ++++- .../FiltersConfigForm/getControlItemsMap.tsx | 3 +- .../Select/SelectFilterPlugin.test.tsx | 413 +++++++++++++++++- .../components/Select/SelectFilterPlugin.tsx | 152 +++++-- .../components/Select/buildQuery.test.ts | 94 +++- .../components/Select/controlPanel.test.ts | 43 ++ .../filters/components/Select/controlPanel.ts | 75 +++- .../components/Select/transformProps.ts | 11 +- .../src/filters/components/Select/types.ts | 9 + superset-frontend/src/filters/utils.ts | 45 +- 11 files changed, 907 insertions(+), 50 deletions(-) create mode 100644 superset-frontend/src/filters/components/Select/controlPanel.test.ts diff --git a/superset-frontend/packages/superset-ui-core/src/query/types/Operator.ts b/superset-frontend/packages/superset-ui-core/src/query/types/Operator.ts index 0d2cb5b59ba..98695555068 100644 --- a/superset-frontend/packages/superset-ui-core/src/query/types/Operator.ts +++ b/superset-frontend/packages/superset-ui-core/src/query/types/Operator.ts @@ -30,6 +30,7 @@ const BINARY_OPERATORS = [ '<=', 'ILIKE', 'LIKE', + 'NOT ILIKE', 'NOT LIKE', 'REGEX', 'TEMPORAL_RANGE', diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FiltersConfigForm.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FiltersConfigForm.tsx index ab6a9f22e69..cc53dcbcb61 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FiltersConfigForm.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FiltersConfigForm.tsx @@ -45,12 +45,16 @@ import { useEffect, useImperativeHandle, useMemo, + useRef, useState, RefObject, memo, } from 'react'; import rison from 'rison'; -import { PluginFilterSelectCustomizeProps } from 'src/filters/components/Select/types'; +import { + PluginFilterSelectCustomizeProps, + SelectFilterOperatorType, +} from 'src/filters/components/Select/types'; import { useSelector } from 'react-redux'; import { getChartDataRequest } from 'src/components/Chart/chartAction'; import { @@ -624,6 +628,49 @@ const FiltersConfigForm = ( forceUpdate(); }; + const currentOperatorType: SelectFilterOperatorType = + formFilter?.controlValues?.operatorType ?? + filterToEdit?.controlValues?.operatorType ?? + SelectFilterOperatorType.Exact; + + const selectedColumnIsString = useMemo(() => { + const columnName = formFilter?.column; + if (!columnName || !datasetDetails?.columns) return true; + const colMeta = datasetDetails.columns.find( + (c: { column_name: string }) => c.column_name === columnName, + ); + if (!colMeta) return true; + return colMeta.type_generic === GenericDataType.String; + }, [formFilter?.column, datasetDetails?.columns]); + + const onOperatorTypeChanged = (value: SelectFilterOperatorType) => { + const previous = form.getFieldValue('filters')?.[filterId].controlValues; + setNativeFilterFieldValues(form, filterId, { + controlValues: { + ...previous, + operatorType: value, + }, + defaultDataMask: null, + }); + formChanged(); + forceUpdate(); + }; + + const prevColumnRef = useRef(formFilter?.column); + const datasetLoaded = !!datasetDetails?.columns; + useEffect(() => { + const columnChanged = prevColumnRef.current !== formFilter?.column; + if ( + (columnChanged || datasetLoaded) && + !selectedColumnIsString && + currentOperatorType !== SelectFilterOperatorType.Exact + ) { + onOperatorTypeChanged(SelectFilterOperatorType.Exact); + } + prevColumnRef.current = formFilter?.column; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [formFilter?.column, selectedColumnIsString, datasetLoaded]); + const validatePreFilter = () => setTimeout( () => @@ -685,6 +732,7 @@ const FiltersConfigForm = ( 'columns.filterable', 'columns.is_dttm', 'columns.type', + 'columns.type_generic', 'columns.verbose_name', 'database.id', 'database.database_name', @@ -1501,6 +1549,67 @@ const FiltersConfigForm = ( hidden initialValue={null} /> + {!isChartCustomization && + itemTypeField === 'filter_select' && ( + + {t('Match type')} +   + + + } + > + (parentRef?.current as HTMLElement) || document.body - : (trigger: HTMLElement) => - (trigger?.parentNode as HTMLElement) || document.body - } - showSearch={showSearch} - mode={multiSelect ? 'multiple' : 'single'} - placeholder={placeholderText} - onClear={() => onSearch('')} - onSearch={onSearch} - onBlur={handleBlur} - onFocus={setFocusedFilter} - onMouseEnter={setHoveredFilter} - onMouseLeave={unsetHoveredFilter} - // @ts-expect-error - onChange={handleChange} - ref={inputRef} - loading={isRefreshing} - oneLine={filterBarOrientation === FilterBarOrientation.Horizontal} - invertSelection={inverseSelection && excludeFilterValues} - options={options} - sortComparator={sortComparator} - onOpenChange={setFilterActive} - className="select-container" - /> + {isLikeOperator ? ( + + ) : ( +