This is an automated email from the ASF dual-hosted git repository.
arivero pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new 9f8b212ccc7 feat(mcp): add LIKE, ILIKE, IN, NOT IN filter operators to
MCP chart tools (#38071)
9f8b212ccc7 is described below
commit 9f8b212ccc75308d019338fab642489bda00af3d
Author: Amin Ghadersohi <[email protected]>
AuthorDate: Fri Feb 20 05:56:40 2026 -0500
feat(mcp): add LIKE, ILIKE, IN, NOT IN filter operators to MCP chart tools
(#38071)
---
superset/mcp_service/chart/chart_utils.py | 5 +
superset/mcp_service/chart/schemas.py | 49 +++++-
.../mcp_service/chart/test_chart_utils.py | 167 +++++++++++++++++++++
3 files changed, 217 insertions(+), 4 deletions(-)
diff --git a/superset/mcp_service/chart/chart_utils.py
b/superset/mcp_service/chart/chart_utils.py
index 748e770a4d8..cd5b9456063 100644
--- a/superset/mcp_service/chart/chart_utils.py
+++ b/superset/mcp_service/chart/chart_utils.py
@@ -458,6 +458,11 @@ def map_filter_operator(op: str) -> str:
">=": ">=",
"<=": "<=",
"!=": "!=",
+ "LIKE": "LIKE",
+ "ILIKE": "ILIKE",
+ "NOT LIKE": "NOT LIKE",
+ "IN": "IN",
+ "NOT IN": "NOT IN",
}
return operator_map.get(op, op)
diff --git a/superset/mcp_service/chart/schemas.py
b/superset/mcp_service/chart/schemas.py
index a27c3e3ceef..a42798a6982 100644
--- a/superset/mcp_service/chart/schemas.py
+++ b/superset/mcp_service/chart/schemas.py
@@ -395,10 +395,32 @@ class FilterConfig(BaseModel):
column: str = Field(
..., description="Column to filter on", min_length=1, max_length=255
)
- op: Literal["=", ">", "<", ">=", "<=", "!="] = Field(
- ..., description="Filter operator"
+ op: Literal[
+ "=",
+ ">",
+ "<",
+ ">=",
+ "<=",
+ "!=",
+ "LIKE",
+ "ILIKE",
+ "NOT LIKE",
+ "IN",
+ "NOT IN",
+ ] = Field(
+ ...,
+ description=(
+ "Filter operator. Use LIKE/ILIKE for pattern matching with %
wildcards "
+ "(e.g., '%mario%'). Use IN/NOT IN with a list of values."
+ ),
+ )
+ value: str | int | float | bool | list[str | int | float | bool] = Field(
+ ...,
+ description=(
+ "Filter value. For IN/NOT IN operators, provide a list of values. "
+ "For LIKE/ILIKE, use % as wildcard (e.g., '%mario%')."
+ ),
)
- value: str | int | float | bool = Field(..., description="Filter value")
@field_validator("column")
@classmethod
@@ -410,10 +432,29 @@ class FilterConfig(BaseModel):
@field_validator("value")
@classmethod
- def sanitize_value(cls, v: str | int | float | bool) -> str | int | float
| bool:
+ def sanitize_value(
+ cls, v: str | int | float | bool | list[str | int | float | bool]
+ ) -> str | int | float | bool | list[str | int | float | bool]:
"""Sanitize filter value to prevent XSS and SQL injection attacks."""
+ if isinstance(v, list):
+ return [sanitize_filter_value(item, max_length=1000) for item in v]
return sanitize_filter_value(v, max_length=1000)
+ @model_validator(mode="after")
+ def validate_value_type_matches_operator(self) -> FilterConfig:
+ """Validate that value type matches the operator requirements."""
+ if self.op in ("IN", "NOT IN"):
+ if not isinstance(self.value, list):
+ raise ValueError(
+ f"Operator '{self.op}' requires a list of values, "
+ f"got {type(self.value).__name__}"
+ )
+ elif isinstance(self.value, list):
+ raise ValueError(
+ f"Operator '{self.op}' requires a single value, not a list"
+ )
+ return self
+
# Actual chart types
class TableChartConfig(BaseModel):
diff --git a/tests/unit_tests/mcp_service/chart/test_chart_utils.py
b/tests/unit_tests/mcp_service/chart/test_chart_utils.py
index eab027c3dca..d1b8696f3a4 100644
--- a/tests/unit_tests/mcp_service/chart/test_chart_utils.py
+++ b/tests/unit_tests/mcp_service/chart/test_chart_utils.py
@@ -81,6 +81,17 @@ class TestMapFilterOperator:
assert map_filter_operator("<=") == "<="
assert map_filter_operator("!=") == "!="
+ def test_map_filter_operators_pattern_matching(self) -> None:
+ """Test mapping of pattern matching operators"""
+ assert map_filter_operator("LIKE") == "LIKE"
+ assert map_filter_operator("ILIKE") == "ILIKE"
+ assert map_filter_operator("NOT LIKE") == "NOT LIKE"
+
+ def test_map_filter_operators_set(self) -> None:
+ """Test mapping of set operators"""
+ assert map_filter_operator("IN") == "IN"
+ assert map_filter_operator("NOT IN") == "NOT IN"
+
def test_map_filter_operator_unknown(self) -> None:
"""Test mapping of unknown operator returns original"""
assert map_filter_operator("UNKNOWN") == "UNKNOWN"
@@ -145,6 +156,99 @@ class TestMapTableConfig:
assert filter_obj["comparator"] == "active"
assert filter_obj["expressionType"] == "SIMPLE"
+ def test_map_table_config_with_like_filter(self) -> None:
+ """Test table config mapping with LIKE filter for pattern matching"""
+ config = TableChartConfig(
+ chart_type="table",
+ columns=[ColumnRef(name="name")],
+ filters=[FilterConfig(column="name", op="LIKE", value="%mario%")],
+ )
+
+ result = map_table_config(config)
+
+ assert "adhoc_filters" in result
+ assert len(result["adhoc_filters"]) == 1
+ filter_obj = result["adhoc_filters"][0]
+ assert filter_obj["subject"] == "name"
+ assert filter_obj["operator"] == "LIKE"
+ assert filter_obj["comparator"] == "%mario%"
+ assert filter_obj["expressionType"] == "SIMPLE"
+
+ def test_map_table_config_with_ilike_filter(self) -> None:
+ """Test table config mapping with ILIKE filter for case-insensitive
matching"""
+ config = TableChartConfig(
+ chart_type="table",
+ columns=[ColumnRef(name="name")],
+ filters=[FilterConfig(column="name", op="ILIKE", value="%mario%")],
+ )
+
+ result = map_table_config(config)
+
+ assert "adhoc_filters" in result
+ filter_obj = result["adhoc_filters"][0]
+ assert filter_obj["operator"] == "ILIKE"
+ assert filter_obj["comparator"] == "%mario%"
+
+ def test_map_table_config_with_in_filter(self) -> None:
+ """Test table config mapping with IN filter for list matching"""
+ config = TableChartConfig(
+ chart_type="table",
+ columns=[ColumnRef(name="platform")],
+ filters=[
+ FilterConfig(
+ column="platform", op="IN", value=["Wii", "PS3", "Xbox360"]
+ )
+ ],
+ )
+
+ result = map_table_config(config)
+
+ assert "adhoc_filters" in result
+ filter_obj = result["adhoc_filters"][0]
+ assert filter_obj["subject"] == "platform"
+ assert filter_obj["operator"] == "IN"
+ assert filter_obj["comparator"] == ["Wii", "PS3", "Xbox360"]
+
+ def test_map_table_config_with_not_in_filter(self) -> None:
+ """Test table config mapping with NOT IN filter"""
+ config = TableChartConfig(
+ chart_type="table",
+ columns=[ColumnRef(name="status")],
+ filters=[
+ FilterConfig(
+ column="status", op="NOT IN", value=["archived", "deleted"]
+ )
+ ],
+ )
+
+ result = map_table_config(config)
+
+ assert "adhoc_filters" in result
+ filter_obj = result["adhoc_filters"][0]
+ assert filter_obj["operator"] == "NOT IN"
+ assert filter_obj["comparator"] == ["archived", "deleted"]
+
+ def test_map_table_config_with_mixed_filters(self) -> None:
+ """Test table config mapping with mixed filter operators"""
+ config = TableChartConfig(
+ chart_type="table",
+ columns=[ColumnRef(name="name"), ColumnRef(name="sales")],
+ filters=[
+ FilterConfig(column="platform", op="=", value="Wii"),
+ FilterConfig(column="name", op="ILIKE", value="%mario%"),
+ FilterConfig(column="genre", op="IN", value=["Sports",
"Racing"]),
+ ],
+ )
+
+ result = map_table_config(config)
+
+ assert len(result["adhoc_filters"]) == 3
+ assert result["adhoc_filters"][0]["operator"] == "=="
+ assert result["adhoc_filters"][1]["operator"] == "ILIKE"
+ assert result["adhoc_filters"][1]["comparator"] == "%mario%"
+ assert result["adhoc_filters"][2]["operator"] == "IN"
+ assert result["adhoc_filters"][2]["comparator"] == ["Sports", "Racing"]
+
def test_map_table_config_with_sort(self) -> None:
"""Test table config mapping with sort"""
config = TableChartConfig(
@@ -878,3 +982,66 @@ class TestMapXYConfigWithNonTemporalColumn:
# time_grain_sqla should be None, not P1M
assert result["time_grain_sqla"] is None
assert result["x_axis_sort_series_type"] == "name"
+
+
+class TestFilterConfigValidation:
+ """Test FilterConfig validation for new operators"""
+
+ def test_like_operator_with_wildcard(self) -> None:
+ """Test LIKE operator accepts string with % wildcards"""
+ f = FilterConfig(column="name", op="LIKE", value="%mario%")
+ assert f.op == "LIKE"
+ assert f.value == "%mario%"
+
+ def test_ilike_operator(self) -> None:
+ """Test ILIKE operator accepts string value"""
+ f = FilterConfig(column="name", op="ILIKE", value="%Mario%")
+ assert f.op == "ILIKE"
+ assert f.value == "%Mario%"
+
+ def test_not_like_operator(self) -> None:
+ """Test NOT LIKE operator accepts string value"""
+ f = FilterConfig(column="name", op="NOT LIKE", value="%test%")
+ assert f.op == "NOT LIKE"
+
+ def test_in_operator_with_list(self) -> None:
+ """Test IN operator accepts list of values"""
+ f = FilterConfig(column="platform", op="IN", value=["Wii", "PS3",
"Xbox360"])
+ assert f.op == "IN"
+ assert f.value == ["Wii", "PS3", "Xbox360"]
+
+ def test_not_in_operator_with_list(self) -> None:
+ """Test NOT IN operator accepts list of values"""
+ f = FilterConfig(column="status", op="NOT IN", value=["archived",
"deleted"])
+ assert f.op == "NOT IN"
+ assert f.value == ["archived", "deleted"]
+
+ def test_in_operator_rejects_scalar_value(self) -> None:
+ """Test IN operator rejects non-list value"""
+ with pytest.raises(ValueError, match="requires a list of values"):
+ FilterConfig(column="platform", op="IN", value="Wii")
+
+ def test_not_in_operator_rejects_scalar_value(self) -> None:
+ """Test NOT IN operator rejects non-list value"""
+ with pytest.raises(ValueError, match="requires a list of values"):
+ FilterConfig(column="status", op="NOT IN", value="active")
+
+ def test_equals_operator_rejects_list_value(self) -> None:
+ """Test = operator rejects list value"""
+ with pytest.raises(ValueError, match="requires a single value, not a
list"):
+ FilterConfig(column="name", op="=", value=["a", "b"])
+
+ def test_like_operator_rejects_list_value(self) -> None:
+ """Test LIKE operator rejects list value"""
+ with pytest.raises(ValueError, match="requires a single value, not a
list"):
+ FilterConfig(column="name", op="LIKE", value=["%a%", "%b%"])
+
+ def test_in_operator_with_numeric_list(self) -> None:
+ """Test IN operator with numeric values"""
+ f = FilterConfig(column="year", op="IN", value=[2020, 2021, 2022])
+ assert f.value == [2020, 2021, 2022]
+
+ def test_in_operator_with_empty_list(self) -> None:
+ """Test IN operator with empty list"""
+ f = FilterConfig(column="platform", op="IN", value=[])
+ assert f.value == []