diff --git a/superset/sql/parse.py b/superset/sql/parse.py index c4983b6d3ef..4c2be71897e 100644 --- a/superset/sql/parse.py +++ b/superset/sql/parse.py @@ -589,6 +589,11 @@ class SQLStatement(BaseSQLStatement[exp.Expression]): # and the script contains backticks, retry with MySQL dialect which # supports backtick-quoted identifiers if (dialect is None or dialect == Dialects.DIALECT) and "`" in script: + logger.warning( + "Parsing with base dialect failed for engine %r; " + "script contains backticks, falling back to MySQL dialect", + engine, + ) try: statements = sqlglot.parse(script, dialect=Dialects.MYSQL) except sqlglot.errors.ParseError: diff --git a/superset/views/api.py b/superset/views/api.py index 1b2163d6437..c24a102d65e 100644 --- a/superset/views/api.py +++ b/superset/views/api.py @@ -73,7 +73,7 @@ class Api(BaseSupersetView): query_context.raise_for_access() result = query_context.get_payload() payload_json = result["queries"] - return json.dumps(payload_json, default=json.json_int_dttm_ser, ignore_nan=True) + return self.json_response(payload_json) @event_logger.log_this @api diff --git a/tests/unit_tests/sql/parse_tests.py b/tests/unit_tests/sql/parse_tests.py index b6d0c558053..0b83dd71dbd 100644 --- a/tests/unit_tests/sql/parse_tests.py +++ b/tests/unit_tests/sql/parse_tests.py @@ -17,6 +17,8 @@ # pylint: disable=invalid-name, redefined-outer-name, too-many-lines +import logging + import pytest from pytest_mock import MockerFixture from sqlglot import Dialects, exp, parse_one @@ -3311,3 +3313,21 @@ def test_backtick_invalid_sql_still_fails() -> None: sql = "SELECT * FROM `table` WHERE" with pytest.raises(SupersetParseError): SQLScript(sql, "base") + + +def test_backtick_fallback_logs_warning(caplog: pytest.LogCaptureFixture) -> None: + """ + Test that the MySQL dialect fallback emits a warning log. + + When the base dialect fails to parse SQL containing backticks and the + parser falls back to the MySQL dialect, the fallback should be observable + via a warning log. + """ + sql = "SELECT * FROM `my_table`" + with caplog.at_level(logging.WARNING, logger="superset.sql.parse"): + SQLScript(sql, "base") + + assert any( + record.levelname == "WARNING" and "MySQL dialect" in record.getMessage() + for record in caplog.records + ) diff --git a/tests/unit_tests/views/test_base.py b/tests/unit_tests/views/test_base.py index b047f25d027..1d40b06fe93 100644 --- a/tests/unit_tests/views/test_base.py +++ b/tests/unit_tests/views/test_base.py @@ -137,3 +137,37 @@ def test_locale_language_extraction_preserves_region_when_configured( "de": {}, } assert _extract_language(locale_str, languages) == expected_language + + +def test_api_query_returns_json_content_type() -> None: + """``Api.query`` returns a response with a JSON content type. + + The handler should use ``json_response`` (like its ``query_form_data`` and + ``time_range`` siblings) so the ``Content-Type`` header is set consistently + instead of returning a raw JSON string. + """ + from flask import current_app + + from superset.views.api import Api + + # Unwrap the decorator stack (event logger, auth, etc.) to exercise the + # handler body directly without app/DB auth context. + handler = Api.query + while hasattr(handler, "__wrapped__"): + handler = handler.__wrapped__ + + query_context = MagicMock() + query_context.get_payload.return_value = {"queries": [{"data": [{"a": 1}]}]} + factory = MagicMock() + factory.create.return_value = query_context + + api_view = Api() + + with patch.object(api_view, "get_query_context_factory", return_value=factory): + with current_app.test_request_context( + data={"query_context": '{"datasource": {"id": 1}}'} + ): + response = handler(api_view) + + assert response.mimetype == "application/json" + assert response.content_type.startswith("application/json")