Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@
"@superset-ui/core": "^0.20.4",
"@swc/core": "^1.15.11",
"antd": "^6.3.0",
"baseline-browser-mapping": "^2.9.19",
"baseline-browser-mapping": "^2.10.0",
"caniuse-lite": "^1.0.30001770",
"docusaurus-plugin-openapi-docs": "^4.6.0",
"docusaurus-theme-openapi-docs": "^4.6.0",
Expand Down
8 changes: 4 additions & 4 deletions docs/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -5516,10 +5516,10 @@ base64-js@^1.3.1, base64-js@^1.5.1:
resolved "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz"
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==

baseline-browser-mapping@^2.9.0, baseline-browser-mapping@^2.9.19:
version "2.9.19"
resolved "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz"
integrity sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==
baseline-browser-mapping@^2.10.0, baseline-browser-mapping@^2.9.0, baseline-browser-mapping@^2.9.19:
version "2.10.0"
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz#5b09935025bf8a80e29130251e337c6a7fc8cbb9"
integrity sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==

batch@0.6.1:
version "0.6.1"
Expand Down
21 changes: 12 additions & 9 deletions superset-frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions superset-frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@
"react-dnd-html5-backend": "^11.1.3",
"react-dom": "^17.0.2",
"react-google-recaptcha": "^3.1.0",
"react-intersection-observer": "^10.0.2",
"react-intersection-observer": "^10.0.3",
"react-json-tree": "^0.20.0",
"react-lines-ellipsis": "^0.16.1",
"react-loadable": "^5.5.0",
Expand Down Expand Up @@ -304,7 +304,7 @@
"babel-plugin-dynamic-import-node": "^2.3.3",
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
"babel-plugin-lodash": "^3.3.4",
"baseline-browser-mapping": "^2.9.19",
"baseline-browser-mapping": "^2.10.0",
"cheerio": "1.2.0",
"concurrently": "^9.2.1",
"copy-webpack-plugin": "^13.0.1",
Expand Down
5 changes: 5 additions & 0 deletions superset/mcp_service/chart/chart_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
49 changes: 45 additions & 4 deletions superset/mcp_service/chart/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -410,10 +432,29 @@ def sanitize_column(cls, v: str) -> str:

@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):
Expand Down
4 changes: 2 additions & 2 deletions superset/thumbnails/digest.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ def _adjust_string_with_rls(
if table_ids:
security_manager.prefetch_rls_filters(table_ids)

for datasource in datasources:
for datasource in sorted(datasources, key=lambda d: d.id if d else -1):
if datasource and getattr(datasource, "is_rls_supported", False):
rls_filters = datasource.get_sqla_row_level_filters()

Expand Down Expand Up @@ -110,7 +110,7 @@ def get_dashboard_digest(dashboard: Dashboard) -> str | None:
return func(dashboard, executor_type, executor)

unique_string = (
f"{dashboard.id}\n{dashboard.charts}\n{dashboard.position_json}\n"
f"{dashboard.id}\n{sorted(dashboard.charts)}\n{dashboard.position_json}\n"
f"{dashboard.css}\n{dashboard.json_metadata}"
)

Expand Down
167 changes: 167 additions & 0 deletions tests/unit_tests/mcp_service/chart/test_chart_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,17 @@ def test_map_filter_operators(self) -> None:
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"
Expand Down Expand Up @@ -145,6 +156,99 @@ def test_map_table_config_with_filters(self) -> None:
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(
Expand Down Expand Up @@ -878,3 +982,66 @@ def test_non_temporal_ignores_time_grain_param(self, mock_is_temporal) -> None:
# 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 == []
Loading
Loading