diff --git a/pyproject.toml b/pyproject.toml index 03e197e3b48..561628cb9bc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -183,6 +183,7 @@ risingwave = ["sqlalchemy-risingwave"] shillelagh = ["shillelagh[all]>=1.4.3, <2"] singlestore = ["sqlalchemy-singlestoredb>=1.1.1, <2"] snowflake = ["snowflake-sqlalchemy>=1.2.4, <2"] +sqlite = ["syntaqlite>=0.1.0"] spark = [ "pyhive[hive]>=0.6.5;python_version<'3.11'", "pyhive[hive_pure_sasl]>=0.7", @@ -226,6 +227,7 @@ development = [ "ruff", "sqloxide", "statsd", + "syntaqlite>=0.1.0", ] [project.urls] @@ -238,7 +240,7 @@ combine_as_imports = true include_trailing_comma = true line_length = 88 known_first_party = "superset, apache-superset-core, apache-superset-extensions-cli" -known_third_party = "alembic, apispec, backoff, celery, click, colorama, cron_descriptor, croniter, cryptography, dateutil, deprecation, flask, flask_appbuilder, flask_babel, flask_caching, flask_compress, flask_jwt_extended, flask_login, flask_migrate, flask_sqlalchemy, flask_talisman, flask_testing, flask_wtf, freezegun, geohash, geopy, holidays, humanize, isodate, jinja2, jwt, markdown, markupsafe, marshmallow, marshmallow-union, msgpack, nh3, numpy, pandas, parameterized, parsedatetime, pgsanity, polyline, prison, progress, pyarrow, sqlalchemy_bigquery, pyhive, pyparsing, pytest, pytest_mock, pytz, redis, requests, selenium, setuptools, shillelagh, simplejson, slack, sqlalchemy, sqlalchemy_utils, typing_extensions, urllib3, werkzeug, wtforms, wtforms_json, yaml" +known_third_party = "alembic, apispec, backoff, celery, click, colorama, cron_descriptor, croniter, cryptography, dateutil, deprecation, flask, flask_appbuilder, flask_babel, flask_caching, flask_compress, flask_jwt_extended, flask_login, flask_migrate, flask_sqlalchemy, flask_talisman, flask_testing, flask_wtf, freezegun, geohash, geopy, holidays, humanize, isodate, jinja2, jwt, markdown, markupsafe, marshmallow, marshmallow-union, msgpack, nh3, numpy, pandas, parameterized, parsedatetime, pgsanity, polyline, prison, progress, pyarrow, sqlalchemy_bigquery, pyhive, pyparsing, pytest, pytest_mock, pytz, redis, requests, selenium, setuptools, shillelagh, simplejson, slack, sqlalchemy, sqlalchemy_utils, syntaqlite, typing_extensions, urllib3, werkzeug, wtforms, wtforms_json, yaml" multi_line_output = 3 order_by_type = false diff --git a/requirements/development.txt b/requirements/development.txt index 7fecd31e208..b75859536b4 100644 --- a/requirements/development.txt +++ b/requirements/development.txt @@ -992,6 +992,8 @@ starlette==0.49.1 # via mcp statsd==4.0.1 # via apache-superset +syntaqlite==0.1.0 + # via apache-superset tabulate==0.9.0 # via # -c requirements/base-constraint.txt diff --git a/superset/config.py b/superset/config.py index 0dd47e1d0c3..51244ddf11d 100644 --- a/superset/config.py +++ b/superset/config.py @@ -2068,6 +2068,15 @@ DEFAULT_RELATIVE_END_TIME = "today" SQL_VALIDATORS_BY_ENGINE = { "presto": "PrestoDBSQLValidator", "postgresql": "PostgreSQLValidator", + # SQLite-based engines (SQLite, GSheets, Shillelagh) can use the + # SQLiteSQLValidator, but it requires the optional syntaqlite package: + # + # pip install "apache-superset[sqlite]" + # + # Once installed, enable validation by uncommenting the lines below: + # "sqlite": "SQLiteSQLValidator", + # "gsheets": "SQLiteSQLValidator", + # "shillelagh": "SQLiteSQLValidator", } # A list of preferred databases, in order. These databases will be diff --git a/superset/sql_validators/__init__.py b/superset/sql_validators/__init__.py index 0298cf32edb..2bc1170a934 100644 --- a/superset/sql_validators/__init__.py +++ b/superset/sql_validators/__init__.py @@ -16,7 +16,7 @@ # under the License. from typing import Optional -from . import base, postgres, presto_db +from . import base, postgres, presto_db, sqlite from .base import SQLValidationAnnotation # noqa: F401 @@ -24,4 +24,5 @@ def get_validator_by_name(name: str) -> Optional[type[base.BaseSQLValidator]]: return { "PrestoDBSQLValidator": presto_db.PrestoDBSQLValidator, "PostgreSQLValidator": postgres.PostgreSQLValidator, + "SQLiteSQLValidator": sqlite.SQLiteSQLValidator, }.get(name) diff --git a/superset/sql_validators/sqlite.py b/superset/sql_validators/sqlite.py new file mode 100644 index 00000000000..074918dccfb --- /dev/null +++ b/superset/sql_validators/sqlite.py @@ -0,0 +1,142 @@ +# 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 __future__ import annotations + +import logging +import re +import subprocess + +from superset.models.core import Database +from superset.sql_validators.base import BaseSQLValidator, SQLValidationAnnotation + +try: + from syntaqlite import get_binary_path +except ModuleNotFoundError: + get_binary_path = None + +logger = logging.getLogger(__name__) + +DIAGNOSTIC_RE = re.compile( + r"^(?:error|warning): (.+)\n" + r" --> .+?:(\d+):(\d+)\n" + r" +\|\n" + r"\d+ \| .+\n" + r" +\| +\^(~*)", + re.MULTILINE, +) + + +class SQLiteSQLValidator(BaseSQLValidator): # pylint: disable=too-few-public-methods + """Validate SQL queries using the syntaqlite binary""" + + name = "SQLiteSQLValidator" + + @classmethod + def validate( + cls, + sql: str, + catalog: str | None, + schema: str | None, + database: Database, + ) -> list[SQLValidationAnnotation]: + annotations: list[SQLValidationAnnotation] = [] + + if get_binary_path is None: + return [ + SQLValidationAnnotation( + message=( + "syntaqlite is not installed. Install it with: " + 'pip install "apache-superset[sqlite]"' + ), + line_number=None, + start_column=None, + end_column=None, + ) + ] + + try: + result = subprocess.run( # noqa: S603 + [ + get_binary_path(), + "--no-config", + "validate", + "--allow", + "schema", + ], + input=sql, + capture_output=True, + text=True, + timeout=10, + ) + except FileNotFoundError: + logger.warning("syntaqlite binary not found") + return [ + SQLValidationAnnotation( + message=( + "syntaqlite binary not found. Ensure it is correctly installed " + 'via "pip install \\"apache-superset[sqlite]\\"" and available ' + "on the system PATH." + ), + line_number=None, + start_column=None, + end_column=None, + ) + ] + except subprocess.TimeoutExpired: + logger.warning("syntaqlite timed out validating SQL") + return [ + SQLValidationAnnotation( + message="SQL validation timed out — the query may be too complex.", + line_number=None, + start_column=None, + end_column=None, + ) + ] + + if result.returncode == 0: + return annotations + + output = (result.stderr or result.stdout).replace("\r\n", "\n") + for match in DIAGNOSTIC_RE.finditer(output): + message = match.group(1) + line_number = int(match.group(2)) + start_column = int(match.group(3)) + # The caret (^) plus tildes (~) span the error token + end_column = start_column + 1 + len(match.group(4)) + + annotations.append( + SQLValidationAnnotation( + message=message, + line_number=line_number, + start_column=start_column, + end_column=end_column, + ) + ) + + # If we couldn't parse the output but got a non-zero exit, add a generic error + if not annotations and result.returncode != 0: + annotations.append( + SQLValidationAnnotation( + message=output.strip() or "SQL syntax validation failed", + line_number=None, + start_column=None, + end_column=None, + ) + ) + + return annotations diff --git a/tests/unit_tests/sql_validators/#sqlite_test.py# b/tests/unit_tests/sql_validators/#sqlite_test.py# new file mode 100644 index 00000000000..ae5a07f4d2a --- /dev/null +++ b/tests/unit_tests/sql_validators/#sqlite_test.py# @@ -0,0 +1,303 @@ +# 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 __future__ import annotations + +import subprocess +from subprocess import CompletedProcess +from unittest.mock import MagicMock, patch + +from superset.sql_validators.sqlite import SQLiteSQLValidator + + +def _mock_result( + returncode: int, + stderr: str = "", + stdout: str = "", +) -> CompletedProcess[str]: + return CompletedProcess( + args=[], + returncode=returncode, + stdout=stdout, + stderr=stderr, + ) + + +def test_valid_syntax() -> None: + mock_database = MagicMock() + sql = "SELECT 1, col FROM my_table" + + with ( + patch( + "superset.sql_validators.sqlite.get_binary_path", + return_value="syntaqlite", + ), + patch( + "superset.sql_validators.sqlite.subprocess.run", + return_value=_mock_result(returncode=0), + ) as run, + ): + annotations = SQLiteSQLValidator.validate( + sql=sql, + catalog=None, + schema="", + database=mock_database, + ) + + assert annotations == [] + run.assert_called_once() + command = run.call_args.args[0] + assert "--input" not in command + assert "-e" not in command + assert run.call_args.kwargs["input"] == sql + + +def test_invalid_syntax_single_error() -> None: + mock_database = MagicMock() + stderr = ( + 'error: near "SELEC": syntax error\n' + " --> :1:1\n" + " |\n" + "1 | SELEC * FROM foo\n" + " | ^~~~~\n" + ) + + with ( + patch( + "superset.sql_validators.sqlite.get_binary_path", return_value="syntaqlite" + ), + patch( + "superset.sql_validators.sqlite.subprocess.run", + return_value=_mock_result(returncode=1, stderr=stderr), + ), + ): + annotations = SQLiteSQLValidator.validate( + sql="SELEC * FROM foo", + catalog=None, + schema="", + database=mock_database, + ) + + assert len(annotations) == 1 + annotation = annotations[0] + assert annotation.line_number == 1 + assert annotation.start_column == 1 + # "SELEC" is 5 chars → caret + 4 tildes → end_column = 1 + 1 + 4 = 6 + assert annotation.end_column == 6 + assert "SELEC" in annotation.message + + +def test_invalid_syntax_multiple_errors() -> None: + mock_database = MagicMock() + stderr = ( + 'error: near "SELEC": syntax error\n' + " --> :1:1\n" + " |\n" + "1 | SELEC * FROM foo; SELEC * FROM bar\n" + " | ^~~~~\n\n" + 'error: near "SELEC": syntax error\n' + " --> :1:20\n" + " |\n" + "1 | SELEC * FROM foo; SELEC * FROM bar\n" + " | ^~~~~\n" + ) + + with ( + patch( + "superset.sql_validators.sqlite.get_binary_path", return_value="syntaqlite" + ), + patch( + "superset.sql_validators.sqlite.subprocess.run", + return_value=_mock_result(returncode=1, stderr=stderr), + ), + ): + annotations = SQLiteSQLValidator.validate( + sql="SELEC * FROM foo; SELEC * FROM bar", + catalog=None, + schema="", + database=mock_database, + ) + + assert len(annotations) == 2 + assert "SELEC" in annotations[0].message + # "SELEC" is 5 chars → caret + 4 tildes → end_column = start_column + 1 + 4 + assert isinstance(annotations[0].start_column, int) + assert isinstance(annotations[0].start_column, int) + assert annotations[0].end_column == annotations[0].start_column + 5 + assert annotations[1].end_column == annotations[1].start_column + 5 + + +def test_multiline_error_reports_correct_line() -> None: + mock_database = MagicMock() + stderr = ( + 'error: near "SELEC": syntax error\n' + " --> :2:1\n" + " |\n" + "2 | SELEC * FROM foo\n" + " | ^~~~~\n" + ) + + with ( + patch( + "superset.sql_validators.sqlite.get_binary_path", return_value="syntaqlite" + ), + patch( + "superset.sql_validators.sqlite.subprocess.run", + return_value=_mock_result(returncode=1, stderr=stderr), + ), + ): + annotations = SQLiteSQLValidator.validate( + sql="SELECT 1;\nSELEC * FROM foo", + catalog=None, + schema="", + database=mock_database, + ) + + assert len(annotations) == 1 + assert annotations[0].line_number == 2 + + +def test_empty_sql() -> None: + mock_database = MagicMock() + + with ( + patch( + "superset.sql_validators.sqlite.get_binary_path", return_value="syntaqlite" + ), + patch( + "superset.sql_validators.sqlite.subprocess.run", + return_value=_mock_result(returncode=0), + ), + ): + annotations = SQLiteSQLValidator.validate( + sql="", + catalog=None, + schema="", + database=mock_database, + ) + + assert annotations == [] + + +def test_valid_complex_query() -> None: + mock_database = MagicMock() + + with ( + patch( + "superset.sql_validators.sqlite.get_binary_path", return_value="syntaqlite" + ), + patch( + "superset.sql_validators.sqlite.subprocess.run", + return_value=_mock_result(returncode=0), + ), + ): + annotations = SQLiteSQLValidator.validate( + sql=( + "SELECT a, COUNT(*) AS cnt " + "FROM my_table " + "WHERE b > 10 " + "GROUP BY a " + "HAVING cnt > 1 " + "ORDER BY cnt DESC " + "LIMIT 100" + ), + catalog=None, + schema="", + database=mock_database, + ) + + assert annotations == [] + + +def test_annotation_to_dict() -> None: + mock_database = MagicMock() + stderr = ( + 'error: near "SELEC": syntax error\n' + " --> :1:1\n" + " |\n" + "1 | SELEC * FROM foo\n" + " | ^~~~~\n" + ) + + with ( + patch( + "superset.sql_validators.sqlite.get_binary_path", return_value="syntaqlite" + ), + patch( + "superset.sql_validators.sqlite.subprocess.run", + return_value=_mock_result(returncode=1, stderr=stderr), + ), + ): + annotations = SQLiteSQLValidator.validate( + sql="SELEC * FROM foo", + catalog=None, + schema="", + database=mock_database, + ) + + assert len(annotations) == 1 + result = annotations[0].to_dict() + assert "line_number" in result + assert "start_column" in result + assert "end_column" in result + assert "message" in result + + +def test_missing_syntaqlite_returns_annotation() -> None: + mock_database = MagicMock() + with patch("superset.sql_validators.sqlite.get_binary_path", None): + annotations = SQLiteSQLValidator.validate( + sql="SELECT 1", + catalog=None, + schema="", + database=mock_database, + ) + + assert len(annotations) == 1 + assert "syntaqlite is not installed" in annotations[0].message + assert annotations[0].line_number is None + + +def test_timeout_returns_annotation() -> None: + mock_database = MagicMock() + + with ( + patch( + "superset.sql_validators.sqlite.get_binary_path", return_value="syntaqlite" + ), + patch( + "superset.sql_validators.sqlite.subprocess.run", + side_effect=subprocess.TimeoutExpired(cmd="syntaqlite", timeout=10), + ), + ): + annotations = SQLiteSQLValidator.validate( + sql="SELECT 1", + catalog=None, + schema="", + database=mock_database, + ) + + assert len(annotations) == 1 + assert "timed out" in annotations[0].message + assert annotations[0].line_number is None + + +def test_get_validator_by_name() -> None: + from superset.sql_validators import get_validator_by_name + + validator = get_validator_by_name("SQLiteSQLValidator") + assert validator is SQLiteSQLValidator diff --git a/tests/unit_tests/sql_validators/sqlite_test.py b/tests/unit_tests/sql_validators/sqlite_test.py new file mode 100644 index 00000000000..a950580eafc --- /dev/null +++ b/tests/unit_tests/sql_validators/sqlite_test.py @@ -0,0 +1,303 @@ +# 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 __future__ import annotations + +import subprocess +from subprocess import CompletedProcess +from unittest.mock import MagicMock, patch + +from superset.sql_validators.sqlite import SQLiteSQLValidator + + +def _mock_result( + returncode: int, + stderr: str = "", + stdout: str = "", +) -> CompletedProcess[str]: + return CompletedProcess( + args=[], + returncode=returncode, + stdout=stdout, + stderr=stderr, + ) + + +def test_valid_syntax() -> None: + mock_database = MagicMock() + sql = "SELECT 1, col FROM my_table" + + with ( + patch( + "superset.sql_validators.sqlite.get_binary_path", + return_value="syntaqlite", + ), + patch( + "superset.sql_validators.sqlite.subprocess.run", + return_value=_mock_result(returncode=0), + ) as run, + ): + annotations = SQLiteSQLValidator.validate( + sql=sql, + catalog=None, + schema="", + database=mock_database, + ) + + assert annotations == [] + run.assert_called_once() + command = run.call_args.args[0] + assert "--input" not in command + assert "-e" not in command + assert run.call_args.kwargs["input"] == sql + + +def test_invalid_syntax_single_error() -> None: + mock_database = MagicMock() + stderr = ( + 'error: near "SELEC": syntax error\n' + " --> :1:1\n" + " |\n" + "1 | SELEC * FROM foo\n" + " | ^~~~~\n" + ) + + with ( + patch( + "superset.sql_validators.sqlite.get_binary_path", return_value="syntaqlite" + ), + patch( + "superset.sql_validators.sqlite.subprocess.run", + return_value=_mock_result(returncode=1, stderr=stderr), + ), + ): + annotations = SQLiteSQLValidator.validate( + sql="SELEC * FROM foo", + catalog=None, + schema="", + database=mock_database, + ) + + assert len(annotations) == 1 + annotation = annotations[0] + assert annotation.line_number == 1 + assert annotation.start_column == 1 + # "SELEC" is 5 chars → caret + 4 tildes → end_column = 1 + 1 + 4 = 6 + assert annotation.end_column == 6 + assert "SELEC" in annotation.message + + +def test_invalid_syntax_multiple_errors() -> None: + mock_database = MagicMock() + stderr = ( + 'error: near "SELEC": syntax error\n' + " --> :1:1\n" + " |\n" + "1 | SELEC * FROM foo; SELEC * FROM bar\n" + " | ^~~~~\n\n" + 'error: near "SELEC": syntax error\n' + " --> :1:20\n" + " |\n" + "1 | SELEC * FROM foo; SELEC * FROM bar\n" + " | ^~~~~\n" + ) + + with ( + patch( + "superset.sql_validators.sqlite.get_binary_path", return_value="syntaqlite" + ), + patch( + "superset.sql_validators.sqlite.subprocess.run", + return_value=_mock_result(returncode=1, stderr=stderr), + ), + ): + annotations = SQLiteSQLValidator.validate( + sql="SELEC * FROM foo; SELEC * FROM bar", + catalog=None, + schema="", + database=mock_database, + ) + + assert len(annotations) == 2 + assert "SELEC" in annotations[0].message + # "SELEC" is 5 chars → caret + 4 tildes → end_column = start_column + 1 + 4 + assert isinstance(annotations[0].start_column, int) + assert annotations[0].end_column == annotations[0].start_column + 5 + assert isinstance(annotations[1].start_column, int) + assert annotations[1].end_column == annotations[1].start_column + 5 + + +def test_multiline_error_reports_correct_line() -> None: + mock_database = MagicMock() + stderr = ( + 'error: near "SELEC": syntax error\n' + " --> :2:1\n" + " |\n" + "2 | SELEC * FROM foo\n" + " | ^~~~~\n" + ) + + with ( + patch( + "superset.sql_validators.sqlite.get_binary_path", return_value="syntaqlite" + ), + patch( + "superset.sql_validators.sqlite.subprocess.run", + return_value=_mock_result(returncode=1, stderr=stderr), + ), + ): + annotations = SQLiteSQLValidator.validate( + sql="SELECT 1;\nSELEC * FROM foo", + catalog=None, + schema="", + database=mock_database, + ) + + assert len(annotations) == 1 + assert annotations[0].line_number == 2 + + +def test_empty_sql() -> None: + mock_database = MagicMock() + + with ( + patch( + "superset.sql_validators.sqlite.get_binary_path", return_value="syntaqlite" + ), + patch( + "superset.sql_validators.sqlite.subprocess.run", + return_value=_mock_result(returncode=0), + ), + ): + annotations = SQLiteSQLValidator.validate( + sql="", + catalog=None, + schema="", + database=mock_database, + ) + + assert annotations == [] + + +def test_valid_complex_query() -> None: + mock_database = MagicMock() + + with ( + patch( + "superset.sql_validators.sqlite.get_binary_path", return_value="syntaqlite" + ), + patch( + "superset.sql_validators.sqlite.subprocess.run", + return_value=_mock_result(returncode=0), + ), + ): + annotations = SQLiteSQLValidator.validate( + sql=( + "SELECT a, COUNT(*) AS cnt " + "FROM my_table " + "WHERE b > 10 " + "GROUP BY a " + "HAVING cnt > 1 " + "ORDER BY cnt DESC " + "LIMIT 100" + ), + catalog=None, + schema="", + database=mock_database, + ) + + assert annotations == [] + + +def test_annotation_to_dict() -> None: + mock_database = MagicMock() + stderr = ( + 'error: near "SELEC": syntax error\n' + " --> :1:1\n" + " |\n" + "1 | SELEC * FROM foo\n" + " | ^~~~~\n" + ) + + with ( + patch( + "superset.sql_validators.sqlite.get_binary_path", return_value="syntaqlite" + ), + patch( + "superset.sql_validators.sqlite.subprocess.run", + return_value=_mock_result(returncode=1, stderr=stderr), + ), + ): + annotations = SQLiteSQLValidator.validate( + sql="SELEC * FROM foo", + catalog=None, + schema="", + database=mock_database, + ) + + assert len(annotations) == 1 + result = annotations[0].to_dict() + assert "line_number" in result + assert "start_column" in result + assert "end_column" in result + assert "message" in result + + +def test_missing_syntaqlite_returns_annotation() -> None: + mock_database = MagicMock() + with patch("superset.sql_validators.sqlite.get_binary_path", None): + annotations = SQLiteSQLValidator.validate( + sql="SELECT 1", + catalog=None, + schema="", + database=mock_database, + ) + + assert len(annotations) == 1 + assert "syntaqlite is not installed" in annotations[0].message + assert annotations[0].line_number is None + + +def test_timeout_returns_annotation() -> None: + mock_database = MagicMock() + + with ( + patch( + "superset.sql_validators.sqlite.get_binary_path", return_value="syntaqlite" + ), + patch( + "superset.sql_validators.sqlite.subprocess.run", + side_effect=subprocess.TimeoutExpired(cmd="syntaqlite", timeout=10), + ), + ): + annotations = SQLiteSQLValidator.validate( + sql="SELECT 1", + catalog=None, + schema="", + database=mock_database, + ) + + assert len(annotations) == 1 + assert "timed out" in annotations[0].message + assert annotations[0].line_number is None + + +def test_get_validator_by_name() -> None: + from superset.sql_validators import get_validator_by_name + + validator = get_validator_by_name("SQLiteSQLValidator") + assert validator is SQLiteSQLValidator