This is an automated email from the ASF dual-hosted git repository. aminghadersohi pushed a commit to branch mcp-select-filter-default-value in repository https://gitbox.apache.org/repos/asf/superset.git
commit 1c9eafe2386fa5d62232913bc5d051e11cad2120 Author: Amin Ghadersohi <[email protected]> AuthorDate: Mon Oct 5 13:30:42 2026 +0000 feat(mcp): allow default values on filter_select native filters Adds default_value to the MCP manage_native_filters create and update specs for filter_select filters, building the stored defaultDataMask with the same builder apply_dashboard_filters already uses for applied values. default_to_first_item and an explicit default_value are mutually exclusive, matching the dashboard UI. --- superset/mcp_service/dashboard/schemas.py | 46 ++- .../dashboard/tool/apply_dashboard_filters.py | 91 +----- .../dashboard/tool/manage_native_filters.py | 101 +++++- .../dashboard/tool/test_manage_native_filters.py | 359 +++++++++++++++++++++ 4 files changed, 503 insertions(+), 94 deletions(-) diff --git a/superset/mcp_service/dashboard/schemas.py b/superset/mcp_service/dashboard/schemas.py index 37e1c255e1e..07a4bb591e0 100644 --- a/superset/mcp_service/dashboard/schemas.py +++ b/superset/mcp_service/dashboard/schemas.py @@ -2268,6 +2268,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 + class BaseNewFilterSpec(BaseModel): """Common fields shared by all new native filter specs.""" @@ -2313,6 +2317,24 @@ 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) for the filter, applied when a " + "viewer opens the dashboard without having changed it. Omit for " + "no default (an empty selection). 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): @@ -2374,6 +2396,17 @@ 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 selected value(s) for the filter (filter_select " + "only). Pass an empty list to clear the default to no " + "selection. Mutually exclusive with setting " + "default_to_first_item=True in the same update; if the filter " + "currently has default_to_first_item enabled, also pass " + "default_to_first_item=False in this same update." + ), + ) default_time_range: str | None = Field( None, description="Default time range (filter_time only)" ) @@ -2390,6 +2423,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 + class ManageNativeFiltersRequest(BaseModel): """Request schema for the manage_native_filters tool.""" @@ -2479,10 +2521,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 - class ApplyFilterValueSpec(BaseModel): """A value to apply to one existing native filter. diff --git a/superset/mcp_service/dashboard/tool/apply_dashboard_filters.py b/superset/mcp_service/dashboard/tool/apply_dashboard_filters.py index 64a53414afd..755b40e71f1 100644 --- a/superset/mcp_service/dashboard/tool/apply_dashboard_filters.py +++ b/superset/mcp_service/dashboard/tool/apply_dashboard_filters.py @@ -32,7 +32,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, @@ -44,9 +44,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, ) @@ -56,16 +57,6 @@ logger = logging.getLogger(__name__) # types manage_native_filters can create. SUPPORTED_FILTER_TYPES: frozenset[str] = frozenset({"filter_select", "filter_time"}) -# 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.""" @@ -142,82 +133,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 _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. - """ - targets = [target for target in (conf.get("targets") or []) if target] - column = (targets[0].get("column") or {}).get("name") if targets else None - if not column: - raise _FilterApplyError( - 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 _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 diff --git a/superset/mcp_service/dashboard/tool/manage_native_filters.py b/superset/mcp_service/dashboard/tool/manage_native_filters.py index 09386e9374a..b30f119246c 100644 --- a/superset/mcp_service/dashboard/tool/manage_native_filters.py +++ b/superset/mcp_service/dashboard/tool/manage_native_filters.py @@ -30,10 +30,12 @@ from typing import Any from fastmcp import Context from superset_core.mcp.decorators import tool, ToolAnnotations +from superset.constants import EMPTY_FILTER_SQL_EXPRESSION from superset.extensions import event_logger from superset.mcp_service.dashboard.constants import generate_id from superset.mcp_service.dashboard.schemas import ( FilterSelectSpec, + FilterSelectValue, FilterTimeSpec, ManageNativeFiltersRequest, ManageNativeFiltersResponse, @@ -54,6 +56,12 @@ _SELECT_CONTROL_FIELDS: dict[str, str] = { "search_all_options": "searchAllOptions", } +# 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. +_NULL_LABEL = "<NULL>" +_TRUE_LABEL = "TRUE" +_FALSE_LABEL = "FALSE" + class _FilterValidationError(Exception): """Raised internally when a filter operation fails validation.""" @@ -64,6 +72,83 @@ 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_LABEL + 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 = (targets[0].get("column") or {}).get("name") if targets else None + if 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: + 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(default_time_range: str | None) -> dict[str, Any]: """Build the default data mask for a time filter. @@ -137,7 +222,7 @@ def _build_new_filter_config( } if spec.sort_ascending is not None: control_values["sortAscending"] = spec.sort_ascending - return { + config: dict[str, Any] = { "id": filter_id, "type": "NATIVE_FILTER", "filterType": "filter_select", @@ -151,6 +236,9 @@ def _build_new_filter_config( "defaultDataMask": _empty_data_mask(), "cascadeParentIds": [], } + if spec.default_value is not None: + config["defaultDataMask"] = _select_data_mask(config, spec.default_value) + return config # filter_time: no dataset target, empty controlValues return { @@ -173,7 +261,7 @@ def _validate_update_type_compat( """Reject update fields that do not apply to the filter's type.""" select_fields_set = [ field - for field in (*_SELECT_CONTROL_FIELDS, "dataset_id", "column") + for field in (*_SELECT_CONTROL_FIELDS, "dataset_id", "column", "default_value") if getattr(spec, field) is not None ] if filter_type != "filter_select" and select_fields_set: @@ -240,6 +328,15 @@ def _merge_filter_update( control_values[control_key] = value merged["controlValues"] = control_values + if spec.default_value is not None: + if control_values.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"] = _select_data_mask(merged, spec.default_value) + if spec.default_time_range is not None: merged["defaultDataMask"] = _time_data_mask(spec.default_time_range) diff --git a/tests/unit_tests/mcp_service/dashboard/tool/test_manage_native_filters.py b/tests/unit_tests/mcp_service/dashboard/tool/test_manage_native_filters.py index 075d7d65467..71a97c7ffe0 100644 --- a/tests/unit_tests/mcp_service/dashboard/tool/test_manage_native_filters.py +++ b/tests/unit_tests/mcp_service/dashboard/tool/test_manage_native_filters.py @@ -37,6 +37,7 @@ Covers: - Permission denied (DashboardForbiddenError) """ +import copy import logging from collections.abc import Callable, Iterator from typing import Any @@ -298,6 +299,144 @@ async def test_add_filter_time(mcp_server): } [email protected] +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"}, + } + + [email protected] +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}, + } + + [email protected] +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"] + + [email protected] +async def test_add_filter_select_default_to_first_item_and_default_value_rejected( + mcp_server, +): + """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": ["EMEA"], + } + ], + }, + ) + + # --------------------------------------------------------------------------- # Update # --------------------------------------------------------------------------- @@ -386,6 +525,144 @@ async def test_update_time_field_on_select_filter_rejected(mcp_server): assert "filter_time" in data["error"] [email protected] +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"}, + } + + [email protected] +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}, + } + + [email protected] +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"] + + [email protected] +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"] + + [email protected] +async def test_update_default_to_first_item_and_default_value_rejected(mcp_server): + """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": ["EMEA"], + } + ], + }, + ) + + @pytest.mark.asyncio async def test_update_duplicate_filter_ids_rejected(mcp_server): dashboard = _mock_dashboard(filters=[EXISTING_SELECT_FILTER]) @@ -891,6 +1168,88 @@ class TestNativeFilterUpdateSpecTimeRangeValidation: ) +# --------------------------------------------------------------------------- +# default_value validation (SC-121506) +# --------------------------------------------------------------------------- + + +class TestFilterSelectSpecDefaultValueValidation: + """FilterSelectSpec.default_value and default_to_first_item are + mutually exclusive, matching the Superset UI.""" + + def test_default_value_alone_passes(self) -> None: + from superset.mcp_service.dashboard.schemas import FilterSelectSpec + + spec = FilterSelectSpec.model_validate( + { + "filter_type": "filter_select", + "name": "Region", + "dataset_id": 5, + "column": "region", + "default_value": ["EMEA"], + } + ) + assert spec.default_value == ["EMEA"] + + def test_default_to_first_item_alone_passes(self) -> None: + from superset.mcp_service.dashboard.schemas import FilterSelectSpec + + spec = FilterSelectSpec.model_validate( + { + "filter_type": "filter_select", + "name": "Region", + "dataset_id": 5, + "column": "region", + "default_to_first_item": True, + } + ) + assert spec.default_to_first_item is True + assert spec.default_value is None + + def test_both_set_raises(self) -> None: + from pydantic import ValidationError + + from superset.mcp_service.dashboard.schemas import FilterSelectSpec + + with pytest.raises(ValidationError, match="mutually exclusive"): + FilterSelectSpec.model_validate( + { + "filter_type": "filter_select", + "name": "Region", + "dataset_id": 5, + "column": "region", + "default_to_first_item": True, + "default_value": ["EMEA"], + } + ) + + +class TestNativeFilterUpdateSpecDefaultValueValidation: + """NativeFilterUpdateSpec gets the same mutual-exclusion guard.""" + + def test_default_value_alone_passes(self) -> None: + from superset.mcp_service.dashboard.schemas import NativeFilterUpdateSpec + + spec = NativeFilterUpdateSpec.model_validate( + {"id": "NATIVE_FILTER-1", "default_value": ["EMEA"]} + ) + assert spec.default_value == ["EMEA"] + + def test_both_set_raises(self) -> None: + from pydantic import ValidationError + + from superset.mcp_service.dashboard.schemas import NativeFilterUpdateSpec + + with pytest.raises(ValidationError, match="mutually exclusive"): + NativeFilterUpdateSpec.model_validate( + { + "id": "NATIVE_FILTER-1", + "default_to_first_item": True, + "default_value": ["EMEA"], + } + ) + + @pytest.mark.asyncio async def test_add_filter_time_rejects_unparseable_default(mcp_server): """End-to-end: manage_native_filters must not persist a dead time
