feat(mcp): allow default values on filter_select native filters (#44985)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user