diff --git a/superset/jinja_context.py b/superset/jinja_context.py index 673e6d3815f..dc9411030e5 100644 --- a/superset/jinja_context.py +++ b/superset/jinja_context.py @@ -28,8 +28,8 @@ from typing import Any, Callable, cast, TYPE_CHECKING, TypedDict, Union import dateutil from flask import current_app, g, has_request_context, request from flask_babel import gettext as _ -from jinja2 import DebugUndefined, Environment, TemplateSyntaxError -from jinja2.exceptions import SecurityError, UndefinedError +from jinja2 import DebugUndefined, Environment, TemplateSyntaxError, UndefinedError +from jinja2.exceptions import SecurityError from jinja2.sandbox import SandboxedEnvironment from sqlalchemy.engine.interfaces import Dialect from sqlalchemy.sql.expression import bindparam @@ -65,6 +65,13 @@ if TYPE_CHECKING: logger = logging.getLogger(__name__) + +class UndefinedTemplateFunctionException(SupersetTemplateException): + """Raised when an undefined function-like Jinja identifier is encountered.""" + + pass + + NONE_TYPE = type(None).__name__ ALLOWED_TYPES = ( NONE_TYPE, @@ -768,6 +775,14 @@ class BaseTemplateProcessor: raise SupersetTemplateException( "Infinite recursion detected in template" ) from ex + except UndefinedError as ex: + match = re.search(r'["\']([^"\']+)["\']\s+is undefined', str(ex)) + undefined_name = match.group(1) if match else None + if undefined_name and re.search( + r"\{\{\s*(?:[\w\.]*\.)?" + re.escape(undefined_name) + r"\s*\(", sql + ): + raise UndefinedTemplateFunctionException(str(ex)) from ex + raise class JinjaTemplateProcessor(BaseTemplateProcessor): diff --git a/superset/sqllab/query_render.py b/superset/sqllab/query_render.py index 7d41d7fb035..d8effce0416 100644 --- a/superset/sqllab/query_render.py +++ b/superset/sqllab/query_render.py @@ -66,6 +66,23 @@ class SqlQueryRenderImpl(SqlQueryRender): except TemplateError as ex: self._raise_template_exception(ex, execution_context) return "NOT_REACHABLE_CODE" + except Exception as ex: + from superset.jinja_context import UndefinedTemplateFunctionException + + if isinstance(ex, UndefinedTemplateFunctionException): + return query_model.sql.strip().strip(";") + raise + + def _strip_sql_comments( + self, + execution_context: SqlJsonExecutionContext, + sql: str, + ) -> str: + from superset.sql.parse import SQLScript + + engine = execution_context.query.database.db_engine_spec.engine + script = SQLScript(sql, engine) + return script.format(comments=False) def _validate( self, @@ -74,7 +91,11 @@ class SqlQueryRenderImpl(SqlQueryRender): sql_template_processor: BaseTemplateProcessor, ) -> None: if is_feature_enabled("ENABLE_TEMPLATE_PROCESSING"): - syntax_tree = sql_template_processor.env.parse(rendered_query) + sql_for_validation = self._strip_sql_comments( + execution_context, + rendered_query, + ) + syntax_tree = sql_template_processor.env.parse(sql_for_validation) undefined_parameters = find_undeclared_variables(syntax_tree) if undefined_parameters: self._raise_undefined_parameter_exception( diff --git a/tests/unit_tests/jinja_context_test.py b/tests/unit_tests/jinja_context_test.py index 985dd909eea..929c470b315 100644 --- a/tests/unit_tests/jinja_context_test.py +++ b/tests/unit_tests/jinja_context_test.py @@ -1639,3 +1639,57 @@ def test_jinja2_server_error_handling(mocker: MockerFixture) -> None: assert "Internal Jinja2 template error" in str(exception) assert "MemoryError" in str(exception) assert "Out of memory" in str(exception) + + +def test_undefined_template_function_exception(mocker: MockerFixture) -> None: + """Test UndefinedTemplateFunctionException for undefined function identifiers.""" + from superset.jinja_context import ( + BaseTemplateProcessor, + UndefinedTemplateFunctionException, + ) + + database = mocker.MagicMock() + database.db_engine_spec = mocker.MagicMock() + + processor = BaseTemplateProcessor(database=database) + + template = "SELECT {{ undefined_function() }}" + with pytest.raises(UndefinedTemplateFunctionException) as exc_info: + processor.process_template(template) + + exception = exc_info.value + assert isinstance(exception, UndefinedTemplateFunctionException) + assert "undefined" in str(exception).lower() + + +def test_undefined_template_function_exception_with_namespace( + mocker: MockerFixture, +) -> None: + """Test namespaced undefined functions raise UndefinedError (not converted).""" + from jinja2.exceptions import UndefinedError + + from superset.jinja_context import BaseTemplateProcessor + + database = mocker.MagicMock() + database.db_engine_spec = mocker.MagicMock() + + processor = BaseTemplateProcessor(database=database) + template = "SELECT {{ namespace.undefined_function() }}" + with pytest.raises(UndefinedError): + processor.process_template(template) + + +def test_undefined_template_variable_not_function(mocker: MockerFixture) -> None: + """Test undefined variables with method calls raise UndefinedError.""" + from jinja2.exceptions import UndefinedError + + from superset.jinja_context import BaseTemplateProcessor + + database = mocker.MagicMock() + database.db_engine_spec = mocker.MagicMock() + + processor = BaseTemplateProcessor(database=database) + + template = "SELECT {{ undefined_variable.some_method() }}" + with pytest.raises(UndefinedError): + processor.process_template(template)