Compare commits

...

2 Commits

Author SHA1 Message Date
Mehmet Salih Yavuz
3e2e3da56e fix(mcp): patch event_logger via the module object, not a dotted string
The `tool/__init__.py` re-exports the `get_chart_data` function from its
submodule of the same name, which shadows the module binding in the
`tool` package namespace. mock.patch walks the dotted string by
getattr()-ing the package namespace, so
`superset.mcp_service.chart.tool.get_chart_data.event_logger` resolves
`get_chart_data` to the function, then raises AttributeError on
`event_logger`, then falls back to `__import__` and fails with
ModuleNotFoundError. Obtain the actual module via importlib and
patch.object the attribute directly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 13:34:21 +03:00
Mehmet Salih Yavuz
b9faefb558 fix(mcp): eager-load dataset.metrics to prevent Excel export DetachedInstanceError
get_chart_data with format=excel could fail with
"Parent instance SqlaTable is not bound to a Session; lazy load operation
of attribute metrics cannot proceed" when the dataset's metrics relationship
was accessed after the request-scoped session detached. Pass a
subqueryload(Slice.table -> SqlaTable.metrics) query option on ChartDAO
lookup so metrics are materialized up front and later detachment is harmless.

Mirrors the eager-loading pattern from #39206.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 12:41:47 +03:00
2 changed files with 170 additions and 3 deletions

View File

