This is an automated email from the ASF dual-hosted git repository.
msyavuz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new cedc35e39f fix(SQLLab): remove error icon displayed when writing Jinja
SQL even when the script is correct (#36422)
cedc35e39f is described below
commit cedc35e39f5b758e35ccb661de425c775bd8792a
Author: Felipe López <[email protected]>
AuthorDate: Tue Jan 6 10:58:40 2026 -0300
fix(SQLLab): remove error icon displayed when writing Jinja SQL even when
the script is correct (#36422)
---
superset/commands/database/validate_sql.py | 44 ++++
tests/integration_tests/databases/api_tests.py | 109 ++++++++
.../commands/databases/validate_sql_test.py | 279 +++++++++++++++++++++
3 files changed, 432 insertions(+)
diff --git a/superset/commands/database/validate_sql.py
b/superset/commands/database/validate_sql.py
index c252cf6fdb..d7ab40f882 100644
--- a/superset/commands/database/validate_sql.py
+++ b/superset/commands/database/validate_sql.py
@@ -32,6 +32,11 @@ from superset.commands.database.exceptions import (
)
from superset.daos.database import DatabaseDAO
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
+from superset.exceptions import (
+ SupersetSyntaxErrorException,
+ SupersetTemplateException,
+)
+from superset.jinja_context import get_template_processor
from superset.models.core import Database
from superset.sql_validators import get_validator_by_name
from superset.sql_validators.base import BaseSQLValidator
@@ -62,12 +67,51 @@ class ValidateSQLCommand(BaseCommand):
sql = self._properties["sql"]
catalog = self._properties.get("catalog")
schema = self._properties.get("schema")
+ template_params = self._properties.get("template_params") or {}
+
try:
+ # Render Jinja templates to handle template syntax before
+ # validation. Note: The ENABLE_TEMPLATE_PROCESSING feature flag is
+ # checked within get_template_processor(), which returns
+ # NoOpTemplateProcessor when disabled. Template processing errors
+ # (e.g., undefined filters, syntax errors) are caught by this
+ # exception handler and surfaced to the client as
+ # ValidatorSQLError or ValidatorSQL400Error with appropriate error
+ # messages.
+ template_processor = get_template_processor(self._model)
+ # process_template() renders Jinja2 templates and always returns a
+ # new string (does not mutate the input SQL). May raise
+ # SupersetSyntaxErrorException for template syntax errors or
+ # SupersetTemplateException for internal errors.
+ sql = template_processor.process_template(sql, **template_params)
+
timeout = app.config["SQLLAB_VALIDATION_TIMEOUT"]
timeout_msg = f"The query exceeded the {timeout} seconds timeout."
with utils.timeout(seconds=timeout, error_message=timeout_msg):
errors = self._validator.validate(sql, catalog, schema,
self._model)
return [err.to_dict() for err in errors]
+ except SupersetSyntaxErrorException as ex:
+ # Template syntax errors (e.g., invalid Jinja2 syntax, undefined
variables)
+ # These contain detailed error information including line numbers
+ logger.warning(
+ "Template syntax error during SQL validation",
+ extra={"errors": [err.message for err in ex.errors]},
+ )
+ raise ValidatorSQL400Error(ex.errors[0]) from ex
+ except SupersetTemplateException as ex:
+ # Internal template processing errors (e.g., recursion, unexpected
failures)
+ logger.error(
+ "Template processing error during SQL validation",
exc_info=True
+ )
+ superset_error = SupersetError(
+ message=__(
+ "Template processing failed: %(ex)s",
+ ex=str(ex),
+ ),
+ error_type=SupersetErrorType.GENERIC_COMMAND_ERROR,
+ level=ErrorLevel.ERROR,
+ )
+ raise ValidatorSQL400Error(superset_error) from ex
except Exception as ex:
logger.exception(ex)
superset_error = SupersetError(
diff --git a/tests/integration_tests/databases/api_tests.py
b/tests/integration_tests/databases/api_tests.py
index cfad961589..94b24896a5 100644
--- a/tests/integration_tests/databases/api_tests.py
+++ b/tests/integration_tests/databases/api_tests.py
@@ -4104,6 +4104,115 @@ class TestDatabaseApi(SupersetTestCase):
assert rv.status_code == 422
assert "Kaboom!" in response["errors"][0]["message"]
+ @mock.patch.dict(
+ "superset.config.SQL_VALIDATORS_BY_ENGINE",
+ SQL_VALIDATORS_BY_ENGINE,
+ clear=True,
+ )
+ def test_validate_sql_with_jinja_templates(self):
+ """
+ Database API: validate SQL with Jinja templates
+ """
+ request_payload = {
+ "sql": (
+ "SELECT *\nFROM birth_names\nWHERE 1=1\n"
+ "{% if city_filter is defined %}\n"
+ " AND city = '{{ city_filter }}'\n{% endif %}\n"
+ "LIMIT {{ limit | default(100) }}"
+ ),
+ "schema": None,
+ "template_params": {},
+ }
+
+ example_db = get_example_database()
+ if example_db.backend not in ("presto", "postgresql"):
+ pytest.skip("Only presto and PG are implemented")
+
+ self.login(ADMIN_USERNAME)
+ uri = f"api/v1/database/{example_db.id}/validate_sql/"
+ rv = self.client.post(uri, json=request_payload)
+ response = json.loads(rv.data.decode("utf-8"))
+ assert rv.status_code == 200
+ # Template was successfully rendered and validated
+ # so a valid query returns an empty result list
+ result = response["result"]
+ assert isinstance(result, list)
+ assert len(result) == 0
+
+ @mock.patch.dict(
+ "superset.config.SQL_VALIDATORS_BY_ENGINE",
+ SQL_VALIDATORS_BY_ENGINE,
+ clear=True,
+ )
+ def test_validate_sql_with_jinja_templates_and_params(self):
+ """
+ Database API: validate SQL with Jinja templates and parameters
+ """
+ request_payload = {
+ "sql": (
+ "SELECT *\nFROM birth_names\nWHERE 1=1\n"
+ "{% if city_filter is defined %}\n"
+ " AND city = '{{ city_filter }}'\n"
+ "{% endif %}\nLIMIT {{ limit }}"
+ ),
+ "schema": None,
+ "template_params": {"city_filter": "New York", "limit": 50},
+ }
+
+ example_db = get_example_database()
+ if example_db.backend not in ("presto", "postgresql"):
+ pytest.skip("Only presto and PG are implemented")
+
+ self.login(ADMIN_USERNAME)
+ uri = f"api/v1/database/{example_db.id}/validate_sql/"
+ rv = self.client.post(uri, json=request_payload)
+ response = json.loads(rv.data.decode("utf-8"))
+ assert rv.status_code == 200
+ # Template was successfully rendered with parameters and validated
+ # so a valid query returns an empty result list
+ result = response["result"]
+ assert isinstance(result, list)
+ assert len(result) == 0
+
+ @mock.patch.dict(
+ "superset.config.SQL_VALIDATORS_BY_ENGINE",
+ SQL_VALIDATORS_BY_ENGINE,
+ clear=True,
+ )
+ def test_validate_sql_with_jinja_invalid_sql_after_render(self):
+ """
+ Database API: validate SQL with Jinja templates that renders to
invalid SQL
+
+ This test ensures that SQL validation errors are not hidden by template
+ processing. The template should render successfully, but the resulting
SQL
+ should fail syntax validation.
+ """
+ request_payload = {
+ "sql": (
+ "SELECT *\nFROM birth_names\n"
+ "{% if add_invalid_clause %}\n"
+ "WHERE\n"
+ "{% endif %}"
+ ),
+ "schema": None,
+ "template_params": {"add_invalid_clause": True},
+ }
+
+ example_db = get_example_database()
+ if example_db.backend not in ("presto", "postgresql"):
+ pytest.skip("Only presto and PG are implemented")
+
+ self.login(ADMIN_USERNAME)
+ uri = f"api/v1/database/{example_db.id}/validate_sql/"
+ rv = self.client.post(uri, json=request_payload)
+ response = json.loads(rv.data.decode("utf-8"))
+ assert rv.status_code == 200
+ # The template should render successfully, but SQL validation
+ # should catch the syntax error (WHERE clause with no condition)
+ result = response["result"]
+ assert isinstance(result, list)
+ assert len(result) > 0
+
def test_get_databases_with_extra_filters(self):
"""
API: Test get database with extra query filter.
diff --git a/tests/unit_tests/commands/databases/validate_sql_test.py
b/tests/unit_tests/commands/databases/validate_sql_test.py
new file mode 100644
index 0000000000..46df10cecf
--- /dev/null
+++ b/tests/unit_tests/commands/databases/validate_sql_test.py
@@ -0,0 +1,279 @@
+# 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 typing import Any
+from unittest.mock import MagicMock
+
+import pytest
+from pytest_mock import MockerFixture
+
+from superset.commands.database.exceptions import (
+ ValidatorSQL400Error,
+ ValidatorSQLError,
+)
+from superset.commands.database.validate_sql import ValidateSQLCommand
+from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
+from superset.exceptions import (
+ SupersetSyntaxErrorException,
+ SupersetTemplateException,
+)
+
+
[email protected]
+def mock_database(mocker: MockerFixture) -> MagicMock:
+ """Create a mock database with PostgreSQL engine."""
+ database = mocker.MagicMock()
+ database.id = 1
+ database.db_engine_spec.engine = "postgresql"
+
+ DatabaseDAO = mocker.patch( # noqa: N806
+ "superset.commands.database.validate_sql.DatabaseDAO"
+ )
+ DatabaseDAO.find_by_id.return_value = database
+ return database
+
+
[email protected]
+def mock_validator(mocker: MockerFixture) -> MagicMock:
+ """Create a mock SQL validator."""
+ validator = mocker.MagicMock()
+ validator.name = "PostgreSQLValidator"
+ validator.validate.return_value = []
+
+ get_validator_by_name = mocker.patch(
+ "superset.commands.database.validate_sql.get_validator_by_name"
+ )
+ get_validator_by_name.return_value = validator
+ return validator
+
+
[email protected]
+def mock_config(mocker: MockerFixture) -> dict[str, Any]:
+ """Mock the application config."""
+ config = {
+ "SQL_VALIDATORS_BY_ENGINE": {"postgresql": "PostgreSQLValidator"},
+ "SQLLAB_VALIDATION_TIMEOUT": 30,
+ }
+ mocker.patch("superset.commands.database.validate_sql.app.config", config)
+ return config
+
+
[email protected]
+def mock_template_processor(
+ mocker: MockerFixture, mock_database: MagicMock
+) -> MagicMock:
+ """Create a mock template processor."""
+ template_processor = mocker.MagicMock()
+ get_template_processor = mocker.patch(
+ "superset.commands.database.validate_sql.get_template_processor"
+ )
+ get_template_processor.return_value = template_processor
+ return template_processor
+
+
+def test_validate_sql_with_jinja_templates(
+ mock_database: MagicMock,
+ mock_validator: MagicMock,
+ mock_template_processor: MagicMock,
+ mock_config: dict[str, Any],
+) -> None:
+ """Test that Jinja templates are rendered before SQL validation."""
+ sql_with_jinja = """SELECT *
+ FROM birth_names
+ WHERE 1=1
+ {% if city_filter is defined %}
+ AND city = '{{ city_filter }}'
+ {% endif %}
+ LIMIT {{ limit | default(100) }}"""
+
+ mock_template_processor.process_template.return_value = (
+ "SELECT *\nFROM birth_names\nWHERE 1=1\nLIMIT 100"
+ )
+
+ data = {"sql": sql_with_jinja, "schema": "public", "template_params": {}}
+ command = ValidateSQLCommand(model_id=1, data=data)
+ result = command.run()
+
+
mock_template_processor.process_template.assert_called_once_with(sql_with_jinja)
+ mock_validator.validate.assert_called_once()
+ assert result == []
+
+
+def test_validate_sql_with_jinja_templates_and_params(
+ mock_database: MagicMock,
+ mock_validator: MagicMock,
+ mock_template_processor: MagicMock,
+ mock_config: dict[str, Any],
+) -> None:
+ """Test that Jinja templates are rendered with parameters before SQL
validation."""
+ sql_with_jinja = """SELECT *
+ FROM birth_names
+ WHERE 1=1
+ {% if city_filter is defined %}
+ AND city = '{{ city_filter }}'
+ {% endif %}
+ LIMIT {{ limit }}"""
+
+ template_params = {"city_filter": "New York", "limit": 50}
+ mock_template_processor.process_template.return_value = (
+ "SELECT *\nFROM birth_names\nWHERE 1=1\n AND city = 'New
York'\nLIMIT 50"
+ )
+
+ data = {
+ "sql": sql_with_jinja,
+ "schema": "public",
+ "template_params": template_params,
+ }
+ command = ValidateSQLCommand(model_id=1, data=data)
+ result = command.run()
+
+ mock_template_processor.process_template.assert_called_once_with(
+ sql_with_jinja, **template_params
+ )
+ mock_validator.validate.assert_called_once()
+ assert result == []
+
+
+def test_validate_sql_without_jinja_templates(
+ mock_database: MagicMock,
+ mock_validator: MagicMock,
+ mock_template_processor: MagicMock,
+ mock_config: dict[str, Any],
+) -> None:
+ """Test that regular SQL without Jinja templates still works."""
+ simple_sql = "SELECT * FROM birth_names LIMIT 100"
+ mock_template_processor.process_template.return_value = simple_sql
+
+ data = {"sql": simple_sql, "schema": "public", "template_params": {}}
+ command = ValidateSQLCommand(model_id=1, data=data)
+ result = command.run()
+
+ mock_template_processor.process_template.assert_called_once()
+ mock_validator.validate.assert_called_once()
+ assert result == []
+
+
+def test_validate_sql_template_syntax_error(
+ mock_database: MagicMock,
+ mock_validator: MagicMock,
+ mock_template_processor: MagicMock,
+ mock_config: dict[str, Any],
+) -> None:
+ """
+ Test that template syntax errors are properly surfaced to the client.
+
+ When template processing raises a SupersetSyntaxErrorException (e.g.,
+ invalid Jinja2 syntax, undefined variables), it should be caught and
+ converted to a ValidatorSQL400Error with detailed error information
+ including line numbers.
+ """
+ syntax_error = SupersetError(
+ message="Jinja2 template error (UndefinedError): 'city_filter' is
undefined",
+ error_type=SupersetErrorType.GENERIC_COMMAND_ERROR,
+ level=ErrorLevel.ERROR,
+ extra={"template": "SELECT * FROM...", "line": 3},
+ )
+ mock_template_processor.process_template.side_effect =
SupersetSyntaxErrorException(
+ [syntax_error]
+ )
+
+ sql_with_undefined_var = """SELECT *
+ FROM birth_names
+ WHERE city = '{{ city_filter }}'
+ LIMIT 100"""
+
+ data = {
+ "sql": sql_with_undefined_var,
+ "schema": "public",
+ "template_params": {},
+ }
+ command = ValidateSQLCommand(model_id=1, data=data)
+
+ with pytest.raises(ValidatorSQL400Error) as exc_info:
+ command.run()
+
+ error = exc_info.value
+ assert error.error.message is not None
+ assert "'city_filter' is undefined" in error.error.message
+ mock_validator.validate.assert_not_called()
+
+
+def test_validate_sql_template_processing_error(
+ mock_database: MagicMock,
+ mock_validator: MagicMock,
+ mock_template_processor: MagicMock,
+ mock_config: dict[str, Any],
+) -> None:
+ """
+ Test that internal template processing errors are properly surfaced to the
client.
+
+ When template processing raises a SupersetTemplateException (e.g.,
recursion,
+ unexpected failures), it should be caught and converted to a
ValidatorSQL400Error
+ with an appropriate error message.
+ """
+ mock_template_processor.process_template.side_effect =
SupersetTemplateException(
+ "Infinite recursion detected in template"
+ )
+
+ data = {
+ "sql": "SELECT * FROM birth_names LIMIT 100",
+ "schema": "public",
+ "template_params": {},
+ }
+ command = ValidateSQLCommand(model_id=1, data=data)
+
+ with pytest.raises(ValidatorSQL400Error) as exc_info:
+ command.run()
+
+ error = exc_info.value
+ assert error.error.message is not None
+ assert "Template processing failed" in error.error.message
+ assert "Infinite recursion" in error.error.message
+ mock_validator.validate.assert_not_called()
+
+
+def test_validate_sql_generic_exception(
+ mock_database: MagicMock,
+ mock_validator: MagicMock,
+ mock_template_processor: MagicMock,
+ mock_config: dict[str, Any],
+) -> None:
+ """
+ Test that unexpected exceptions are still caught and handled gracefully.
+
+ When an unexpected exception occurs (not template-related), it should be
caught
+ and converted to a ValidatorSQLError with the validator name in the
message.
+ """
+ mock_template_processor.process_template.side_effect = RuntimeError(
+ "Unexpected error occurred"
+ )
+
+ data = {
+ "sql": "SELECT * FROM birth_names",
+ "schema": "public",
+ "template_params": {},
+ }
+ command = ValidateSQLCommand(model_id=1, data=data)
+
+ with pytest.raises(ValidatorSQLError) as exc_info:
+ command.run()
+
+ error = exc_info.value
+ assert error.error.message is not None
+ assert "PostgreSQLValidator" in error.error.message
+ assert "Unexpected error occurred" in error.error.message
+ mock_validator.validate.assert_not_called()