This is an automated email from the ASF dual-hosted git repository.
aminghadersohi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new e6d088d6b1b fix(mcp): surface rejected filter columns in get_chart_sql
(#43680)
e6d088d6b1b is described below
commit e6d088d6b1bf460dcb0653eb539f606893b4283a
Author: Amin Ghadersohi <[email protected]>
AuthorDate: Tue Sep 8 18:05:37 2026 -0400
fix(mcp): surface rejected filter columns in get_chart_sql (#43680)
---
superset/common/query_actions.py | 78 +++++++++++++---
superset/mcp_service/chart/chart_helpers.py | 70 ++++++++++++++
superset/mcp_service/chart/tool/get_chart_data.py | 64 +------------
superset/mcp_service/chart/tool/get_chart_sql.py | 19 +++-
superset/models/helpers.py | 9 +-
.../charts/commands/data/test_get_data_command.py | 15 ++-
tests/unit_tests/common/test_query_actions.py | 97 ++++++++++++++++++++
.../mcp_service/chart/tool/test_get_chart_data.py | 39 ++++++--
.../mcp_service/chart/tool/test_get_chart_sql.py | 102 +++++++++++++++++++++
9 files changed, 404 insertions(+), 89 deletions(-)
diff --git a/superset/common/query_actions.py b/superset/common/query_actions.py
index 7668ebfe0c9..1591e92338c 100644
--- a/superset/common/query_actions.py
+++ b/superset/common/query_actions.py
@@ -78,6 +78,41 @@ def _get_timegrains(
return {"data": grains}
+def _filter_status(
+ datasource: Explorable,
+ query_obj: QueryObject,
+ applied_filter_columns: list[Any],
+ rejected_filter_columns: list[Any],
+) -> dict[str, Any]:
+ """Describe which of the query's filters reached the generated SQL.
+
+ Used by both SQL-only and data-bearing results so the public filter status
+ and the datasource-only rejection carrier cannot diverge.
+ """
+ applied_time_columns, rejected_time_columns = get_time_filter_status(
+ datasource, query_obj.applied_time_extras
+ )
+ return {
+ "applied_filters": [
+ {"column": get_column_name(col)} for col in applied_filter_columns
+ ]
+ + applied_time_columns,
+ "rejected_filters": [
+ {
+ "reason": ExtraFiltersReasonType.COL_NOT_IN_DATASOURCE,
+ "column": get_column_name(col),
+ }
+ for col in rejected_filter_columns
+ ]
+ + rejected_time_columns,
+ # Keep the datasource rejection origin available to consumers that
+ # must distinguish it from temporal pseudo-filter status above.
+ "rejected_filter_columns": [
+ get_column_name(col) for col in rejected_filter_columns
+ ],
+ }
+
+
def _get_query(
query_context: QueryContext,
query_obj: QueryObject,
@@ -86,7 +121,26 @@ def _get_query(
datasource = _get_datasource(query_context, query_obj)
result = {"language": datasource.query_language}
try:
- result["query"] = datasource.get_query_str(query_obj.to_dict())
+ # Prefer the extended form so the rejected/applied filter columns the
+ # datasource computed while building the query are not discarded: a
+ # filter silently dropped during query construction is otherwise
+ # invisible to anyone requesting only the SQL. Datasources that do not
+ # implement it (e.g. semantic layers) keep the plain string form.
+ if get_query_str_extended := getattr(
+ datasource, "get_query_str_extended", None
+ ):
+ extended = get_query_str_extended(query_obj.to_dict())
+ result["query"] = extended.full_sql
+ result.update(
+ _filter_status(
+ datasource,
+ query_obj,
+ extended.applied_filter_columns,
+ extended.rejected_filter_columns,
+ )
+ )
+ else:
+ result["query"] = datasource.get_query_str(query_obj.to_dict())
except QueryObjectValidationError as err:
# Validation errors (missing required fields, invalid config)
# No SQL was generated
@@ -185,24 +239,18 @@ def _materialize_full_payload(
)
del payload["df"]
- applied_time_columns, rejected_time_columns = get_time_filter_status(
- datasource, query_obj.applied_time_extras
- )
-
applied_filter_columns = payload.get("applied_filter_columns", [])
rejected_filter_columns = payload.get("rejected_filter_columns", [])
del payload["applied_filter_columns"]
del payload["rejected_filter_columns"]
- payload["applied_filters"] = [
- {"column": get_column_name(col)} for col in applied_filter_columns
- ] + applied_time_columns
- payload["rejected_filters"] = [
- {
- "reason": ExtraFiltersReasonType.COL_NOT_IN_DATASOURCE,
- "column": get_column_name(col),
- }
- for col in rejected_filter_columns
- ] + rejected_time_columns
+ payload.update(
+ _filter_status(
+ datasource,
+ query_obj,
+ applied_filter_columns,
+ rejected_filter_columns,
+ )
+ )
if result_type == ChartDataResultType.RESULTS and status !=
QueryStatus.FAILED:
return {
diff --git a/superset/mcp_service/chart/chart_helpers.py
b/superset/mcp_service/chart/chart_helpers.py
index 95da1318576..f76e3426f24 100644
--- a/superset/mcp_service/chart/chart_helpers.py
+++ b/superset/mcp_service/chart/chart_helpers.py
@@ -30,6 +30,7 @@ from typing import Any, TYPE_CHECKING
from urllib.parse import parse_qs, urlparse
from superset.constants import EXTRA_FORM_DATA_OVERRIDE_REGULAR_MAPPINGS
+from superset.utils.core import ExtraFiltersReasonType
if TYPE_CHECKING:
from superset.mcp_service.chart.schemas import AppliedDashboardFilter
@@ -62,6 +63,75 @@ class ChartNotOnDashboardError(ValueError):
"""Raised when a chart is not part of the given dashboard's slices."""
+def requested_filter_columns(extra_form_data: dict[str, Any] | None) ->
set[str]:
+ """Return simple column names explicitly requested through extra form
data."""
+ if not extra_form_data:
+ return set()
+
+ columns: set[str] = set()
+ for filter_ in extra_form_data.get("filters") or []:
+ if isinstance(filter_, dict) and isinstance(column :=
filter_.get("col"), str):
+ columns.add(column)
+ for filter_ in extra_form_data.get("adhoc_filters") or []:
+ if (
+ isinstance(filter_, dict)
+ and filter_.get("expressionType") == "SIMPLE"
+ and isinstance(column := filter_.get("subject"), str)
+ ):
+ columns.add(column)
+ return columns
+
+
+def rejected_columns_in_query(query: Any) -> set[str]:
+ """Return the rejected filter column names reported by one query payload.
+
+ Query construction reports dropped filters as ``rejected_filters`` entries
+ (``{"reason": ..., "column": ...}``), the shape every consumer of a
+ chart-data or query payload sees. The raw ``rejected_filter_columns`` list
+ is still accepted for payloads captured before that conversion.
+ """
+ if not isinstance(query, dict):
+ return set()
+
+ # QUERY results retain the datasource-only list so temporal pseudo-filter
+ # rejections cannot be mistaken for ordinary filters with the same name.
+ # Prefer it whenever present, including when it is empty.
+ if "rejected_filter_columns" in query:
+ return {
+ column
+ for column in query.get("rejected_filter_columns") or []
+ if isinstance(column, str)
+ }
+
+ columns = {
+ column
+ for entry in query.get("rejected_filters") or []
+ if isinstance(entry, dict)
+ and entry.get("reason") != ExtraFiltersReasonType.NO_TEMPORAL_COLUMN
+ and isinstance(column := entry.get("column"), str)
+ }
+ return columns
+
+
+def rejected_requested_filter_columns(
+ result: Any, extra_form_data: dict[str, Any] | None
+) -> list[str]:
+ """Find request filters rejected by datasource query construction.
+
+ Only columns the caller asked for are reported, so a stale filter stored in
+ an older chart configuration cannot fail the request.
+ """
+ if not isinstance(result, dict):
+ return []
+ requested = requested_filter_columns(extra_form_data)
+ rejected = {
+ column
+ for query in result.get("queries", [])
+ for column in rejected_columns_in_query(query)
+ }
+ return sorted(requested & rejected)
+
+
def find_chart_by_identifier(
identifier: int | str,
query_options: list[Any] | None = None,
diff --git a/superset/mcp_service/chart/tool/get_chart_data.py
b/superset/mcp_service/chart/tool/get_chart_data.py
index 1016ea01b9c..77f4904c8c8 100644
--- a/superset/mcp_service/chart/tool/get_chart_data.py
+++ b/superset/mcp_service/chart/tool/get_chart_data.py
@@ -43,6 +43,7 @@ from superset.mcp_service.chart.chart_helpers import (
find_chart_by_identifier,
get_cached_form_data,
merge_extra_form_data_filters_into_query,
+ rejected_requested_filter_columns,
)
from superset.mcp_service.chart.chart_utils import validate_chart_dataset
from superset.mcp_service.chart.schemas import (
@@ -63,65 +64,6 @@ from superset.utils.core import GenericDataType
logger = logging.getLogger(__name__)
-def _requested_filter_columns(extra_form_data: dict[str, Any] | None) ->
set[str]:
- """Return simple column names explicitly requested through extra form
data."""
- if not extra_form_data:
- return set()
-
- columns: set[str] = set()
- for filter_ in extra_form_data.get("filters", []):
- if isinstance(filter_, dict) and isinstance(column :=
filter_.get("col"), str):
- columns.add(column)
- for filter_ in extra_form_data.get("adhoc_filters", []):
- if (
- isinstance(filter_, dict)
- and filter_.get("expressionType") == "SIMPLE"
- and isinstance(column := filter_.get("subject"), str)
- ):
- columns.add(column)
- return columns
-
-
-def _rejected_columns_in_query(query: Any) -> set[str]:
- """Return the rejected filter column names reported by one query payload.
-
- ``_materialize_full_payload`` converts the datasource's raw
- ``rejected_filter_columns`` list into the ``rejected_filters`` entries
- (``{"reason": ..., "column": ...}``) that every consumer of a chart-data
- payload sees, so that is the primary shape to read. The raw key is still
- accepted for payloads captured before that conversion.
- """
- if not isinstance(query, dict):
- return set()
-
- columns = {
- column
- for entry in query.get("rejected_filters", [])
- if isinstance(entry, dict) and isinstance(column :=
entry.get("column"), str)
- }
- columns.update(
- column
- for column in query.get("rejected_filter_columns", [])
- if isinstance(column, str)
- )
- return columns
-
-
-def _rejected_requested_filter_columns(
- result: Any, extra_form_data: dict[str, Any] | None
-) -> list[str]:
- """Find request filters rejected by datasource query construction."""
- if not isinstance(result, dict):
- return []
- requested = _requested_filter_columns(extra_form_data)
- rejected = {
- column
- for query in result.get("queries", [])
- for column in _rejected_columns_in_query(query)
- }
- return sorted(requested & rejected)
-
-
_GENERIC_TYPE_MAP: dict[int, str] = {
GenericDataType.NUMERIC: "numeric",
GenericDataType.STRING: "string",
@@ -747,7 +689,7 @@ async def get_chart_data( # noqa: C901
command.validate()
result = command.run()
- if rejected := _rejected_requested_filter_columns(
+ if rejected := rejected_requested_filter_columns(
result, request.extra_form_data
):
rejected_columns = ", ".join(rejected)
@@ -1109,7 +1051,7 @@ async def _query_from_form_data( # noqa: C901
command.validate()
result = command.run()
- if rejected := _rejected_requested_filter_columns(
+ if rejected := rejected_requested_filter_columns(
result, request.extra_form_data
):
rejected_columns = ", ".join(rejected)
diff --git a/superset/mcp_service/chart/tool/get_chart_sql.py
b/superset/mcp_service/chart/tool/get_chart_sql.py
index 3914e2672ce..33a7276ba95 100644
--- a/superset/mcp_service/chart/tool/get_chart_sql.py
+++ b/superset/mcp_service/chart/tool/get_chart_sql.py
@@ -38,6 +38,7 @@ from superset.mcp_service.chart.chart_helpers import (
build_query_context_from_form_data,
extract_x_axis_col,
merge_extra_form_data_filters_into_query,
+ rejected_requested_filter_columns,
resolve_form_data_datasource,
resolve_groupby,
resolve_metrics,
@@ -238,7 +239,11 @@ def _sql_from_saved_query_context(
result = command.run()
return _extract_sql_from_result(
- result, chart.id, chart.slice_name, chart.datasource_name
+ result,
+ chart.id,
+ chart.slice_name,
+ chart.datasource_name,
+ extra_form_data=extra_form_data,
)
except SupersetSecurityException:
raise # Let access denials propagate for consistent error handling
@@ -326,6 +331,7 @@ def _sql_from_form_data(
chart_id=getattr(chart, "id", None),
chart_name=getattr(chart, "slice_name", None),
datasource_name=_resolve_datasource_name(form_data, chart),
+ extra_form_data=extra_form_data,
)
@@ -334,6 +340,7 @@ def _extract_sql_from_result(
chart_id: int | None,
chart_name: str | None,
datasource_name: str | None,
+ extra_form_data: dict[str, Any] | None = None,
) -> ChartSql | ChartError:
"""Extract SQL query string(s) from the ChartDataCommand result.
@@ -350,6 +357,16 @@ def _extract_sql_from_result(
error_type="EmptyQuery",
)
+ # A filter naming a column the dataset does not have is dropped during
query
+ # construction. Returning the resulting unfiltered SQL as a success would
+ # misrepresent it as the SQL for the filters that were asked for.
+ if rejected := rejected_requested_filter_columns(result, extra_form_data):
+ rejected_columns = ", ".join(rejected)
+ return ChartError(
+ error=f"Unknown dataset column(s) in filters: {rejected_columns}",
+ error_type="ValidationError",
+ )
+
sql_parts: list[str] = []
errors: list[str] = []
language = "sql"
diff --git a/superset/models/helpers.py b/superset/models/helpers.py
index 8d2cae5ce36..06f8274025f 100644
--- a/superset/models/helpers.py
+++ b/superset/models/helpers.py
@@ -1745,6 +1745,11 @@ class QueryStringExtended(NamedTuple):
sql: str
sql_shifted_temporal_labels: set[str]
+ @property
+ def full_sql(self) -> str:
+ """The prequeries and the main query as one displayable statement."""
+ return ";\n\n".join([*self.prequeries, self.sql]) + ";"
+
class SqlaQuery(NamedTuple):
applied_template_filters: list[str]
@@ -3864,9 +3869,7 @@ class ExploreMixin: # pylint:
disable=too-many-public-methods
return values
def get_query_str(self, query_obj: QueryObjectDict) -> str:
- query_str_ext = self.get_query_str_extended(query_obj)
- all_queries = query_str_ext.prequeries + [query_str_ext.sql]
- return ";\n\n".join(all_queries) + ";"
+ return self.get_query_str_extended(query_obj).full_sql
def _get_series_orderby(
self,
diff --git a/tests/unit_tests/charts/commands/data/test_get_data_command.py
b/tests/unit_tests/charts/commands/data/test_get_data_command.py
index c4d7c9281a1..e4691653df1 100644
--- a/tests/unit_tests/charts/commands/data/test_get_data_command.py
+++ b/tests/unit_tests/charts/commands/data/test_get_data_command.py
@@ -291,7 +291,10 @@ def test_get_query_catches_parsing_error() -> None:
with patch("superset.common.query_actions._get_datasource") as mock_get_ds:
mock_datasource = Mock()
mock_datasource.query_language = "sql"
- mock_datasource.get_query_str.side_effect = parse_error
+ # SQL is compiled through get_query_str_extended so the datasource's
+ # applied/rejected filter columns survive into the payload; the parse
+ # error surfaces from there.
+ mock_datasource.get_query_str_extended.side_effect = parse_error
mock_get_ds.return_value = mock_datasource
# GREEN: Exception is caught, values returned (new behavior after fix)
@@ -330,7 +333,10 @@ def
test_get_query_handles_parsing_error_with_missing_sql_key() -> None:
with patch("superset.common.query_actions._get_datasource") as mock_get_ds:
mock_datasource = Mock()
mock_datasource.query_language = "sql"
- mock_datasource.get_query_str.side_effect = parse_error
+ # SQL is compiled through get_query_str_extended so the datasource's
+ # applied/rejected filter columns survive into the payload; the parse
+ # error surfaces from there.
+ mock_datasource.get_query_str_extended.side_effect = parse_error
mock_get_ds.return_value = mock_datasource
result = _get_query(mock_query_context, mock_query_obj, False)
@@ -367,7 +373,10 @@ def
test_get_query_handles_parsing_error_with_null_sql_value() -> None:
with patch("superset.common.query_actions._get_datasource") as mock_get_ds:
mock_datasource = Mock()
mock_datasource.query_language = "sql"
- mock_datasource.get_query_str.side_effect = parse_error
+ # SQL is compiled through get_query_str_extended so the datasource's
+ # applied/rejected filter columns survive into the payload; the parse
+ # error surfaces from there.
+ mock_datasource.get_query_str_extended.side_effect = parse_error
mock_get_ds.return_value = mock_datasource
result = _get_query(mock_query_context, mock_query_obj, False)
diff --git a/tests/unit_tests/common/test_query_actions.py
b/tests/unit_tests/common/test_query_actions.py
index 0cb68b7b1f2..996f427482b 100644
--- a/tests/unit_tests/common/test_query_actions.py
+++ b/tests/unit_tests/common/test_query_actions.py
@@ -25,6 +25,7 @@ from superset.common.chart_data_timing import (
QueryAcquisitionResult,
QueryAcquisitionTiming,
)
+from superset.common.db_query_status import QueryStatus
from superset.common.query_actions import (
_prepare_drill_detail_query,
_prepare_samples_query,
@@ -281,3 +282,99 @@ def
test_legacy_result_wrapper_delegates_to_timed_resolver() -> None:
query_obj,
False,
)
+
+
+def test_get_query_surfaces_rejected_filter_columns() -> None:
+ """The QUERY result type must report filters dropped while building the
SQL.
+
+ A filter naming a column the dataset does not have is silently discarded
+ during query construction. Without this, a caller asking only for the SQL
+ (e.g. the MCP get_chart_sql tool, or "View query") receives the unfiltered
+ statement with no indication that a filter was dropped.
+ """
+ from superset.common.query_actions import _get_query
+ from superset.models.helpers import QueryStringExtended
+
+ extended = QueryStringExtended(
+ applied_template_filters=[],
+ applied_filter_columns=["country"],
+ rejected_filter_columns=["does_not_exist"],
+ labels_expected=[],
+ prequeries=[],
+ sql="SELECT country FROM sales",
+ sql_shifted_temporal_labels=set(),
+ )
+
+ mock_query_obj = MagicMock()
+ mock_query_obj.to_dict.return_value = {}
+ mock_query_obj.applied_time_extras = {}
+
+ with (
+ patch.object(query_actions, "_get_datasource") as mock_get_ds,
+ patch.object(query_actions, "get_time_filter_status",
return_value=([], [])),
+ ):
+ datasource = MagicMock()
+ datasource.query_language = "sql"
+ datasource.get_query_str_extended.return_value = extended
+ mock_get_ds.return_value = datasource
+
+ result = _get_query(MagicMock(), mock_query_obj, False)
+
+ assert result["query"] == "SELECT country FROM sales;"
+ assert result["applied_filters"] == [{"column": "country"}]
+ assert [entry["column"] for entry in result["rejected_filters"]] == [
+ "does_not_exist"
+ ]
+ assert result["rejected_filter_columns"] == ["does_not_exist"]
+
+
+def test_materialized_payload_preserves_datasource_rejection_origin() -> None:
+ """Data results retain provenance alongside combined temporal status."""
+ query_context = MagicMock()
+ query_context.result_type = ChartDataResultType.FULL
+ query_obj = MagicMock()
+ query_obj.result_type = ChartDataResultType.FULL
+ query_obj.applied_time_extras = {}
+ payload = {
+ "df": None,
+ "status": QueryStatus.FAILED,
+ "applied_filter_columns": [],
+ "rejected_filter_columns": ["__time_col"],
+ }
+
+ with (
+ patch.object(query_actions, "_get_datasource",
return_value=MagicMock()),
+ patch.object(
+ query_actions,
+ "get_time_filter_status",
+ return_value=(
+ [],
+ [{"reason": "not_in_datasource", "column": "__time_col"}],
+ ),
+ ),
+ ):
+ result = query_actions._materialize_full_payload(
+ query_context, query_obj, payload
+ )
+
+ assert result["rejected_filter_columns"] == ["__time_col"]
+ assert len(result["rejected_filters"]) == 2
+
+
+def test_get_query_falls_back_when_datasource_has_no_extended_form() -> None:
+ """Datasources without get_query_str_extended keep the plain string
path."""
+ from superset.common.query_actions import _get_query
+
+ mock_query_obj = MagicMock()
+ mock_query_obj.to_dict.return_value = {}
+
+ with patch.object(query_actions, "_get_datasource") as mock_get_ds:
+ datasource = MagicMock(spec=["query_language", "get_query_str"])
+ datasource.query_language = "sql"
+ datasource.get_query_str.return_value = "SELECT 1;"
+ mock_get_ds.return_value = datasource
+
+ result = _get_query(MagicMock(), mock_query_obj, False)
+
+ assert result["query"] == "SELECT 1;"
+ assert "rejected_filters" not in result
diff --git a/tests/unit_tests/mcp_service/chart/tool/test_get_chart_data.py
b/tests/unit_tests/mcp_service/chart/tool/test_get_chart_data.py
index 789d53409f5..4bb74aaff0d 100644
--- a/tests/unit_tests/mcp_service/chart/tool/test_get_chart_data.py
+++ b/tests/unit_tests/mcp_service/chart/tool/test_get_chart_data.py
@@ -27,6 +27,10 @@ from unittest.mock import MagicMock
import pytest
+from superset.mcp_service.chart.chart_helpers import (
+ rejected_requested_filter_columns,
+ requested_filter_columns,
+)
from superset.mcp_service.chart.schemas import (
ChartData,
ChartError,
@@ -41,15 +45,13 @@ from superset.mcp_service.chart.tool.get_chart_data import (
_MAX_RECOMMENDATIONS,
_query_from_form_data,
_recommend_visualizations,
- _rejected_requested_filter_columns,
- _requested_filter_columns,
)
from superset.utils import json
from superset.utils.core import ExtraFiltersReasonType, GenericDataType
def test_requested_filter_columns_supports_both_payload_shapes() -> None:
- assert _requested_filter_columns(
+ assert requested_filter_columns(
{
"filters": [{"col": "country", "op": "==", "val": "USA"}],
"adhoc_filters": [
@@ -65,6 +67,10 @@ def
test_requested_filter_columns_supports_both_payload_shapes() -> None:
) == {"country", "city"}
+def test_requested_filter_columns_accepts_null_lists() -> None:
+ assert requested_filter_columns({"filters": None, "adhoc_filters": None})
== set()
+
+
def test_rejected_requested_filter_columns_ignores_saved_chart_filters() ->
None:
result = {
"queries": [
@@ -72,7 +78,7 @@ def
test_rejected_requested_filter_columns_ignores_saved_chart_filters() -> None
]
}
- assert _rejected_requested_filter_columns(
+ assert rejected_requested_filter_columns(
result,
{
"adhoc_filters": [
@@ -109,7 +115,7 @@ def
test_rejected_requested_filter_columns_reads_materialized_payload() -> None:
]
}
- assert _rejected_requested_filter_columns(
+ assert rejected_requested_filter_columns(
result,
{"filters": [{"col": "does_not_exist", "op": "==", "val": "x"}]},
) == ["does_not_exist"]
@@ -129,7 +135,7 @@ def
test_rejected_requested_filter_columns_ignores_rejected_time_filters() -> No
}
assert (
- _rejected_requested_filter_columns(
+ rejected_requested_filter_columns(
result,
{"filters": [{"col": "country", "op": "==", "val": "USA"}]},
)
@@ -137,6 +143,27 @@ def
test_rejected_requested_filter_columns_ignores_rejected_time_filters() -> No
)
+def test_rejected_requested_filter_columns_prefers_datasource_rejections() ->
None:
+ result = {
+ "queries": [
+ {
+ "rejected_filter_columns": [],
+ "rejected_filters": [
+ {"reason": "not_in_datasource", "column": "__time_col"}
+ ],
+ }
+ ]
+ }
+
+ assert (
+ rejected_requested_filter_columns(
+ result,
+ {"filters": [{"col": "__time_col", "op": "==", "val": "value"}]},
+ )
+ == []
+ )
+
+
def _collect_groupby_extras(
form_data: dict[str, Any],
groupby_columns: list[str],
diff --git a/tests/unit_tests/mcp_service/chart/tool/test_get_chart_sql.py
b/tests/unit_tests/mcp_service/chart/tool/test_get_chart_sql.py
index ed7b8fd7ccd..50bcfcf9272 100644
--- a/tests/unit_tests/mcp_service/chart/tool/test_get_chart_sql.py
+++ b/tests/unit_tests/mcp_service/chart/tool/test_get_chart_sql.py
@@ -1552,3 +1552,105 @@ class TestGetChartSqlTool:
)
assert result is None
+
+
+class TestRejectedFilterColumnsAreSurfaced:
+ """A filter naming a column the dataset does not have is dropped during
+ query construction. get_chart_sql must not return the resulting unfiltered
+ SQL as a success, which would misrepresent it as the SQL for the requested
+ filters."""
+
+ BAD_FILTER = {
+ "clause": "WHERE",
+ "expressionType": "SIMPLE",
+ "subject": "does_not_exist",
+ "operator": "==",
+ "comparator": "value",
+ }
+
+ def _result(self, rejected_columns):
+ return {
+ "queries": [
+ {
+ "query": "SELECT country, count(*) FROM sales GROUP BY
country",
+ "language": "sql",
+ "rejected_filters": [
+ {
+ "reason": "COL_NOT_IN_DATASOURCE",
+ "column": column,
+ }
+ for column in rejected_columns
+ ],
+ }
+ ]
+ }
+
+ def test_rejected_request_filter_returns_validation_error(self):
+ from superset.mcp_service.chart.tool.get_chart_sql import (
+ _extract_sql_from_result,
+ )
+
+ result = _extract_sql_from_result(
+ self._result(["does_not_exist"]),
+ chart_id=1,
+ chart_name="Sales",
+ datasource_name="sales",
+ extra_form_data={"adhoc_filters": [self.BAD_FILTER]},
+ )
+
+ assert isinstance(result, ChartError)
+ assert result.error_type == "ValidationError"
+ assert "does_not_exist" in result.error
+
+ def test_rejected_filter_not_requested_by_caller_is_ignored(self):
+ """A stale filter saved on the chart must not fail the request."""
+ from superset.mcp_service.chart.tool.get_chart_sql import (
+ _extract_sql_from_result,
+ )
+
+ result = _extract_sql_from_result(
+ self._result(["stale_saved_filter"]),
+ chart_id=1,
+ chart_name="Sales",
+ datasource_name="sales",
+ extra_form_data={"filters": [{"col": "country", "op": "==", "val":
"US"}]},
+ )
+
+ assert isinstance(result, ChartSql)
+ assert result.sql.startswith("SELECT country")
+
+ def test_temporal_rejection_does_not_match_same_named_request_filter(self):
+ """Temporal pseudo-filters and datasource columns have separate
origins."""
+ from superset.mcp_service.chart.tool.get_chart_sql import (
+ _extract_sql_from_result,
+ )
+
+ result = self._result(["__time_col"])
+ result["queries"][0]["rejected_filter_columns"] = []
+
+ extracted = _extract_sql_from_result(
+ result,
+ chart_id=1,
+ chart_name="Sales",
+ datasource_name="sales",
+ extra_form_data={
+ "filters": [{"col": "__time_col", "op": "==", "val": "value"}]
+ },
+ )
+
+ assert isinstance(extracted, ChartSql)
+
+ def test_no_rejections_returns_sql(self):
+ from superset.mcp_service.chart.tool.get_chart_sql import (
+ _extract_sql_from_result,
+ )
+
+ result = _extract_sql_from_result(
+ self._result([]),
+ chart_id=1,
+ chart_name="Sales",
+ datasource_name="sales",
+ extra_form_data={"adhoc_filters": [self.BAD_FILTER]},
+ )
+
+ assert isinstance(result, ChartSql)