Compare commits

...

7 Commits

Author SHA1 Message Date
Amin Ghadersohi
30ebc94b2f Merge branch 'master' into mcp-chart-explore-url 2026-06-06 15:17:13 -07:00
Amin Ghadersohi
053f1292a4 fix(mcp): preserve form_data_key URL for preview callers of generate_explore_link
Returning a durable permalink URL by default broke callers that re-parse the
URL for a form_data_key: update_chart_preview failed its success path with
"missing form_data_key" and generate_chart preview mode lost its unsaved-state
key. Add a prefer_permalink flag (default True for the explore tool) and have
the two preview callers pass prefer_permalink=False to keep the
/explore/?form_data_key=... contract. Add a regression test.
2026-06-06 22:14:02 +00:00
Amin Ghadersohi
8b85cdbd4a test(mcp): mock permalink command in form_data_key fallback test
After narrowing the explore-link exception handling, the durable-permalink
path no longer swallows the AttributeError raised by check_chart_access
accessing g.user outside a request context. Patch CreateExplorePermalinkCommand.run
to fail explicitly so the test deterministically exercises the form_data_key
fallback, matching the test's stated intent.
2026-06-06 18:58:39 +00:00
Amin Ghadersohi
c8d6f09aa9 fix(mcp): narrow explore-link exception handling to expected failure modes
Address review feedback that the permalink-creation except was too broad.
CreateExplorePermalinkCommand already wraps its internal failures into
ExplorePermalinkCreateFailedError, so catch only that and SQLAlchemyError
in the permalink and outer fallback blocks. Programming errors (TypeError,
AttributeError, KeyError, ValueError) now surface to the tool handler
instead of being silently masked behind a fallback URL.
2026-06-05 22:58:57 +00:00
Amin Ghadersohi
0f65ec35ca Merge branch 'master' into mcp-chart-explore-url 2026-06-05 15:55:41 -07:00
Amin Ghadersohi
d694de8856 fix(mcp): relocate extract_permalink_key_from_url and fix UUID datasource
- Move extract_permalink_key_from_url from chart_helpers to url_utils
  and reimplement via urlparse path parsing instead of regex
- Fix generate_explore_link tool to use dataset.id (resolved numeric ID)
  instead of request.dataset_id (may be UUID string) when building the
  datasource field in the returned form_data dict
- Add unit tests for extract_permalink_key_from_url in test_url_utils.py
2026-06-04 18:30:42 +00:00
Amin Ghadersohi
62fae8c493 fix(mcp): generate durable explore permalink URL instead of ephemeral form_data_key
The `generate_explore_link` MCP tool previously returned a URL like
`/explore/?form_data_key=<key>` backed by Redis cache (~24h expiry).
When the key expired the URL became unreachable, preventing LLMs from
iteratively building and sharing charts.

This commit changes the URL strategy:
1. Try `CreateExplorePermalinkCommand` first (DB-backed key-value store,
   does not expire) → `/explore/p/<key>/`
2. Fall back to `MCPCreateFormDataCommand` (Redis cache) on permalink
   failure → `/explore/?form_data_key=<key>`
3. Fall back to plain dataset URL on both failing.

The tool response now includes a `permalink_key` field (populated when
the durable permalink path is used) alongside the existing `form_data_key`
(populated only in the Redis-fallback case).

`extract_permalink_key_from_url()` added to `chart_helpers.py`.
Tests updated to mock `CreateExplorePermalinkCommand.run` by default and
cover both the permalink path and each fallback tier.
2026-06-04 18:13:20 +00:00
8 changed files with 338 additions and 171 deletions

View File