@@ -25,6 +25,7 @@ from typing import Any, Dict, List, TYPE_CHECKING
from fastmcp import Context
from flask import current_app
from sqlalchemy.orm import subqueryload
from superset_core.mcp.decorators import tool, ToolAnnotations
if TYPE_CHECKING:
@@ -164,7 +165,18 @@ async def get_chart_data( # noqa: C901
# Build query context entirely from cached form_data
return await _query_from_form_data(cached_form_data_dict, request, ctx)
# Find the chart by identifier
# Find the chart by identifier.
# Eagerly load the dataset's metrics relationship so Excel export
# (which may run after the request-scoped session is detached) can
# access dataset.metrics without triggering a lazy load. See
# apache/superset#39206 for the analogous database eager-load fix.
from superset.connectors.sqla.models import SqlaTable
from superset.models.slice import Slice
chart_query_options = [
subqueryload(Slice.table).subqueryload(SqlaTable.metrics),
]
with event_logger.log_context(action="mcp.get_chart_data.chart_lookup"):
if isinstance(request.identifier, int) or (
isinstance(request.identifier, str) and request.identifier.isdigit()
@@ -177,14 +189,18 @@ async def get_chart_data( # noqa: C901
await ctx.debug(
"Performing ID-based chart lookup: chart_id=%s" % (chart_id,)
)
chart = ChartDAO.find_by_id(chart_id)
chart = ChartDAO.find_by_id(chart_id, query_options=chart_query_options)
elif isinstance(request.identifier, str):
await ctx.debug(
"Performing UUID-based chart lookup: uuid=%s"
% (request.identifier,)
)
# Try UUID lookup using DAO flexible method
chart = ChartDAO.find_by_id(request.identifier, id_column="uuid")
chart = ChartDAO.find_by_id(
request.identifier,
id_column="uuid",
query_options=chart_query_options,
)
if not chart:
await ctx.error("Chart not found: identifier=%s" % (request.identifier,))

View File

@@ -858,3 +858,154 @@ class TestChartDataCommandValidation:
)
mock_command.run.assert_not_called()
@pytest.fixture
def mcp_server():
from superset.mcp_service.app import mcp
return mcp
@pytest.fixture
def mock_auth():
"""Mock MCP auth so Client.call_tool() doesn't need a real admin user."""
import importlib
from contextlib import contextmanager
from unittest.mock import Mock, patch
_gcd_module = importlib.import_module(
"superset.mcp_service.chart.tool.get_chart_data"
)
@contextmanager
def _noop_log_context(*_args: Any, **_kwargs: Any) -> Any:
yield lambda **_kw: None
# Neutralize event_logger.log_context: the default DBEventLogger would
# otherwise insert a log row referencing our mock user_id and fail a
# FK constraint against the real users table. Patch via the module
# object directly — the `tool` package's __init__.py re-exports the
# get_chart_data function under the same name, which shadows the
# submodule binding in the package namespace, so a dotted-string patch
# target resolves to the function and mock.patch cannot find
# event_logger on it.
mock_event_logger = Mock()
mock_event_logger.log_context.side_effect = _noop_log_context
with (
patch("superset.mcp_service.auth.get_user_from_request") as mock_get_user,
patch.object(_gcd_module, "event_logger", mock_event_logger),
):
user = Mock()
user.id = 1
user.username = "admin"
mock_get_user.return_value = user
yield mock_get_user
def _extract_metrics_load_path(load_opt: Any) -> list[str]:
"""Walk a SQLAlchemy Load option and return the attr chain.
e.g. subqueryload(Slice.table).subqueryload(SqlaTable.metrics)
-> ["table", "metrics"]
"""
path = getattr(load_opt, "path", ())
return [elem.key for elem in path if hasattr(elem, "key")]
class TestChartLookupEagerLoading:
"""Tests that get_chart_data eager-loads dataset.metrics on chart lookup.
Regression tests for the Excel export DetachedInstanceError on
dataset.metrics. The chart's dataset metrics relationship must be
eager-loaded at fetch time so it remains accessible during Excel export
after the request-scoped session is detached.
"""
@pytest.mark.asyncio
async def test_numeric_id_lookup_passes_metrics_eager_load(
self, mcp_server, mock_auth
):
"""Integer identifier lookup must eager-load Slice.table.metrics."""
from unittest.mock import patch
from fastmcp import Client
with patch(
"superset.daos.chart.ChartDAO.find_by_id", return_value=None
) as mock_find:
async with Client(mcp_server) as client:
await client.call_tool(
"get_chart_data",
{"request": {"identifier": 42, "format": "excel"}},
)
mock_find.assert_called_once()
call = mock_find.call_args
assert call.args == (42,)
query_options = call.kwargs.get("query_options")
assert query_options is not None, (
"Chart lookup must pass query_options for eager-loading."
)
assert len(query_options) == 1
load_path = _extract_metrics_load_path(query_options[0])
assert load_path == ["table", "metrics"], (
f"Expected subqueryload chain 'table' -> 'metrics', got {load_path}"
)
@pytest.mark.asyncio
async def test_uuid_lookup_passes_metrics_eager_load(self, mcp_server, mock_auth):
"""UUID identifier lookup must also eager-load Slice.table.metrics."""
from unittest.mock import patch
from fastmcp import Client
uuid = "a1b2c3d4-5678-90ab-cdef-1234567890ab"
with patch(
"superset.daos.chart.ChartDAO.find_by_id", return_value=None
) as mock_find:
async with Client(mcp_server) as client:
await client.call_tool(
"get_chart_data",
{"request": {"identifier": uuid, "format": "excel"}},
)
mock_find.assert_called_once()
call = mock_find.call_args
assert call.args == (uuid,)
assert call.kwargs.get("id_column") == "uuid"
query_options = call.kwargs.get("query_options")
assert query_options is not None, (
"UUID chart lookup must pass query_options for eager-loading."
)
load_path = _extract_metrics_load_path(query_options[0])
assert load_path == ["table", "metrics"], (
f"Expected subqueryload chain 'table' -> 'metrics', got {load_path}"
)
@pytest.mark.asyncio
async def test_json_format_also_eager_loads_metrics(self, mcp_server, mock_auth):
"""Eager-load is applied for every format, not just Excel.
Applying unconditionally keeps the fix robust if additional code paths
start touching dataset.metrics, and avoids branching behavior that
would be easy to regress on.
"""
from unittest.mock import patch
from fastmcp import Client
with patch(
"superset.daos.chart.ChartDAO.find_by_id", return_value=None
) as mock_find:
async with Client(mcp_server) as client:
await client.call_tool(
"get_chart_data",
{"request": {"identifier": 7, "format": "json"}},
)
call = mock_find.call_args
query_options = call.kwargs.get("query_options")
assert query_options is not None
assert _extract_metrics_load_path(query_options[0]) == ["table", "metrics"]