Set query_context on chart creation
diff --git a/superset/charts/data/api.py b/superset/charts/data/api.py index b1d8305..34d3fca 100644 --- a/superset/charts/data/api.py +++ b/superset/charts/data/api.py
@@ -37,6 +37,7 @@ ) from superset.charts.data.query_context_cache_loader import QueryContextCacheLoader from superset.charts.data.query_context_sidecar import ( + DEFAULT_QUERY_CONTEXT_SIDECAR_TIMEOUT, fetch_query_context_from_sidecar, QueryContextSidecarError, ) @@ -78,7 +79,6 @@ logger = logging.getLogger(__name__) -DEFAULT_QUERY_CONTEXT_SIDECAR_TIMEOUT = 30 MISSING_QUERY_CONTEXT_MESSAGE = ( "Chart has no query context saved. Please save the chart again." )
diff --git a/superset/charts/data/query_context_sidecar.py b/superset/charts/data/query_context_sidecar.py index c01e44e..e88ddcb 100644 --- a/superset/charts/data/query_context_sidecar.py +++ b/superset/charts/data/query_context_sidecar.py
@@ -16,15 +16,64 @@ # under the License. from __future__ import annotations +import logging from typing import Any import requests +from flask import current_app as app + +from superset.utils import json + +logger = logging.getLogger(__name__) + +DEFAULT_QUERY_CONTEXT_SIDECAR_TIMEOUT = 30 class QueryContextSidecarError(Exception): """Raised when query context cannot be generated via sidecar.""" +def maybe_generate_query_context(model: Any, params_json: str | None) -> None: + """Best-effort generation of query_context via the sidecar service. + + Sets ``model.query_context`` on success. Failures are logged but never + re-raised so chart saves are not blocked. + """ + sidecar_url = app.config.get("QUERY_CONTEXT_SIDECAR_URL") + if not sidecar_url or not params_json: + return + + try: + form_data = json.loads(params_json) + except (TypeError, json.JSONDecodeError): + logger.warning("Could not parse chart params for sidecar query context") + return + + timeout = app.config.get( + "QUERY_CONTEXT_SIDECAR_TIMEOUT", + DEFAULT_QUERY_CONTEXT_SIDECAR_TIMEOUT, + ) + + try: + result = fetch_query_context_from_sidecar( + sidecar_url=sidecar_url, + form_data=form_data, + timeout=timeout, + ) + model.query_context = json.dumps(result) + except QueryContextSidecarError: + logger.warning( + "Failed to generate query context via sidecar for chart %s", + getattr(model, "id", "?"), + ) + except Exception: + logger.warning( + "Unexpected error generating query context via sidecar for chart %s", + getattr(model, "id", "?"), + exc_info=True, + ) + + def fetch_query_context_from_sidecar( *, sidecar_url: str,
diff --git a/superset/commands/chart/create.py b/superset/commands/chart/create.py index 84b3aa2..0f4201a 100644 --- a/superset/commands/chart/create.py +++ b/superset/commands/chart/create.py
@@ -24,6 +24,7 @@ from marshmallow import ValidationError from superset import security_manager +from superset.charts.data.query_context_sidecar import maybe_generate_query_context from superset.commands.base import BaseCommand, CreateMixin from superset.commands.chart.exceptions import ( ChartCreateFailedError, @@ -48,7 +49,12 @@ self.validate() self._properties["last_saved_at"] = datetime.now() self._properties["last_saved_by"] = g.user - return ChartDAO.create(attributes=self._properties) + chart = ChartDAO.create(attributes=self._properties) + + if not self._properties.get("query_context"): + maybe_generate_query_context(chart, self._properties.get("params")) + + return chart def validate(self) -> None: exceptions = []
diff --git a/superset/commands/chart/update.py b/superset/commands/chart/update.py index 25ca253..a1dbe1c 100644 --- a/superset/commands/chart/update.py +++ b/superset/commands/chart/update.py
@@ -24,6 +24,7 @@ from marshmallow import ValidationError from superset import security_manager +from superset.charts.data.query_context_sidecar import maybe_generate_query_context from superset.commands.base import BaseCommand, UpdateMixin from superset.commands.chart.exceptions import ( ChartForbiddenError, @@ -70,7 +71,16 @@ self._properties["last_saved_at"] = datetime.now() self._properties["last_saved_by"] = g.user - return ChartDAO.update(self._model, self._properties) + chart = ChartDAO.update(self._model, self._properties) + + if ( + "params" in self._properties + and not self._properties.get("query_context") + and not self._properties.get("query_context_generation") + ): + maybe_generate_query_context(chart, self._properties["params"]) + + return chart def _validate_new_dashboard_access( self, requested_dashboards: list[Dashboard], exceptions: list[Exception]
diff --git a/tests/unit_tests/charts/test_query_context_sidecar.py b/tests/unit_tests/charts/test_query_context_sidecar.py index a2b836c..4ea0f59 100644 --- a/tests/unit_tests/charts/test_query_context_sidecar.py +++ b/tests/unit_tests/charts/test_query_context_sidecar.py
@@ -16,6 +16,7 @@ # under the License. from __future__ import annotations +from typing import Any from unittest import mock import pytest @@ -23,6 +24,7 @@ from superset.charts.data.query_context_sidecar import ( fetch_query_context_from_sidecar, + maybe_generate_query_context, QueryContextSidecarError, ) @@ -85,3 +87,128 @@ form_data={"viz_type": "pie"}, timeout=15, ) + + +# --------------------------------------------------------------------------- +# Tests for maybe_generate_query_context +# --------------------------------------------------------------------------- + + +class _FakeApp: + """Minimal stand-in for the Flask app proxy used by the sidecar module.""" + + def __init__(self, config: dict[str, Any] | None = None) -> None: + self.config = config or {} + + +@mock.patch( + "superset.charts.data.query_context_sidecar.app", + new=_FakeApp({}), +) +def test_maybe_generate_noop_when_no_sidecar_url() -> None: + model = mock.MagicMock() + maybe_generate_query_context(model, '{"viz_type": "pie"}') + + +@mock.patch( + "superset.charts.data.query_context_sidecar.app", + new=_FakeApp({"QUERY_CONTEXT_SIDECAR_URL": "http://sidecar.internal"}), +) +def test_maybe_generate_noop_when_params_json_is_none() -> None: + model = mock.MagicMock() + maybe_generate_query_context(model, None) + + +@mock.patch( + "superset.charts.data.query_context_sidecar.fetch_query_context_from_sidecar" +) +@mock.patch( + "superset.charts.data.query_context_sidecar.app", + new=_FakeApp( + { + "QUERY_CONTEXT_SIDECAR_URL": "http://sidecar.internal", + "QUERY_CONTEXT_SIDECAR_TIMEOUT": 10, + } + ), +) +def test_maybe_generate_sets_query_context_on_success( + mock_fetch: mock.MagicMock, +) -> None: + mock_fetch.return_value = {"datasource": {"id": 1}, "queries": []} + model = mock.MagicMock() + + maybe_generate_query_context(model, '{"viz_type": "pie"}') + + mock_fetch.assert_called_once_with( + sidecar_url="http://sidecar.internal", + form_data={"viz_type": "pie"}, + timeout=10, + ) + assert model.query_context is not None + + +@mock.patch( + "superset.charts.data.query_context_sidecar.fetch_query_context_from_sidecar" +) +@mock.patch( + "superset.charts.data.query_context_sidecar.app", + new=_FakeApp( + { + "QUERY_CONTEXT_SIDECAR_URL": "http://sidecar.internal", + "QUERY_CONTEXT_SIDECAR_TIMEOUT": 10, + } + ), +) +def test_maybe_generate_logs_on_sidecar_error( + mock_fetch: mock.MagicMock, + caplog: pytest.LogCaptureFixture, +) -> None: + mock_fetch.side_effect = QueryContextSidecarError("boom") + model = mock.MagicMock() + model.id = 42 + + with caplog.at_level("WARNING"): + maybe_generate_query_context(model, '{"viz_type": "pie"}') + + assert "Failed to generate query context" in caplog.text + + +@mock.patch( + "superset.charts.data.query_context_sidecar.app", + new=_FakeApp({"QUERY_CONTEXT_SIDECAR_URL": "http://sidecar.internal"}), +) +def test_maybe_generate_logs_on_invalid_json( + caplog: pytest.LogCaptureFixture, +) -> None: + model = mock.MagicMock() + + with caplog.at_level("WARNING"): + maybe_generate_query_context(model, "not-valid-json{{{") + + assert "Could not parse chart params" in caplog.text + + +@mock.patch( + "superset.charts.data.query_context_sidecar.fetch_query_context_from_sidecar" +) +@mock.patch( + "superset.charts.data.query_context_sidecar.app", + new=_FakeApp( + { + "QUERY_CONTEXT_SIDECAR_URL": "http://sidecar.internal", + "QUERY_CONTEXT_SIDECAR_TIMEOUT": 10, + } + ), +) +def test_maybe_generate_logs_on_unexpected_error( + mock_fetch: mock.MagicMock, + caplog: pytest.LogCaptureFixture, +) -> None: + mock_fetch.side_effect = RuntimeError("unexpected") + model = mock.MagicMock() + model.id = 99 + + with caplog.at_level("WARNING"): + maybe_generate_query_context(model, '{"viz_type": "pie"}') + + assert "Unexpected error" in caplog.text