@@ -157,14 +157,31 @@ def validate_chart_dataset(
)
def generate_explore_link(dataset_id: int | str, form_data: Dict[str, Any]) -> str:
"""Generate an explore link for the given dataset and form data."""
def generate_explore_link(
dataset_id: int | str,
form_data: Dict[str, Any],
prefer_permalink: bool = True,
) -> str:
"""Generate an explore link for the given dataset and form data.
Prefers a durable explore permalink (DB-backed key-value store, does not
expire) over an ephemeral form_data_key (Redis cache, expires in ~24h).
Falls back to the form_data_key approach if permalink creation fails, then
to a plain dataset URL as a last resort.
Set ``prefer_permalink=False`` for callers that depend on a ``form_data_key``
in the returned URL (e.g. preview flows that extract and re-cache the key);
this skips the permalink path and returns an ``/explore/?form_data_key=...``
URL directly.
"""
from sqlalchemy.exc import SQLAlchemyError
from superset.commands.exceptions import CommandException
from superset.commands.explore.form_data.parameters import CommandParameters
from superset.commands.explore.permalink.create import CreateExplorePermalinkCommand
from superset.daos.dataset import DatasetDAO
from superset.exceptions import SupersetException
from superset.explore.permalink.exceptions import ExplorePermalinkCreateFailedError
from superset.mcp_service.commands.create_form_data import (
MCPCreateFormDataCommand,
)
@@ -200,7 +217,27 @@ def generate_explore_link(dataset_id: int | str, form_data: Dict[str, Any]) -> s
"datasource": f"{numeric_dataset_id}__table",
}
# Try to create form_data in cache using MCP-specific CreateFormDataCommand
# Try durable permalink first (DB-backed key-value store, does not expire).
# CreateExplorePermalinkCommand wraps its internal failures (encode/create/
# SQLAlchemy errors) into ExplorePermalinkCreateFailedError, so catch only
# those expected modes here — letting programming errors (TypeError, etc.)
# surface instead of being silently masked by the form_data_key fallback.
# Callers that need a form_data_key URL opt out via prefer_permalink=False.
if prefer_permalink:
try:
state = {"formData": form_data_with_datasource}
permalink_key = CreateExplorePermalinkCommand(state=state).run()
return f"{base_url}/explore/p/{permalink_key}/"
except (
ExplorePermalinkCreateFailedError,
SQLAlchemyError,
) as permalink_e:
logger.debug(
"Permalink generation failed, falling back to form_data_key: %s",
permalink_e,
)
# Fall back to ephemeral form_data_key (Redis-backed cache)
cmd_params = CommandParameters(
datasource_type=DatasourceType.TABLE,
datasource_id=numeric_dataset_id,
@@ -208,23 +245,18 @@ def generate_explore_link(dataset_id: int | str, form_data: Dict[str, Any]) -> s
tab_id=None,
form_data=json.dumps(form_data_with_datasource),
)
# Create the form_data cache entry and get the key
form_data_key = MCPCreateFormDataCommand(cmd_params).run()
# Return URL with just the form_data_key
return f"{base_url}/explore/?form_data_key={form_data_key}"
except (
CommandException,
SupersetException,
SQLAlchemyError,
KeyError,
ValueError,
AttributeError,
TypeError,
) as e:
# Fallback to basic explore URL with numeric ID if available
# Fallback to basic explore URL with numeric ID if available. Only the
# expected failure modes of dataset lookup / form_data creation are caught
# here; programming errors propagate to the tool handler so they aren't
# silently masked behind a fallback URL.
logger.debug("Explore link generation fallback due to: %s", e)
if numeric_dataset_id is not None:
return (

View File

@@ -527,7 +527,9 @@ async def generate_chart( # noqa: C901
# Generate explore link with cached form_data for preview-only mode
from superset.mcp_service.chart.chart_utils import generate_explore_link
explore_url = generate_explore_link(request.dataset_id, form_data)
explore_url = generate_explore_link(
request.dataset_id, form_data, prefer_permalink=False
)
await ctx.debug("Generated explore link: explore_url=%s" % (explore_url,))
# Extract form_data_key from the explore URL

View File

@@ -240,8 +240,11 @@ def update_chart_preview( # noqa: C901
"api_version": "v1",
}
# Generate new explore link with updated form_data
explore_url = generate_explore_link(request.dataset_id, new_form_data)
# Generate new explore link with updated form_data. This preview flow
# extracts and re-caches the form_data_key, so force that URL shape.
explore_url = generate_explore_link(
request.dataset_id, new_form_data, prefer_permalink=False
)
# Extract new form_data_key from the explore URL
new_form_data_key = extract_form_data_key_from_url(explore_url)

View File

@@ -30,7 +30,9 @@ from superset_core.mcp.decorators import tool, ToolAnnotations
from superset.extensions import event_logger
from superset.mcp_service.auth import has_dataset_access
from superset.mcp_service.chart.chart_helpers import extract_form_data_key_from_url
from superset.mcp_service.chart.chart_helpers import (
extract_form_data_key_from_url,
)
from superset.mcp_service.chart.chart_utils import (
generate_explore_link as generate_url,
get_table_chart_type_label,
@@ -40,6 +42,9 @@ from superset.mcp_service.chart.compile import validate_and_compile
from superset.mcp_service.chart.schemas import (
GenerateExploreLinkRequest,
)
from superset.mcp_service.utils.url_utils import (
extract_permalink_key_from_url,
)
logger = logging.getLogger(__name__)
@@ -135,6 +140,7 @@ async def generate_explore_link(
return {
"url": "",
"form_data": {},
"permalink_key": None,
"form_data_key": None,
"chart_type_label": None,
"error": (
@@ -154,6 +160,7 @@ async def generate_explore_link(
return {
"url": "",
"form_data": {},
"permalink_key": None,
"form_data_key": None,
"chart_type_label": None,
"error": (
@@ -178,6 +185,7 @@ async def generate_explore_link(
return {
"url": default_url,
"form_data": {},
"permalink_key": None,
"form_data_key": None,
"chart_type_label": None,
"error": None,
@@ -209,7 +217,7 @@ async def generate_explore_link(
# Add datasource to form_data for consistency with generate_chart
# Only set if not already present to avoid overwriting
if "datasource" not in form_data:
form_data["datasource"] = f"{request.dataset_id}__table"
form_data["datasource"] = f"{dataset.id}__table"
await ctx.debug(
"Form data generated with keys: %s, has_viz_type=%s, has_datasource=%s"
@@ -248,6 +256,7 @@ async def generate_explore_link(
return {
"url": "",
"form_data": form_data,
"permalink_key": None,
"form_data_key": None,
"chart_type_label": None,
"error": error_payload,
@@ -262,19 +271,23 @@ async def generate_explore_link(
dataset_id=request.dataset_id, form_data=form_data
)
# Extract form_data_key from the explore URL
form_data_key = extract_form_data_key_from_url(explore_url)
# Extract permalink_key (durable) or fall back to form_data_key (ephemeral)
permalink_key = extract_permalink_key_from_url(explore_url)
form_data_key = (
extract_form_data_key_from_url(explore_url) if not permalink_key else None
)
await ctx.report_progress(4, 4, "URL generation complete")
await ctx.info(
"Explore link generated successfully: url_length=%s, dataset_id=%s, "
"form_data_key=%s"
% (len(explore_url or ""), request.dataset_id, form_data_key)
"permalink_key=%s, form_data_key=%s"
% (len(explore_url or ""), request.dataset_id, permalink_key, form_data_key)
)
return {
"url": explore_url,
"form_data": form_data,
"permalink_key": permalink_key,
"form_data_key": form_data_key,
"chart_type_label": get_table_chart_type_label(form_data.get("viz_type")),
"error": None,
@@ -293,6 +306,7 @@ async def generate_explore_link(
return {
"url": "",
"form_data": {},
"permalink_key": None,
"form_data_key": None,
"chart_type_label": None,
"error": f"Failed to generate explore link: {str(e)}",

View File

@@ -57,6 +57,22 @@ def get_superset_base_url() -> str:
return default_url
def extract_permalink_key_from_url(url: str | None) -> str | None:
"""Extract the permalink key from an explore permalink URL.
Matches the /explore/p/<key>/ pattern produced by
CreateExplorePermalinkCommand. Returns the key, or None if the URL
does not follow that pattern.
"""
if not url:
return None
path = urlparse(url).path
parts = [p for p in path.split("/") if p]
if len(parts) >= 3 and parts[-3] == "explore" and parts[-2] == "p":
return parts[-1]
return None
def get_mcp_service_url() -> str:
"""
Get the MCP service base URL where screenshot endpoints are served.

View File

@@ -1176,13 +1176,25 @@ class TestGenerateExploreLink:
self, mock_command, mock_get_base_url
) -> None:
"""Test generate_explore_link creates form_data_key when dataset exists"""
from superset.explore.permalink.exceptions import (
ExplorePermalinkCreateFailedError,
)
mock_get_base_url.return_value = "http://localhost:9001"
mock_command.return_value.run.return_value = "test_form_data_key"
# Mock dataset exists
# Mock dataset exists; force the durable-permalink path to fail so the
# function falls back to the ephemeral form_data_key.
mock_dataset = type("Dataset", (), {"id": 123})()
with patch(
"superset.daos.dataset.DatasetDAO.find_by_id", return_value=mock_dataset
with (
patch(
"superset.daos.dataset.DatasetDAO.find_by_id", return_value=mock_dataset
),
patch(
"superset.commands.explore.permalink.create."
"CreateExplorePermalinkCommand.run",
side_effect=ExplorePermalinkCreateFailedError("permalink unavailable"),
),
):
result = generate_explore_link(123, {"viz_type": "table"})
@@ -1191,6 +1203,36 @@ class TestGenerateExploreLink:
)
mock_command.assert_called_once()
@patch("superset.mcp_service.chart.chart_utils.get_superset_base_url")
@patch("superset.mcp_service.commands.create_form_data.MCPCreateFormDataCommand")
def test_generate_explore_link_prefer_permalink_false(
self, mock_command, mock_get_base_url
) -> None:
"""prefer_permalink=False skips the permalink path and returns a
form_data_key URL, so preview callers that re-parse the key keep working."""
mock_get_base_url.return_value = "http://localhost:9001"
mock_command.return_value.run.return_value = "test_form_data_key"
mock_dataset = type("Dataset", (), {"id": 123})()
with (
patch(
"superset.daos.dataset.DatasetDAO.find_by_id", return_value=mock_dataset
),
patch(
"superset.commands.explore.permalink.create."
"CreateExplorePermalinkCommand.run"
) as mock_permalink,
):
result = generate_explore_link(
123, {"viz_type": "table"}, prefer_permalink=False
)
assert (
result == "http://localhost:9001/explore/?form_data_key=test_form_data_key"
)
mock_command.assert_called_once()
mock_permalink.assert_not_called()
@patch("superset.mcp_service.chart.chart_utils.get_superset_base_url")
def test_generate_explore_link_exception_handling(self, mock_get_base_url) -> None:
"""Test generate_explore_link handles SQLAlchemy exceptions gracefully"""

View File

@@ -49,6 +49,13 @@ generate_explore_link_module = importlib.import_module(
logging.basicConfig(level=logging.DEBUG)
logger = logging.getLogger(__name__)
_PERMALINK_PATCH = (
"superset.commands.explore.permalink.create.CreateExplorePermalinkCommand.run"
)
_FORM_DATA_PATCH = (
"superset.mcp_service.commands.create_form_data.MCPCreateFormDataCommand.run"
)
@pytest.fixture
def mcp_server():
@@ -100,6 +107,17 @@ def mock_webdriver_baseurl(app_context):
current_app.config["WEBDRIVER_BASEURL_USER_FRIENDLY"] = original_value
@pytest.fixture(autouse=True)
def mock_permalink_creation():
"""Create durable permalink by default.
Override in individual tests that need fallback-to-form_data_key or
fallback-to-basic-URL behaviour by patching _PERMALINK_PATCH to raise.
"""
with patch(_PERMALINK_PATCH, return_value="test_permalink_key"):
yield
def _mock_dataset(id: int = 1) -> Mock:
"""Create a mock dataset object with columns and db_engine_spec."""
from superset.utils.core import ColumnSpec, GenericDataType
@@ -132,15 +150,11 @@ class TestGenerateExploreLink:
"""Comprehensive tests for generate_explore_link MCP tool."""
@patch("superset.daos.dataset.DatasetDAO.find_by_id")
@patch(
"superset.mcp_service.commands.create_form_data.MCPCreateFormDataCommand.run"
)
@pytest.mark.asyncio
async def test_generate_table_explore_link_minimal(
self, mock_create_form_data, mock_find_dataset, mcp_server
self, mock_find_dataset, mcp_server
):
"""Test generating explore link for minimal table chart."""
mock_create_form_data.return_value = "test_form_data_key_123"
mock_find_dataset.return_value = _mock_dataset(id=1)
config = TableChartConfig(
@@ -156,21 +170,18 @@ class TestGenerateExploreLink:
assert result.data["error"] is None
assert (
result.data["url"]
== "http://localhost:9001/explore/?form_data_key=test_form_data_key_123"
== "http://localhost:9001/explore/p/test_permalink_key/"
)
assert result.data["permalink_key"] == "test_permalink_key"
assert result.data["form_data_key"] is None
assert result.data["chart_type_label"] == "table chart"
mock_create_form_data.assert_called_once()
@patch("superset.daos.dataset.DatasetDAO.find_by_id")
@patch(
"superset.mcp_service.commands.create_form_data.MCPCreateFormDataCommand.run"
)
@pytest.mark.asyncio
async def test_generate_table_explore_link_with_features(
self, mock_create_form_data, mock_find_dataset, mcp_server
self, mock_find_dataset, mcp_server
):
"""Test generating explore link for table chart with features."""
mock_create_form_data.return_value = "comprehensive_key_456"
mock_find_dataset.return_value = _mock_dataset(id=5)
config = TableChartConfig(
@@ -196,21 +207,18 @@ class TestGenerateExploreLink:
assert result.data["error"] is None
assert (
result.data["url"]
== "http://localhost:9001/explore/?form_data_key=comprehensive_key_456"
== "http://localhost:9001/explore/p/test_permalink_key/"
)
assert result.data["permalink_key"] == "test_permalink_key"
assert result.data["form_data_key"] is None
assert result.data["chart_type_label"] == "table chart"
mock_create_form_data.assert_called_once()
@patch("superset.daos.dataset.DatasetDAO.find_by_id")
@patch(
"superset.mcp_service.commands.create_form_data.MCPCreateFormDataCommand.run"
)
@pytest.mark.asyncio
async def test_generate_ag_grid_table_explore_link_label(
self, mock_create_form_data, mock_find_dataset, mcp_server
self, mock_find_dataset, mcp_server
) -> None:
"""Test generating explore link reports AG Grid table label."""
mock_create_form_data.return_value = "ag_grid_key_123"
mock_find_dataset.return_value = _mock_dataset(id=1)
config = TableChartConfig(
@@ -229,15 +237,11 @@ class TestGenerateExploreLink:
assert result.data["chart_type_label"] == "interactive table chart"
@patch("superset.daos.dataset.DatasetDAO.find_by_id")
@patch(
"superset.mcp_service.commands.create_form_data.MCPCreateFormDataCommand.run"
)
@pytest.mark.asyncio
async def test_generate_line_chart_explore_link(
self, mock_create_form_data, mock_find_dataset, mcp_server
self, mock_find_dataset, mcp_server
):
"""Test generating explore link for line chart."""
mock_create_form_data.return_value = "line_chart_key_789"
mock_find_dataset.return_value = _mock_dataset(id=3)
config = XYChartConfig(
@@ -263,21 +267,14 @@ class TestGenerateExploreLink:
assert result.data["error"] is None
assert (
result.data["url"]
== "http://localhost:9001/explore/?form_data_key=line_chart_key_789"
== "http://localhost:9001/explore/p/test_permalink_key/"
)
assert result.data["chart_type_label"] is None
mock_create_form_data.assert_called_once()
@patch("superset.daos.dataset.DatasetDAO.find_by_id")
@patch(
"superset.mcp_service.commands.create_form_data.MCPCreateFormDataCommand.run"
)
@pytest.mark.asyncio
async def test_generate_bar_chart_explore_link(
self, mock_create_form_data, mock_find_dataset, mcp_server
):
async def test_generate_bar_chart_explore_link(self, mock_find_dataset, mcp_server):
"""Test generating explore link for bar chart."""
mock_create_form_data.return_value = "bar_chart_key_abc"
mock_find_dataset.return_value = _mock_dataset(id=7)
config = XYChartConfig(
@@ -298,20 +295,15 @@ class TestGenerateExploreLink:
assert result.data["error"] is None
assert (
result.data["url"]
== "http://localhost:9001/explore/?form_data_key=bar_chart_key_abc"
== "http://localhost:9001/explore/p/test_permalink_key/"
)
mock_create_form_data.assert_called_once()
@patch("superset.daos.dataset.DatasetDAO.find_by_id")
@patch(
"superset.mcp_service.commands.create_form_data.MCPCreateFormDataCommand.run"
)
@pytest.mark.asyncio
async def test_generate_area_chart_explore_link(
self, mock_create_form_data, mock_find_dataset, mcp_server
self, mock_find_dataset, mcp_server
):
"""Test generating explore link for area chart."""
mock_create_form_data.return_value = "area_chart_key_def"
mock_find_dataset.return_value = _mock_dataset(id=2)
config = XYChartConfig(
@@ -335,20 +327,15 @@ class TestGenerateExploreLink:
assert result.data["error"] is None
assert (
result.data["url"]
== "http://localhost:9001/explore/?form_data_key=area_chart_key_def"
== "http://localhost:9001/explore/p/test_permalink_key/"
)
mock_create_form_data.assert_called_once()
@patch("superset.daos.dataset.DatasetDAO.find_by_id")
@patch(
"superset.mcp_service.commands.create_form_data.MCPCreateFormDataCommand.run"
)
@pytest.mark.asyncio
async def test_generate_scatter_chart_explore_link(
self, mock_create_form_data, mock_find_dataset, mcp_server
self, mock_find_dataset, mcp_server
):
"""Test generating explore link for scatter chart."""
mock_create_form_data.return_value = "scatter_chart_key_ghi"
mock_find_dataset.return_value = _mock_dataset(id=4)
config = XYChartConfig(
@@ -370,22 +357,71 @@ class TestGenerateExploreLink:
assert result.data["error"] is None
assert (
result.data["url"]
== "http://localhost:9001/explore/?form_data_key=scatter_chart_key_ghi"
== "http://localhost:9001/explore/p/test_permalink_key/"
)
@patch(_PERMALINK_PATCH)
@patch("superset.daos.dataset.DatasetDAO.find_by_id")
@patch(_FORM_DATA_PATCH)
@pytest.mark.asyncio
async def test_generate_explore_link_permalink_fails_fallback_to_form_data_key(
self,
mock_create_form_data,
mock_find_dataset,
mock_create_permalink,
mcp_server,
):
"""When permalink creation fails, fall back to ephemeral form_data_key URL."""
from superset.explore.permalink.exceptions import (
ExplorePermalinkCreateFailedError,
)
mock_find_dataset.return_value = _mock_dataset(id=1)
mock_create_permalink.side_effect = ExplorePermalinkCreateFailedError(
"DB unavailable"
)
mock_create_form_data.return_value = "fallback_form_data_key"
config = TableChartConfig(
chart_type="table", columns=[ColumnRef(name="test_col")]
)
request = GenerateExploreLinkRequest(dataset_id="1", config=config)
async with Client(mcp_server) as client:
result = await client.call_tool(
"generate_explore_link", {"request": request.model_dump()}
)
assert result.data["error"] is None
assert (
result.data["url"]
== "http://localhost:9001/explore/?form_data_key=fallback_form_data_key"
)
assert result.data["form_data_key"] == "fallback_form_data_key"
assert result.data["permalink_key"] is None
mock_create_form_data.assert_called_once()
@patch(_PERMALINK_PATCH)
@patch("superset.daos.dataset.DatasetDAO.find_by_id")
@patch(
"superset.mcp_service.commands.create_form_data.MCPCreateFormDataCommand.run"
)
@patch(_FORM_DATA_PATCH)
@pytest.mark.asyncio
async def test_generate_explore_link_cache_failure_fallback(
self, mock_create_form_data, mock_find_dataset, mcp_server
async def test_generate_explore_link_both_fail_fallback_to_basic_url(
self,
mock_create_form_data,
mock_find_dataset,
mock_create_permalink,
mcp_server,
):
"""Test fallback when form_data cache creation fails."""
mock_find_dataset.return_value = _mock_dataset(id=1)
"""When both permalink and form_data_key fail, fall back to basic URL."""
from superset.commands.exceptions import CommandException
from superset.explore.permalink.exceptions import (
ExplorePermalinkCreateFailedError,
)
mock_find_dataset.return_value = _mock_dataset(id=1)
mock_create_permalink.side_effect = ExplorePermalinkCreateFailedError(
"DB unavailable"
)
mock_create_form_data.side_effect = CommandException("Cache storage failed")
config = TableChartConfig(
@@ -398,28 +434,31 @@ class TestGenerateExploreLink:
"generate_explore_link", {"request": request.model_dump()}
)
# Should fallback to basic URL format
assert result.data["error"] is None
assert (
result.data["url"]
== "http://localhost:9001/explore/?datasource_type=table&datasource_id=1"
)
@patch(_PERMALINK_PATCH)
@patch("superset.daos.dataset.DatasetDAO.find_by_id")
@patch(
"superset.mcp_service.commands.create_form_data.MCPCreateFormDataCommand.run"
)
@patch(_FORM_DATA_PATCH)
@pytest.mark.asyncio
async def test_generate_explore_link_database_lock_fallback(
self, mock_create_form_data, mock_find_dataset, mcp_server
self,
mock_create_form_data,
mock_find_dataset,
mock_create_permalink,
mcp_server,
):
"""Test fallback when database is locked."""
"""When permalink fails with SQLAlchemy error, fall back to form_data_key."""
from sqlalchemy.exc import OperationalError
mock_find_dataset.return_value = _mock_dataset(id=5)
mock_create_form_data.side_effect = OperationalError(
mock_create_permalink.side_effect = OperationalError(
"database is locked", None, None
)
mock_create_form_data.return_value = "lock_fallback_key"
config = XYChartConfig(
chart_type="xy",
@@ -434,23 +473,18 @@ class TestGenerateExploreLink:
"generate_explore_link", {"request": request.model_dump()}
)
# Should fallback to basic dataset URL
assert result.data["error"] is None
assert (
result.data["url"]
== "http://localhost:9001/explore/?datasource_type=table&datasource_id=5"
== "http://localhost:9001/explore/?form_data_key=lock_fallback_key"
)
@patch("superset.daos.dataset.DatasetDAO.find_by_id")
@patch(
"superset.mcp_service.commands.create_form_data.MCPCreateFormDataCommand.run"
)
@pytest.mark.asyncio
async def test_generate_explore_link_with_many_columns(
self, mock_create_form_data, mock_find_dataset, mcp_server
self, mock_find_dataset, mcp_server
):
"""Test generating explore link with many columns."""
mock_create_form_data.return_value = "many_columns_key"
mock_find_dataset.return_value = _mock_dataset(id=1)
# Create 15 columns
@@ -474,20 +508,15 @@ class TestGenerateExploreLink:
assert result.data["error"] is None
assert (
result.data["url"]
== "http://localhost:9001/explore/?form_data_key=many_columns_key"
== "http://localhost:9001/explore/p/test_permalink_key/"
)
mock_create_form_data.assert_called_once()
@patch("superset.daos.dataset.DatasetDAO.find_by_id")
@patch(
"superset.mcp_service.commands.create_form_data.MCPCreateFormDataCommand.run"
)
@pytest.mark.asyncio
async def test_generate_explore_link_with_many_filters(
self, mock_create_form_data, mock_find_dataset, mcp_server
self, mock_find_dataset, mcp_server
):
"""Test generating explore link with many filters."""
mock_create_form_data.return_value = "many_filters_key"
mock_find_dataset.return_value = _mock_dataset(id=1)
# Create 12 filters
@@ -517,20 +546,15 @@ class TestGenerateExploreLink:
assert result.data["error"] is None
assert (
result.data["url"]
== "http://localhost:9001/explore/?form_data_key=many_filters_key"
== "http://localhost:9001/explore/p/test_permalink_key/"
)
mock_create_form_data.assert_called_once()
@patch("superset.daos.dataset.DatasetDAO.find_by_id")
@patch(
"superset.mcp_service.commands.create_form_data.MCPCreateFormDataCommand.run"
)
@pytest.mark.asyncio
async def test_explore_link_url_format_consistency(
self, mock_create_form_data, mock_find_dataset, mcp_server
self, mock_find_dataset, mcp_server
):
"""Test that all generated URLs follow consistent format."""
mock_create_form_data.return_value = "consistency_test_key"
"""Test that all generated URLs follow consistent permalink format."""
mock_find_dataset.return_value = _mock_dataset(id=1)
configs = [
@@ -569,23 +593,19 @@ class TestGenerateExploreLink:
"generate_explore_link", {"request": request.model_dump()}
)
# All URLs should follow the same format
# All URLs should follow the same permalink format
assert (
result.data["url"]
== "http://localhost:9001/explore/?form_data_key=consistency_test_key"
== "http://localhost:9001/explore/p/test_permalink_key/"
)
assert result.data["error"] is None
@patch("superset.daos.dataset.DatasetDAO.find_by_id")
@patch(
"superset.mcp_service.commands.create_form_data.MCPCreateFormDataCommand.run"
)
@pytest.mark.asyncio
async def test_generate_explore_link_dataset_id_types(
self, mock_create_form_data, mock_find_dataset, mcp_server
self, mock_find_dataset, mcp_server
):
"""Test explore link generation with different dataset_id formats."""
mock_create_form_data.return_value = "dataset_test_key"
mock_find_dataset.return_value = _mock_dataset(id=1)
config = TableChartConfig(
@@ -604,19 +624,15 @@ class TestGenerateExploreLink:
assert result.data["error"] is None
assert (
result.data["url"]
== "http://localhost:9001/explore/?form_data_key=dataset_test_key"
== "http://localhost:9001/explore/p/test_permalink_key/"
)
@patch("superset.daos.dataset.DatasetDAO.find_by_id")
@patch(
"superset.mcp_service.commands.create_form_data.MCPCreateFormDataCommand.run"
)
@pytest.mark.asyncio
async def test_generate_explore_link_complex_configuration(
self, mock_create_form_data, mock_find_dataset, mcp_server
self, mock_find_dataset, mcp_server
):
"""Test explore link generation with complex chart configuration."""
mock_create_form_data.return_value = "complex_config_key"
mock_find_dataset.return_value = _mock_dataset(id=10)
config = XYChartConfig(
@@ -648,22 +664,30 @@ class TestGenerateExploreLink:
assert result.data["error"] is None
assert (
result.data["url"]
== "http://localhost:9001/explore/?form_data_key=complex_config_key"
== "http://localhost:9001/explore/p/test_permalink_key/"
)
mock_create_form_data.assert_called_once()
@patch(_PERMALINK_PATCH)
@patch("superset.daos.dataset.DatasetDAO.find_by_id")
@patch(
"superset.mcp_service.commands.create_form_data.MCPCreateFormDataCommand.run"
)
@patch(_FORM_DATA_PATCH)
@pytest.mark.asyncio
async def test_fallback_url_different_datasets(
self, mock_create_form_data, mock_find_dataset, mcp_server
self,
mock_create_form_data,
mock_find_dataset,
mock_create_permalink,
mcp_server,
):
"""Test fallback URLs are correct for different dataset IDs."""
mock_find_dataset.return_value = _mock_dataset(id=1)
"""When both fallbacks fail, basic URL uses the correct dataset_id."""
from superset.commands.exceptions import CommandException
from superset.explore.permalink.exceptions import (
ExplorePermalinkCreateFailedError,
)
mock_find_dataset.return_value = _mock_dataset(id=1)
mock_create_permalink.side_effect = ExplorePermalinkCreateFailedError(
"Always fail for fallback testing"
)
mock_create_form_data.side_effect = CommandException(
"Always fail for fallback testing"
)
@@ -680,7 +704,10 @@ class TestGenerateExploreLink:
)
# Should fallback to basic URL with correct dataset_id
expected_url = f"http://localhost:9001/explore/?datasource_type=table&datasource_id={dataset_id}"
expected_url = (
f"http://localhost:9001/explore/?datasource_type=table"
f"&datasource_id={dataset_id}"
)
assert result.data["error"] is None
assert result.data["url"] == expected_url
@@ -720,22 +747,21 @@ class TestGenerateExploreLink:
assert result.data["url"] == ""
assert result.data["form_data"] == {}
assert result.data["form_data_key"] is None
assert result.data["permalink_key"] is None
assert result.data["chart_type_label"] is None
assert "Invalid config structure" in result.data["error"]
finally:
# Restore original function
explore_module.map_config_to_form_data = original_func
@patch(_PERMALINK_PATCH)
@patch("superset.daos.dataset.DatasetDAO.find_by_id")
@patch(
"superset.mcp_service.commands.create_form_data.MCPCreateFormDataCommand.run"
)
@pytest.mark.asyncio
async def test_generate_explore_link_returns_form_data_key(
self, mock_create_form_data, mock_find_dataset, mcp_server
async def test_generate_explore_link_returns_permalink_key(
self, mock_find_dataset, mock_create_permalink, mcp_server
):
"""Test that form_data_key is properly extracted from URL."""
mock_create_form_data.return_value = "extracted_form_key_xyz"
"""Test that permalink_key is properly extracted from the durable URL."""
mock_create_permalink.return_value = "extracted_permalink_xyz"
mock_find_dataset.return_value = _mock_dataset(id=1)
config = TableChartConfig(
@@ -749,19 +775,19 @@ class TestGenerateExploreLink:
)
assert result.data["error"] is None
assert result.data["form_data_key"] == "extracted_form_key_xyz"
assert "form_data_key=extracted_form_key_xyz" in result.data["url"]
assert result.data["permalink_key"] == "extracted_permalink_xyz"
assert result.data["form_data_key"] is None
assert "extracted_permalink_xyz" in result.data["url"]
assert result.data["url"] == (
"http://localhost:9001/explore/p/extracted_permalink_xyz/"
)
@patch("superset.daos.dataset.DatasetDAO.find_by_id")
@patch(
"superset.mcp_service.commands.create_form_data.MCPCreateFormDataCommand.run"
)
@pytest.mark.asyncio
async def test_generate_explore_link_returns_form_data(
self, mock_create_form_data, mock_find_dataset, mcp_server
self, mock_find_dataset, mcp_server
):
"""Test that form_data dict is returned for external rendering."""
mock_create_form_data.return_value = "form_data_test_key"
mock_find_dataset.return_value = _mock_dataset(id=1)
config = XYChartConfig(
@@ -806,6 +832,7 @@ class TestGenerateExploreLink:
assert result.data["url"] == ""
assert result.data["form_data"] == {}
assert result.data["form_data_key"] is None
assert result.data["permalink_key"] is None
assert result.data["chart_type_label"] is None
assert "Dataset not found: 99999" in result.data["error"]
assert "list_datasets" in result.data["error"]
@@ -833,6 +860,7 @@ class TestGenerateExploreLink:
)
assert result.data["form_data"] == {}
assert result.data["form_data_key"] is None
assert result.data["permalink_key"] is None
assert result.data["chart_type_label"] is None
@patch("superset.daos.dataset.DatasetDAO.find_by_id")
@@ -853,6 +881,7 @@ class TestGenerateExploreLink:
assert result.data["url"] == ""
assert result.data["form_data"] == {}
assert result.data["form_data_key"] is None
assert result.data["permalink_key"] is None
assert result.data["chart_type_label"] is None
assert "Dataset not found: 99999" in result.data["error"]
@@ -879,6 +908,7 @@ class TestGenerateExploreLink:
assert result.data["url"] == ""
assert result.data["form_data"] == {}
assert result.data["form_data_key"] is None
assert result.data["permalink_key"] is None
assert result.data["chart_type_label"] is None
assert "Dataset not found" in result.data["error"]
@@ -895,19 +925,14 @@ class TestGenerateExploreLinkColumnNormalization:
"superset.mcp_service.chart.validation.dataset_validator.DatasetValidator._get_dataset_context"
)
@patch("superset.daos.dataset.DatasetDAO.find_by_id")
@patch(
"superset.mcp_service.commands.create_form_data.MCPCreateFormDataCommand.run"
)
@pytest.mark.asyncio
async def test_xy_chart_x_axis_normalized_in_form_data(
self,
mock_create_form_data,
mock_find_dataset,
mock_get_context,
mcp_server,
):
"""x-axis column name in wrong case is normalized in form_data."""
mock_create_form_data.return_value = "norm_test_key_1"
mock_find_dataset.return_value = _mock_dataset(id=18)
mock_get_context.return_value = DatasetContext(
id=18,
@@ -942,19 +967,14 @@ class TestGenerateExploreLinkColumnNormalization:
"superset.mcp_service.chart.validation.dataset_validator.DatasetValidator._get_dataset_context"
)
@patch("superset.daos.dataset.DatasetDAO.find_by_id")
@patch(
"superset.mcp_service.commands.create_form_data.MCPCreateFormDataCommand.run"
)
@pytest.mark.asyncio
async def test_filter_column_normalized_in_form_data(
self,
mock_create_form_data,
mock_find_dataset,
mock_get_context,
mcp_server,
):
"""Filter column name in wrong case is normalized in adhoc_filters."""
mock_create_form_data.return_value = "norm_test_key_2"
mock_find_dataset.return_value = _mock_dataset(id=18)
mock_get_context.return_value = DatasetContext(
id=18,
@@ -1001,19 +1021,14 @@ class TestGenerateExploreLinkColumnNormalization:
"superset.mcp_service.chart.validation.dataset_validator.DatasetValidator._get_dataset_context"
)
@patch("superset.daos.dataset.DatasetDAO.find_by_id")
@patch(
"superset.mcp_service.commands.create_form_data.MCPCreateFormDataCommand.run"
)
@pytest.mark.asyncio
async def test_normalization_fallback_when_dataset_not_found(
self,
mock_create_form_data,
mock_find_dataset,
mock_get_context,
mcp_server,
):
"""When dataset context is unavailable, original names pass through."""
mock_create_form_data.return_value = "norm_test_key_3"
mock_find_dataset.return_value = _mock_dataset(id=99)
mock_get_context.return_value = None
@@ -1046,21 +1061,19 @@ class TestGenerateExploreLinkValidation:
"""
return
@patch(_PERMALINK_PATCH)
@patch.object(generate_explore_link_module, "validate_and_compile")
@patch("superset.daos.dataset.DatasetDAO.find_by_id")
@patch(
"superset.mcp_service.commands.create_form_data.MCPCreateFormDataCommand.run"
)
@pytest.mark.asyncio
async def test_validation_failure_returns_structured_error(
self,
mock_create_form_data,
mock_find_dataset,
mock_validate,
mock_create_permalink,
mcp_server,
):
"""Non-existent column → structured ChartGenerationError with suggestions,
and MCPCreateFormDataCommand must NOT be called (no cache write)."""
and CreateExplorePermalinkCommand must NOT be called (no cache write)."""
from superset.mcp_service.chart.compile import CompileResult
from superset.mcp_service.common.error_schemas import ChartGenerationError
@@ -1094,29 +1107,28 @@ class TestGenerateExploreLinkValidation:
assert result.data["url"] == ""
assert result.data["form_data_key"] is None
assert result.data["permalink_key"] is None
assert result.data["chart_type_label"] is None
error = result.data["error"]
assert isinstance(error, dict)
assert error["error_code"] == "CHART_VALIDATION_FAILED"
assert "sum_boys" in error["suggestions"]
mock_create_form_data.assert_not_called()
mock_create_permalink.assert_not_called()
@patch(_PERMALINK_PATCH)
@patch.object(
generate_explore_link_module, "has_dataset_access", return_value=False
)
@patch("superset.daos.dataset.DatasetDAO.find_by_id")
@patch(
"superset.mcp_service.commands.create_form_data.MCPCreateFormDataCommand.run"
)
@pytest.mark.asyncio
async def test_dataset_access_denied_short_circuits(
self,
mock_create_form_data,
mock_find_dataset,
unused_access_mock,
mock_create_permalink,
mcp_server,
):
"""has_dataset_access=False blocks the tool before any cache write."""
"""has_dataset_access=False blocks the tool before any permalink write."""
mock_find_dataset.return_value = _mock_dataset(id=3)
config = TableChartConfig(
@@ -1133,4 +1145,4 @@ class TestGenerateExploreLinkValidation:
assert result.data["chart_type_label"] is None
# Surface as "not found" rather than leaking that the dataset exists.
assert "Dataset not found" in result.data["error"]
mock_create_form_data.assert_not_called()
mock_create_permalink.assert_not_called()

View File

@@ -0,0 +1,46 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from superset.mcp_service.utils.url_utils import extract_permalink_key_from_url
def test_extract_permalink_key_from_url_with_trailing_slash():
url = "http://localhost:8088/explore/p/abc123/"
assert extract_permalink_key_from_url(url) == "abc123"
def test_extract_permalink_key_from_url_without_trailing_slash():
url = "http://localhost:8088/explore/p/abc123"
assert extract_permalink_key_from_url(url) == "abc123"
def test_extract_permalink_key_from_url_no_match():
url = "http://localhost:8088/explore/?form_data_key=abc123"
assert extract_permalink_key_from_url(url) is None
def test_extract_permalink_key_from_url_none():
assert extract_permalink_key_from_url(None) is None
def test_extract_permalink_key_from_url_empty():
assert extract_permalink_key_from_url("") is None
def test_extract_permalink_key_from_url_with_path_prefix():
url = "https://example.com/superset/explore/p/xyz789/"
assert extract_permalink_key_from_url(url) == "xyz789"