feat(mcp): allow default values on filter_select native filters (#44985)

This commit is contained in:
Amin Ghadersohi
2026-10-07 07:48:25 +11:00
committed by GitHub
parent 1473d48134
commit 17f4c215b3
6 changed files with 995 additions and 98 deletions

View File

@@ -110,6 +110,28 @@ retains its call's slot rather than exhausting cancellation capacity for new
calls. One warning with the call ID records abandonment. A timeout does not guarantee that warehouse writes were
rolled back, so do not blindly retry mutating SQL.
## Native filter defaults
`manage_native_filters` accepts `default_value` for select filters. An empty
list clears the selection. With `enable_empty_filter: true`, that explicit
empty default matches no rows in both the dashboard UI and server-side
dashboard chart queries, including for UI-created non-exact select filters.
Creating a required select filter without `default_value` also stores this
match-nothing default, unless `default_to_first_item` is enabled.
Inverse-selection filters retain the UI's no-predicate behavior for empty
selections. Disabling `enable_empty_filter` removes the empty default's
match-nothing predicate without requiring another `default_value` update.
Explicit defaults, including an empty list, cannot be combined with
`default_to_first_item: true`. Creating a first-item filter or enabling that
option on an existing filter leaves the stored selection unset so the dashboard
can choose the first available item after loading options. Toggling
`enable_empty_filter` on a first-item filter preserves that behavior rather than
storing an explicit empty selection or match-nothing predicate. Any update to
a first-item filter also clears a defined legacy stored selection (including
`null`) when first-item mode remains enabled, allowing automatic selection.
Select filters with legacy string-column
targets support both default updates and applied selections.
## Bounding dashboard dataset metadata
`get_dashboard_datasets` lists a dashboard's datasets and semantic views with

View File

@@ -2289,6 +2289,10 @@ class DeleteDashboardResponse(BaseModel):
# manage_native_filters schemas
# ---------------------------------------------------------------------------
# The JSON scalars a filter_select selection can hold. Mirrors the value array
# the frontend stores in a native filter's ``filterState.value``.
FilterSelectValue = bool | int | float | str | None
def _reject_bool_dataset_id(value: object) -> object:
"""bool is a subclass of int, so dataset_id=true would coerce to dataset ID 1
@@ -2347,6 +2351,23 @@ class FilterSelectSpec(BaseNewFilterSpec):
search_all_options: bool = Field(
False, description="Query the database on search rather than client-side"
)
default_value: List[FilterSelectValue] | None = Field(
None,
description=(
"Default selected value(s), shown when a viewer opens the "
"dashboard unchanged. Omit for no default. Mutually exclusive "
"with default_to_first_item."
),
)
@model_validator(mode="after")
def _validate_default_value_compat(self) -> "FilterSelectSpec":
if self.default_to_first_item and self.default_value is not None:
raise ValueError(
"default_to_first_item and default_value are mutually "
"exclusive; set at most one."
)
return self
class FilterTimeSpec(BaseNewFilterSpec):
@@ -2451,6 +2472,14 @@ class NativeFilterUpdateSpec(BaseModel):
search_all_options: bool | None = Field(
None, description="Search all options in the database (filter_select only)"
)
default_value: List[FilterSelectValue] | None = Field(
None,
description=(
"New default value(s) (filter_select only). Empty list clears "
"it. Mutually exclusive with default_to_first_item=True; if "
"already enabled, also pass default_to_first_item=False here."
),
)
default_time_range: str | None = Field(
None, description="Default time range (filter_time only)"
)
@@ -2467,6 +2496,15 @@ class NativeFilterUpdateSpec(BaseModel):
def _validate_default_time_range(cls, v: str | None) -> str | None:
return validate_time_range(v)
@model_validator(mode="after")
def _validate_default_value_compat(self) -> "NativeFilterUpdateSpec":
if self.default_to_first_item and self.default_value is not None:
raise ValueError(
"default_to_first_item and default_value are mutually "
"exclusive; set at most one in the same update."
)
return self
@field_validator("dataset_id", mode="before")
@classmethod
def reject_bool_dataset_id(cls, value: object) -> object:
@@ -2561,10 +2599,6 @@ class ManageNativeFiltersResponse(BaseModel):
# apply_dashboard_filters schemas
# ---------------------------------------------------------------------------
# The JSON scalars a filter_select selection can hold. Mirrors the value array
# the frontend stores in a native filter's ``filterState.value``.
FilterSelectValue = bool | int | float | str | None
def _reject_bool_range_bound(value: object) -> object:
"""Reject boolean bounds before they can be coerced to integers."""

View File

@@ -33,7 +33,7 @@ from fastmcp import Context
from flask import current_app
from superset_core.mcp.decorators import tool, ToolAnnotations
from superset.constants import EMPTY_FILTER_SQL_EXPRESSION, NO_TIME_RANGE
from superset.constants import NO_TIME_RANGE
from superset.extensions import event_logger
from superset.mcp_service.dashboard.permalink import (
build_dashboard_permalink_url,
@@ -45,9 +45,10 @@ from superset.mcp_service.dashboard.schemas import (
ApplyDashboardFiltersRequest,
ApplyDashboardFiltersResponse,
ApplyFilterValueSpec,
FilterSelectValue,
)
from superset.mcp_service.dashboard.tool.manage_native_filters import (
_FilterValidationError as _FilterApplyError,
_select_data_mask,
current_native_filter_config,
)
from superset.utils.number_format import format_smart_number
@@ -68,16 +69,6 @@ _SINGLE_VALUE_MINIMUM = 0
_SINGLE_VALUE_EXACT = 1
_SINGLE_VALUE_MAXIMUM = 2
# Display strings the frontend uses when labelling a selected value; mirrored
# here so a permalink's label reads the same as a UI-applied one.
_NULL_LABEL = "<NULL>"
_TRUE_LABEL = "TRUE"
_FALSE_LABEL = "FALSE"
class _FilterApplyError(Exception):
"""Raised internally when a requested filter value cannot be applied."""
def _publish_filters_applied(dashboard_id: int, permalink_key: str) -> bool:
"""Best-effort principal nudge; filter state stays in the authorized permalink."""
@@ -154,15 +145,6 @@ def _resolve_filter(reference: str, configs: list[dict[str, Any]]) -> dict[str,
)
def _value_label(value: FilterSelectValue) -> str:
"""Format one selected value the way the dashboard UI labels it."""
if value is None:
return _NULL_LABEL
if isinstance(value, bool):
return _TRUE_LABEL if value else _FALSE_LABEL
return str(value)
def _filter_target_column(conf: dict[str, Any]) -> str:
"""Return the first target's column name, or raise when there is none."""
targets = [target for target in (conf.get("targets") or []) if target]
@@ -175,67 +157,6 @@ def _filter_target_column(conf: dict[str, Any]) -> str:
return column
def _select_data_mask(
conf: dict[str, Any], values: list[FilterSelectValue]
) -> dict[str, Any]:
"""Build the data mask a filter_select filter produces for ``values``.
Mirrors the frontend's ``getSelectExtraFormData``: a non-empty selection
becomes an ``IN`` predicate on the filter's target column, and an empty
selection on a filter marked ``enableEmptyFilter`` becomes an impossible
predicate (the "required filter, nothing chosen" state) rather than no
filtering at all.
"""
column = _filter_target_column(conf)
control_values = conf.get("controlValues") or {}
if control_values.get("inverseSelection"):
raise _FilterApplyError(
f"Filter '{conf.get('name') or conf.get('id')}' enables inverse "
"selection, which this tool does not support."
)
if (operator := control_values.get("operatorType", "exact")) != "exact":
raise _FilterApplyError(
f"Filter '{conf.get('name') or conf.get('id')}' uses matching "
f"operator '{operator}', which this tool does not support. "
"Only exact-match select filters are supported."
)
# A single-select filter renders one value; storing several would disagree
# with the control the moment a viewer touches it. multiSelect defaults to
# true, so only an explicit false restricts the selection.
if control_values.get("multiSelect") is False and len(values) > 1:
raise _FilterApplyError(
f"Filter '{conf.get('name') or conf.get('id')}' is single-select "
f"and accepts at most one value, but {len(values)} were given."
)
if values:
extra_form_data: dict[str, Any] = {
"filters": [{"col": column, "op": "IN", "val": list(values)}]
}
filter_state: dict[str, Any] = {
"value": list(values),
"label": ", ".join(_value_label(value) for value in values),
}
else:
extra_form_data = (
{
"adhoc_filters": [
{
"expressionType": "SQL",
"clause": "WHERE",
"sqlExpression": EMPTY_FILTER_SQL_EXPRESSION,
}
]
}
if control_values.get("enableEmptyFilter")
else {}
)
filter_state = {"value": None}
return {"extraFormData": extra_form_data, "filterState": filter_state}
def _time_data_mask(conf: dict[str, Any], time_range: str) -> dict[str, Any]:
"""Build the data mask a filter_time filter produces for ``time_range``."""
is_set = bool(time_range) and time_range != NO_TIME_RANGE

View File

@@ -30,11 +30,13 @@ from typing import Any
from fastmcp import Context
from superset_core.mcp.decorators import tool, ToolAnnotations
from superset.constants import EMPTY_FILTER_SQL_EXPRESSION, NULL_STRING
from superset.extensions import event_logger
from superset.mcp_service.dashboard.constants import generate_id
from superset.mcp_service.dashboard.schemas import (
FilterRangeSpec,
FilterSelectSpec,
FilterSelectValue,
FilterTimeGrainSpec,
FilterTimeSpec,
ManageNativeFiltersRequest,
@@ -66,6 +68,7 @@ _TYPE_SPECIFIC_UPDATE_FIELDS: dict[str, frozenset[str]] = {
"column",
"multi_select",
"default_to_first_item",
"default_value",
"enable_empty_filter",
"sort_ascending",
"search_all_options",
@@ -80,6 +83,12 @@ _ALL_TYPE_SPECIFIC_UPDATE_FIELDS: frozenset[str] = frozenset().union(
)
# Display strings the frontend uses when labelling a selected value; mirrored
# here so a stored default's label reads the same as an applied one.
_TRUE_LABEL = "TRUE"
_FALSE_LABEL = "FALSE"
class _FilterValidationError(Exception):
"""Raised internally when a filter operation fails validation."""
@@ -89,6 +98,111 @@ def _empty_data_mask() -> dict[str, Any]:
return {"filterState": {"value": None}, "extraFormData": {}}
def _value_label(value: FilterSelectValue) -> str:
"""Format one selected value the way the dashboard UI labels it."""
if value is None:
return NULL_STRING
if isinstance(value, bool):
return _TRUE_LABEL if value else _FALSE_LABEL
return str(value)
def _select_data_mask(
conf: dict[str, Any], values: list[FilterSelectValue]
) -> dict[str, Any]:
"""Build the data mask a filter_select filter produces for ``values``.
Mirrors the frontend's ``getSelectExtraFormData``: a non-empty selection
becomes an ``IN`` predicate on the filter's target column, and an empty
selection on a filter marked ``enableEmptyFilter`` becomes an impossible
predicate (the "required filter, nothing chosen" state) rather than no
filtering at all. Shared by ``apply_dashboard_filters`` (applied values)
and this module (default values on create/update) so both paths agree.
"""
targets = [target for target in (conf.get("targets") or []) if target]
column = (
_target_key(targets[0])[1] if targets and isinstance(targets[0], dict) else None
)
if not isinstance(column, str) or not column:
raise _FilterValidationError(
f"Filter '{conf.get('name') or conf.get('id')}' has no target "
"column, so a value cannot be applied to it."
)
control_values = conf.get("controlValues") or {}
if control_values.get("inverseSelection"):
raise _FilterValidationError(
f"Filter '{conf.get('name') or conf.get('id')}' enables inverse "
"selection, which this tool does not support."
)
if (operator := control_values.get("operatorType", "exact")) != "exact":
raise _FilterValidationError(
f"Filter '{conf.get('name') or conf.get('id')}' uses matching "
f"operator '{operator}', which this tool does not support. "
"Only exact-match select filters are supported."
)
# A single-select filter renders one value; storing several would disagree
# with the control the moment a viewer touches it. multiSelect defaults to
# true, so only an explicit false restricts the selection.
if control_values.get("multiSelect") is False and len(values) > 1:
raise _FilterValidationError(
f"Filter '{conf.get('name') or conf.get('id')}' is single-select "
f"and accepts at most one value, but {len(values)} were given."
)
if values:
extra_form_data: dict[str, Any] = {
"filters": [{"col": column, "op": "IN", "val": list(values)}]
}
filter_state: dict[str, Any] = {
"value": list(values),
"label": ", ".join(_value_label(value) for value in values),
}
else:
return _empty_select_data_mask(conf)
return {"extraFormData": extra_form_data, "filterState": filter_state}
def _empty_select_data_mask(conf: dict[str, Any]) -> dict[str, Any]:
"""Mirror the frontend's empty selection, including required-filter semantics."""
controls = conf.get("controlValues") or {}
if not controls.get("enableEmptyFilter") or controls.get("inverseSelection"):
return _empty_data_mask()
return {
"extraFormData": {
"adhoc_filters": [
{
"expressionType": "SQL",
"clause": "WHERE",
"sqlExpression": EMPTY_FILTER_SQL_EXPRESSION,
}
]
},
# An explicit empty selection is a static default to server-side
# dashboard context; None would cause its predicate to be skipped.
"filterState": {"value": []},
}
def _default_data_mask(
conf: dict[str, Any], values: list[FilterSelectValue]
) -> dict[str, Any]:
"""Build a stored select default, allowing empty unsupported UI selections.
First-item defaults leave the value unset so the UI can select it after
loading options. Explicit empty selections do not depend on the matching
operator. Required filters still contribute an impossible predicate unless
inverse selection is on, matching SelectFilterPlugin.updateDataMask.
"""
if (conf.get("controlValues") or {}).get("defaultToFirstItem"):
# A defined value, including None or [], blocks the UI's first-item default.
return {"filterState": {}, "extraFormData": {}}
if not values:
return _empty_select_data_mask(conf)
return _select_data_mask(conf, values)
def _time_data_mask(default_time_range: str | None) -> dict[str, Any]:
"""Build the default data mask for a time filter.
@@ -183,7 +297,7 @@ def _build_new_filter_config(
}
if spec.sort_ascending is not None:
control_values["sortAscending"] = spec.sort_ascending
return {
config: dict[str, Any] = {
**base,
"filterType": "filter_select",
"targets": [
@@ -193,6 +307,16 @@ def _build_new_filter_config(
"defaultDataMask": _empty_data_mask(),
}
if (
spec.default_value is not None
or spec.default_to_first_item
or spec.enable_empty_filter
):
config["defaultDataMask"] = _default_data_mask(
config, spec.default_value or []
)
return config
if isinstance(spec, FilterRangeSpec):
_validate_dataset_column(spec.dataset_id, spec.column, require_numeric=True)
return {
@@ -275,11 +399,7 @@ def _merge_target(spec: NativeFilterUpdateSpec, merged: dict[str, Any]) -> None:
merged["targets"] = [target]
return
column = (
spec.column
if spec.column is not None
else (target.get("column") or {}).get("name")
)
column = spec.column if spec.column is not None else _target_key(target)[1]
if dataset_id is None or not column:
raise _FilterValidationError(
f"Filter '{spec.id}' is missing a dataset or column target; "
@@ -293,6 +413,76 @@ def _merge_target(spec: NativeFilterUpdateSpec, merged: dict[str, Any]) -> None:
merged["targets"] = [target]
def _target_key(target: dict[str, Any]) -> tuple[Any, Any]:
"""Identify a filter target by its dataset and column name."""
column = target.get("column")
return target.get("datasetId"), (
column.get("name") if isinstance(column, dict) else column
)
def _stored_default_is_stale(
spec: NativeFilterUpdateSpec, existing: dict[str, Any], target_changed: bool
) -> bool:
"""Whether an update without ``default_value`` invalidates the stored one.
A stored default goes stale when the filter is retargeted to another
column or dataset, when it becomes single-select while the default holds
several values, or when ``default_to_first_item`` is switched on (the
explicit default would otherwise win and the first item never applies).
"""
if existing.get("filterType") != "filter_select":
return False
if spec.default_to_first_item is True:
return True
stored = ((existing.get("defaultDataMask") or {}).get("filterState") or {}).get(
"value"
)
if stored is None:
return False
stored_count = len(stored) if isinstance(stored, list) else 1
return target_changed or (spec.multi_select is False and stored_count > 1)
def _merge_select_default(
spec: NativeFilterUpdateSpec,
existing: dict[str, Any],
merged: dict[str, Any],
target_changed: bool,
) -> None:
"""Apply an explicit default_value, or drop a stored default gone stale."""
if spec.default_value is not None:
if (merged.get("controlValues") or {}).get("defaultToFirstItem"):
raise _FilterValidationError(
f"Filter '{spec.id}' has default_to_first_item enabled; "
"pass default_to_first_item=False in this same update "
"before setting an explicit default_value."
)
merged["defaultDataMask"] = _default_data_mask(merged, spec.default_value)
elif (
existing.get("filterType") == "filter_select"
and (merged.get("controlValues") or {}).get("defaultToFirstItem")
and "value"
in ((existing.get("defaultDataMask") or {}).get("filterState") or {})
):
# Heal defined legacy selections that block the UI's first-item default.
merged["defaultDataMask"] = _default_data_mask(merged, [])
elif (
existing.get("filterType") == "filter_select"
and spec.enable_empty_filter is not None
and not ((existing.get("defaultDataMask") or {}).get("filterState") or {}).get(
"value"
)
):
# Rebuild empty masks when the required control changes, including
# older masks that stored value=None alongside an impossible predicate.
merged["defaultDataMask"] = _default_data_mask(merged, [])
elif _stored_default_is_stale(spec, existing, target_changed):
# Reset rather than re-apply the old value against a different column,
# a single-select control, or a "first item" default.
merged["defaultDataMask"] = _default_data_mask(merged, [])
def _merge_filter_update(
spec: NativeFilterUpdateSpec,
existing: dict[str, Any],
@@ -312,8 +502,13 @@ def _merge_filter_update(
merged["description"] = spec.description
if spec.scope_chart_ids is not None:
merged["scope"] = _build_scope(spec.scope_chart_ids, dashboard_chart_ids)
target_changed = False
if spec.dataset_id is not None or spec.column is not None:
previous_target = dict((existing.get("targets") or [{}])[0] or {})
_merge_target(spec, merged)
target_changed = _target_key(merged["targets"][0]) != _target_key(
previous_target
)
control_values = dict(merged.get("controlValues") or {})
for field, control_key in _CONTROL_VALUE_FIELDS.items():
@@ -322,6 +517,8 @@ def _merge_filter_update(
control_values[control_key] = value
merged["controlValues"] = control_values
_merge_select_default(spec, existing, merged, target_changed)
if spec.default_time_range is not None:
merged["defaultDataMask"] = _time_data_mask(spec.default_time_range)

View File

@@ -37,6 +37,7 @@ Covers:
- Permission denied (DashboardForbiddenError)
"""
import copy
import logging
from collections.abc import Callable, Iterator
from typing import Any
@@ -225,8 +226,10 @@ async def _call(mcp_server: object, request: dict[str, Any]) -> dict[str, Any]:
@pytest.mark.asyncio
async def test_add_filter_select(mcp_server):
captured: dict = {"current_config": []}
@pytest.mark.parametrize("enable_empty_filter", [False, True])
async def test_add_filter_select(mcp_server: object, enable_empty_filter: bool) -> None:
"""First-item defaults must leave selection to the dashboard UI."""
captured: dict[str, Any] = {"current_config": []}
dashboard = _mock_dashboard(filters=[], chart_ids=[10, 11, 12])
with (
@@ -246,7 +249,7 @@ async def test_add_filter_select(mcp_server):
"column": "region",
"multi_select": False,
"default_to_first_item": True,
"enable_empty_filter": True,
"enable_empty_filter": enable_empty_filter,
"sort_ascending": False,
"search_all_options": True,
"scope_chart_ids": [10, 11],
@@ -276,16 +279,18 @@ async def test_add_filter_select(mcp_server):
"controlValues": {
"multiSelect": False,
"defaultToFirstItem": True,
"enableEmptyFilter": True,
"enableEmptyFilter": enable_empty_filter,
"searchAllOptions": True,
"sortAscending": False,
},
"defaultDataMask": {"filterState": {"value": None}, "extraFormData": {}},
"defaultDataMask": {"filterState": {}, "extraFormData": {}},
"cascadeParentIds": [],
}
assert data["filters"][0]["id"] == new_id
assert data["filters"][0]["filter_type"] == "filter_select"
assert "value" not in config["defaultDataMask"]["filterState"]
@pytest.mark.asyncio
async def test_add_filter_time(mcp_server):
@@ -1270,3 +1275,721 @@ async def test_add_filter_time_rejects_unparseable_default(mcp_server):
],
},
)
@pytest.mark.asyncio
async def test_add_filter_select_with_default_value(mcp_server):
"""default_value on create builds defaultDataMask via the same
builder apply_dashboard_filters uses for applied values (SC-121506)."""
captured: dict = {"current_config": []}
dashboard = _mock_dashboard(filters=[])
with (
patch(DAO_FIND_BY_ID, return_value=dashboard),
patch(DATASET_FIND_BY_ID, return_value=_mock_dataset()),
patch(COMMAND_PATH, side_effect=_mock_command(captured)),
):
data = await _call(
mcp_server,
{
"dashboard_id": 1,
"add": [
{
"filter_type": "filter_select",
"name": "Region",
"dataset_id": 5,
"column": "region",
"default_value": ["EMEA"],
}
],
},
)
assert data["error"] is None
config = captured["payload"]["modified"][0]
assert config["defaultDataMask"] == {
"extraFormData": {"filters": [{"col": "region", "op": "IN", "val": ["EMEA"]}]},
"filterState": {"value": ["EMEA"], "label": "EMEA"},
}
@pytest.mark.asyncio
async def test_add_filter_select_default_value_empty_list(mcp_server):
"""An explicit empty default_value is a no-selection default, distinct
from omitting the field entirely but producing the same empty mask when
enable_empty_filter is not set."""
captured: dict = {"current_config": []}
dashboard = _mock_dashboard(filters=[])
with (
patch(DAO_FIND_BY_ID, return_value=dashboard),
patch(DATASET_FIND_BY_ID, return_value=_mock_dataset()),
patch(COMMAND_PATH, side_effect=_mock_command(captured)),
):
data = await _call(
mcp_server,
{
"dashboard_id": 1,
"add": [
{
"filter_type": "filter_select",
"name": "Region",
"dataset_id": 5,
"column": "region",
"default_value": [],
}
],
},
)
assert data["error"] is None
config = captured["payload"]["modified"][0]
assert config["defaultDataMask"] == {
"extraFormData": {},
"filterState": {"value": None},
}
@pytest.mark.asyncio
async def test_add_filter_select_default_value_single_select_overflow_rejected(
mcp_server,
):
"""multi_select=False with multiple default values is rejected the same
way apply_dashboard_filters rejects multiple applied values."""
dashboard = _mock_dashboard(filters=[])
with (
patch(DAO_FIND_BY_ID, return_value=dashboard),
patch(DATASET_FIND_BY_ID, return_value=_mock_dataset()),
):
data = await _call(
mcp_server,
{
"dashboard_id": 1,
"add": [
{
"filter_type": "filter_select",
"name": "Region",
"dataset_id": 5,
"column": "region",
"multi_select": False,
"default_value": ["EMEA", "APAC"],
}
],
},
)
assert "single-select" in data["error"]
assert "2 were given" in data["error"]
@pytest.mark.parametrize("values", [[], ["EMEA"]])
@pytest.mark.asyncio
async def test_add_filter_select_default_to_first_item_and_default_value_rejected(
mcp_server: object,
values: list[str],
) -> None:
"""Request-schema validation happens at the MCP tool-call boundary,
before the tool body runs, so the client raises ToolError rather than
returning a JSON error body (mirrors
test_add_filter_time_rejects_unparseable_default)."""
from fastmcp.exceptions import ToolError
dashboard = _mock_dashboard(filters=[])
with patch(DAO_FIND_BY_ID, return_value=dashboard):
with pytest.raises(ToolError, match="mutually exclusive"):
await _call(
mcp_server,
{
"dashboard_id": 1,
"add": [
{
"filter_type": "filter_select",
"name": "Region",
"dataset_id": 5,
"column": "region",
"default_to_first_item": True,
"default_value": values,
}
],
},
)
@pytest.mark.asyncio
async def test_update_sets_default_value(mcp_server):
"""default_value on update builds defaultDataMask via the same builder
used on create and by apply_dashboard_filters (SC-121506)."""
captured: dict = {"current_config": [EXISTING_SELECT_FILTER]}
dashboard = _mock_dashboard(filters=[EXISTING_SELECT_FILTER])
with (
patch(DAO_FIND_BY_ID, return_value=dashboard),
patch(COMMAND_PATH, side_effect=_mock_command(captured)),
):
data = await _call(
mcp_server,
{
"dashboard_id": 1,
"update": [
{
"id": "NATIVE_FILTER-existing1",
"default_value": ["APAC"],
}
],
},
)
assert data["error"] is None
config = captured["payload"]["modified"][0]
assert config["defaultDataMask"] == {
"extraFormData": {"filters": [{"col": "region", "op": "IN", "val": ["APAC"]}]},
"filterState": {"value": ["APAC"], "label": "APAC"},
}
@pytest.mark.asyncio
async def test_update_default_value_empty_list_clears_default(mcp_server):
existing = copy.deepcopy(EXISTING_SELECT_FILTER)
existing["defaultDataMask"] = {
"extraFormData": {"filters": [{"col": "region", "op": "IN", "val": ["EMEA"]}]},
"filterState": {"value": ["EMEA"], "label": "EMEA"},
}
captured: dict = {"current_config": [existing]}
dashboard = _mock_dashboard(filters=[existing])
with (
patch(DAO_FIND_BY_ID, return_value=dashboard),
patch(COMMAND_PATH, side_effect=_mock_command(captured)),
):
data = await _call(
mcp_server,
{
"dashboard_id": 1,
"update": [{"id": "NATIVE_FILTER-existing1", "default_value": []}],
},
)
assert data["error"] is None
config = captured["payload"]["modified"][0]
assert config["defaultDataMask"] == {
"extraFormData": {},
"filterState": {"value": None},
}
_EMEA_DEFAULT_MASK = {
"extraFormData": {"filters": [{"col": "region", "op": "IN", "val": ["EMEA"]}]},
"filterState": {"value": ["EMEA"], "label": "EMEA"},
}
_CLEARED_MASK = {"extraFormData": {}, "filterState": {"value": None}}
async def _update_existing(
mcp_server: object, existing: dict[str, Any], changes: dict[str, Any]
) -> dict[str, Any]:
"""Run one update against ``existing`` and return the saved filter config."""
captured: dict[str, Any] = {"current_config": [existing]}
dashboard = _mock_dashboard(filters=[existing])
with (
patch(DAO_FIND_BY_ID, return_value=dashboard),
patch(DATASET_FIND_BY_ID, return_value=_mock_dataset()),
patch(COMMAND_PATH, side_effect=_mock_command(captured)),
):
data = await _call(
mcp_server,
{
"dashboard_id": 1,
"update": [{"id": "NATIVE_FILTER-existing1", **changes}],
},
)
assert data["error"] is None
return captured["payload"]["modified"][0]
@pytest.mark.asyncio
async def test_update_column_change_clears_stored_default(mcp_server):
"""Retargeting a filter must not re-apply the old column's default."""
existing = copy.deepcopy(EXISTING_SELECT_FILTER)
existing["defaultDataMask"] = copy.deepcopy(_EMEA_DEFAULT_MASK)
config = await _update_existing(mcp_server, existing, {"column": "country"})
assert config["targets"][0]["column"] == {"name": "country"}
assert config["defaultDataMask"] == _CLEARED_MASK
@pytest.mark.asyncio
async def test_update_same_column_keeps_stored_default(mcp_server):
existing = copy.deepcopy(EXISTING_SELECT_FILTER)
existing["defaultDataMask"] = copy.deepcopy(_EMEA_DEFAULT_MASK)
config = await _update_existing(
mcp_server, existing, {"column": "region", "name": "Renamed"}
)
assert config["defaultDataMask"] == _EMEA_DEFAULT_MASK
@pytest.mark.asyncio
async def test_update_column_change_with_new_default_sets_it(mcp_server):
existing = copy.deepcopy(EXISTING_SELECT_FILTER)
existing["defaultDataMask"] = copy.deepcopy(_EMEA_DEFAULT_MASK)
config = await _update_existing(
mcp_server, existing, {"column": "country", "default_value": ["FR"]}
)
assert config["defaultDataMask"]["filterState"]["value"] == ["FR"]
assert config["defaultDataMask"]["extraFormData"]["filters"][0]["col"] == "country"
@pytest.mark.asyncio
async def test_update_single_select_clears_multi_value_default(mcp_server):
existing = copy.deepcopy(EXISTING_SELECT_FILTER)
existing["defaultDataMask"] = {
"extraFormData": {
"filters": [{"col": "region", "op": "IN", "val": ["EMEA", "APAC"]}]
},
"filterState": {"value": ["EMEA", "APAC"], "label": "EMEA, APAC"},
}
config = await _update_existing(mcp_server, existing, {"multi_select": False})
assert config["controlValues"]["multiSelect"] is False
assert config["defaultDataMask"] == _CLEARED_MASK
@pytest.mark.asyncio
async def test_update_single_select_keeps_single_value_default(mcp_server):
existing = copy.deepcopy(EXISTING_SELECT_FILTER)
existing["defaultDataMask"] = copy.deepcopy(_EMEA_DEFAULT_MASK)
config = await _update_existing(mcp_server, existing, {"multi_select": False})
assert config["defaultDataMask"] == _EMEA_DEFAULT_MASK
@pytest.mark.asyncio
@pytest.mark.parametrize("enable_empty_filter", [False, True])
@pytest.mark.parametrize(
"default_mask",
[
_EMEA_DEFAULT_MASK,
_CLEARED_MASK,
{"extraFormData": {}, "filterState": {"value": []}},
{"extraFormData": {}, "filterState": {}},
],
)
async def test_update_default_to_first_item_clears_explicit_default(
mcp_server: object, enable_empty_filter: bool, default_mask: dict[str, Any]
) -> None:
"""Enabling first-item selection must remove any defined selection value."""
existing: dict[str, Any] = copy.deepcopy(EXISTING_SELECT_FILTER)
existing["controlValues"]["enableEmptyFilter"] = enable_empty_filter
existing["defaultDataMask"] = copy.deepcopy(default_mask)
config = await _update_existing(
mcp_server, existing, {"default_to_first_item": True}
)
assert config["controlValues"]["defaultToFirstItem"] is True
assert "value" not in config["defaultDataMask"]["filterState"]
assert config["defaultDataMask"] == {"extraFormData": {}, "filterState": {}}
@pytest.mark.asyncio
@pytest.mark.parametrize("enable_empty_filter", [False, True])
@pytest.mark.parametrize("filter_state", [{}, {"value": None}, {"value": []}])
async def test_update_first_item_required_toggle_preserves_unset_selection(
mcp_server: object, enable_empty_filter: bool, filter_state: dict[str, Any]
) -> None:
"""Required-control changes must not block automatic first-item selection."""
existing: dict[str, Any] = copy.deepcopy(EXISTING_SELECT_FILTER)
existing["controlValues"].update(
defaultToFirstItem=True, enableEmptyFilter=not enable_empty_filter
)
existing["defaultDataMask"] = {
"extraFormData": {},
"filterState": filter_state,
}
config = await _update_existing(
mcp_server, existing, {"enable_empty_filter": enable_empty_filter}
)
assert config["controlValues"]["enableEmptyFilter"] is enable_empty_filter
assert "value" not in config["defaultDataMask"]["filterState"]
assert config["defaultDataMask"] == {"extraFormData": {}, "filterState": {}}
@pytest.mark.asyncio
@pytest.mark.parametrize("enable_empty_filter", [False, True])
@pytest.mark.parametrize("filter_state", [{"value": None}, {"value": []}])
@pytest.mark.parametrize(
"changes",
[
{"name": "Renamed region"},
{"description": "Updated description"},
{"scope_chart_ids": [10]},
{"sort_ascending": True},
{"search_all_options": True},
],
)
async def test_update_first_item_heals_legacy_selection(
mcp_server: object,
enable_empty_filter: bool,
filter_state: dict[str, Any],
changes: dict[str, Any],
) -> None:
"""Any update heals defined legacy selections that block first-item defaults."""
existing: dict[str, Any] = copy.deepcopy(EXISTING_SELECT_FILTER)
existing["controlValues"].update(
defaultToFirstItem=True, enableEmptyFilter=enable_empty_filter
)
existing["defaultDataMask"] = {
"extraFormData": {},
"filterState": copy.deepcopy(filter_state),
}
original = copy.deepcopy(existing)
config = await _update_existing(mcp_server, existing, changes)
assert config["defaultDataMask"] == {"extraFormData": {}, "filterState": {}}
assert existing == original
@pytest.mark.asyncio
@pytest.mark.parametrize("enable_empty_filter", [False, True])
@pytest.mark.parametrize("changes", [{"column": "country"}, {"multi_select": False}])
async def test_update_first_item_stale_default_restores_unset_selection(
mcp_server: object, enable_empty_filter: bool, changes: dict[str, Any]
) -> None:
"""Every stale-default reset must defer to automatic first-item selection."""
existing: dict[str, Any] = copy.deepcopy(EXISTING_SELECT_FILTER)
existing["controlValues"].update(
defaultToFirstItem=True, enableEmptyFilter=enable_empty_filter
)
existing["defaultDataMask"] = {
"extraFormData": {
"filters": [{"col": "region", "op": "IN", "val": ["EMEA", "APAC"]}]
},
"filterState": {"value": ["EMEA", "APAC"], "label": "EMEA, APAC"},
}
config = await _update_existing(mcp_server, existing, changes)
assert "value" not in config["defaultDataMask"]["filterState"]
assert config["defaultDataMask"] == {"extraFormData": {}, "filterState": {}}
@pytest.mark.asyncio
@pytest.mark.parametrize(
"control_values",
[{"inverseSelection": True}, {"operatorType": "contains"}],
)
async def test_update_empty_default_clears_on_unsupported_ui_filter(
mcp_server, control_values
):
"""Clearing applies no predicate, so inverse-selection / non-exact filters
created in the UI can still have their default cleared."""
existing = copy.deepcopy(EXISTING_SELECT_FILTER)
existing["controlValues"].update(control_values)
existing["defaultDataMask"] = copy.deepcopy(_EMEA_DEFAULT_MASK)
config = await _update_existing(mcp_server, existing, {"default_value": []})
assert config["defaultDataMask"] == _CLEARED_MASK
@pytest.mark.asyncio
async def test_update_non_empty_default_still_rejected_on_inverse_filter(mcp_server):
existing = copy.deepcopy(EXISTING_SELECT_FILTER)
existing["controlValues"]["inverseSelection"] = True
dashboard = _mock_dashboard(filters=[existing])
with patch(DAO_FIND_BY_ID, return_value=dashboard):
data = await _call(
mcp_server,
{
"dashboard_id": 1,
"update": [
{"id": "NATIVE_FILTER-existing1", "default_value": ["EMEA"]}
],
},
)
assert "inverse" in data["error"]
@pytest.mark.asyncio
async def test_update_default_value_on_time_filter_rejected(mcp_server):
dashboard = _mock_dashboard(filters=[EXISTING_TIME_FILTER])
with patch(DAO_FIND_BY_ID, return_value=dashboard):
data = await _call(
mcp_server,
{
"dashboard_id": 1,
"update": [
{
"id": "NATIVE_FILTER-existing2",
"default_value": ["EMEA"],
}
],
},
)
assert "default_value" in data["error"]
assert "filter_select filters" in data["error"]
@pytest.mark.asyncio
async def test_update_default_value_with_existing_default_to_first_item_rejected(
mcp_server,
):
"""An update that sets default_value must also explicitly clear a
pre-existing default_to_first_item; the two cannot both be effectively
enabled after the merge (SC-121506)."""
existing = copy.deepcopy(EXISTING_SELECT_FILTER)
existing["controlValues"]["defaultToFirstItem"] = True
dashboard = _mock_dashboard(filters=[existing])
with patch(DAO_FIND_BY_ID, return_value=dashboard):
data = await _call(
mcp_server,
{
"dashboard_id": 1,
"update": [
{
"id": "NATIVE_FILTER-existing1",
"default_value": ["EMEA"],
}
],
},
)
assert "default_to_first_item" in data["error"]
@pytest.mark.parametrize("values", [[], ["EMEA"]])
@pytest.mark.asyncio
async def test_update_default_to_first_item_and_default_value_rejected(
mcp_server: object,
values: list[str],
) -> None:
"""Request-schema validation happens at the MCP tool-call boundary,
before the tool body runs, so the client raises ToolError rather than
returning a JSON error body."""
from fastmcp.exceptions import ToolError
dashboard = _mock_dashboard(filters=[EXISTING_SELECT_FILTER])
with patch(DAO_FIND_BY_ID, return_value=dashboard):
with pytest.raises(ToolError, match="mutually exclusive"):
await _call(
mcp_server,
{
"dashboard_id": 1,
"update": [
{
"id": "NATIVE_FILTER-existing1",
"default_to_first_item": True,
"default_value": values,
}
],
},
)
@pytest.mark.parametrize("default_value", [None, []])
def test_required_empty_default_applies_in_server_dashboard_context(
default_value: list[object] | None,
) -> None:
"""Required empty defaults must reach queries with or without an explicit value."""
from superset.charts.data.dashboard_filter_context import (
_extract_filter_extra_form_data,
DashboardFilterStatus,
)
from superset.mcp_service.dashboard.schemas import FilterSelectSpec
from superset.mcp_service.dashboard.tool.manage_native_filters import (
_build_new_filter_config,
)
with patch(DATASET_FIND_BY_ID, return_value=_mock_dataset()):
config = _build_new_filter_config(
FilterSelectSpec(
filter_type="filter_select",
name="Region",
dataset_id=5,
column="region",
default_value=default_value,
enable_empty_filter=True,
),
[10, 11],
)
extra, status = _extract_filter_extra_form_data(config)
assert status == DashboardFilterStatus.APPLIED
assert extra == {
"adhoc_filters": [
{"expressionType": "SQL", "clause": "WHERE", "sqlExpression": "1 = 0"}
]
}
assert config["defaultDataMask"]["filterState"]["value"] == []
@pytest.mark.parametrize("inverse", [False, True])
def test_clear_required_non_exact_default(inverse: bool) -> None:
"""Empty non-exact defaults obey required and inverse-selection semantics."""
from superset.mcp_service.dashboard.schemas import NativeFilterUpdateSpec
from superset.mcp_service.dashboard.tool.manage_native_filters import (
_merge_filter_update,
)
existing: dict[str, Any] = copy.deepcopy(EXISTING_SELECT_FILTER)
existing["controlValues"].update(
enableEmptyFilter=True, operatorType="contains", inverseSelection=inverse
)
merged = _merge_filter_update(
NativeFilterUpdateSpec(id=existing["id"], default_value=[]), existing, [10, 11]
)
mask = merged["defaultDataMask"]
if inverse:
assert mask == {"filterState": {"value": None}, "extraFormData": {}}
else:
assert mask["filterState"]["value"] == []
assert mask["extraFormData"]["adhoc_filters"][0]["sqlExpression"] == "1 = 0"
@pytest.mark.parametrize("update", [{"column": "country"}, {"dataset_id": 6}])
def test_update_legacy_string_target(update: dict[str, Any]) -> None:
"""Legacy string columns can be retargeted and their stale defaults cleared."""
from superset.mcp_service.dashboard.schemas import NativeFilterUpdateSpec
from superset.mcp_service.dashboard.tool.manage_native_filters import (
_merge_filter_update,
)
existing: dict[str, Any] = copy.deepcopy(EXISTING_SELECT_FILTER)
existing["targets"][0]["column"] = "region"
existing["defaultDataMask"] = {
"filterState": {"value": ["EMEA"]},
"extraFormData": {"filters": [{"col": "region", "op": "IN", "val": ["EMEA"]}]},
}
with patch(DATASET_FIND_BY_ID, return_value=_mock_dataset()):
merged = _merge_filter_update(
NativeFilterUpdateSpec(id=existing["id"], **update), existing, [10, 11]
)
assert merged["targets"] == [
{
"datasetId": update.get("dataset_id", 5),
"column": {"name": update.get("column", "region")},
}
]
assert merged["defaultDataMask"] == {
"filterState": {"value": None},
"extraFormData": {},
}
assert existing["targets"][0]["column"] == "region"
@pytest.mark.parametrize("value", [None, []])
def test_disable_required_empty_default(value: list[object] | None) -> None:
"""Disabling the required control drops old and new empty-default predicates."""
from superset.mcp_service.dashboard.schemas import NativeFilterUpdateSpec
from superset.mcp_service.dashboard.tool.manage_native_filters import (
_merge_filter_update,
)
existing: dict[str, Any] = copy.deepcopy(EXISTING_SELECT_FILTER)
existing["controlValues"]["enableEmptyFilter"] = True
existing["defaultDataMask"] = {
"filterState": {"value": value},
"extraFormData": {
"adhoc_filters": [
{"expressionType": "SQL", "clause": "WHERE", "sqlExpression": "1 = 0"}
]
},
}
merged = _merge_filter_update(
NativeFilterUpdateSpec(id=existing["id"], enable_empty_filter=False),
existing,
[10, 11],
)
assert merged["defaultDataMask"] == {
"filterState": {"value": None},
"extraFormData": {},
}
def test_select_null_label_uses_shared_constant() -> None:
"""Stored NULL labels follow the shared display constant."""
from superset.mcp_service.dashboard.tool.manage_native_filters import (
_select_data_mask,
)
with patch(
"superset.mcp_service.dashboard.tool.manage_native_filters.NULL_STRING",
"<shared-null-label>",
create=True,
):
mask = _select_data_mask(EXISTING_SELECT_FILTER, [None])
assert mask["filterState"]["label"] == "<shared-null-label>"
def test_select_data_mask_legacy_string_column() -> None:
"""Applied values accept the same legacy column shape as target updates."""
from superset.mcp_service.dashboard.tool.manage_native_filters import (
_select_data_mask,
)
existing: dict[str, Any] = copy.deepcopy(EXISTING_SELECT_FILTER)
existing["targets"][0]["column"] = "region"
mask = _select_data_mask(existing, ["EMEA"])
assert mask["extraFormData"] == {
"filters": [{"col": "region", "op": "IN", "val": ["EMEA"]}]
}
def test_update_default_legacy_string_column() -> None:
"""Setting a default on a legacy target preserves its column and config."""
from superset.mcp_service.dashboard.schemas import NativeFilterUpdateSpec
from superset.mcp_service.dashboard.tool.manage_native_filters import (
_merge_filter_update,
)
existing: dict[str, Any] = copy.deepcopy(EXISTING_SELECT_FILTER)
existing["targets"][0]["column"] = "region"
merged = _merge_filter_update(
NativeFilterUpdateSpec(id=existing["id"], default_value=["EMEA"]),
existing,
[10, 11],
)
assert merged["defaultDataMask"]["extraFormData"] == {
"filters": [{"col": "region", "op": "IN", "val": ["EMEA"]}]
}
assert existing["defaultDataMask"]["filterState"]["value"] is None
@pytest.mark.parametrize(
"target",
[
{"column": 42},
{"column": ["region"]},
{"column": {"name": 42}},
{"column": {}},
"region",
],
)
def test_select_data_mask_invalid_target(target: object) -> None:
"""Malformed stored targets produce validation errors, not invalid predicates."""
from superset.mcp_service.dashboard.tool.manage_native_filters import (
_FilterValidationError,
_select_data_mask,
)
existing: dict[str, Any] = copy.deepcopy(EXISTING_SELECT_FILTER)
existing["targets"] = [target]
with pytest.raises(_FilterValidationError, match="has no target column"):
_select_data_mask(existing, ["EMEA"])

View File

@@ -123,7 +123,7 @@ TOOL_BUDGETS = {
"manage_dashboard_certification": 1_900,
"manage_dashboard_owners": 2_200,
"manage_dashboard_roles": 1_900,
"manage_native_filters": 9_100,
"manage_native_filters": 9_600,
"open_sql_lab_with_context": 1_800,
"query_dataset": 3_700,
"remove_chart_from_dashboard": 1_300,