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