Compare commits

..
Author SHA1 Message Date
Elizabeth Thompson 1cadf39ce8 test(sql): cover SqlglotError fallback branch in parse_predicate
The regression test only exercised the ParseError branch, leaving the
generic sqlglot.errors.SqlglotError fallback in
SQLStatement.parse_predicate uncovered and dropping line coverage below
the 100% gate. Add a test that mocks sqlglot.parse_one to raise a bare
SqlglotError and asserts it is converted to a SupersetParseError.
2026-08-28 22:14:52 +00:00
Elizabeth Thompson 876b8641e2 fix(sql): catch sqlglot ParseError when parsing RLS predicates
SQLStatement.parse_predicate called sqlglot.parse_one unguarded, so a
syntactically invalid RLS predicate raised a raw sqlglot ParseError.
Reachable via apply_rls (e.g. POST /api/v1/sqllab/estimate with
RLS_IN_SQLLAB enabled), this surfaced as an opaque 500 instead of a
typed 422.

Wrap the call to convert ParseError/SqlglotError into SupersetParseError,
mirroring the existing idiom in SQLStatement._parse.
2026-08-28 16:49:17 +00:00
19 changed files with 150 additions and 15 deletions
@@ -390,3 +390,19 @@ def get_session() -> scoped_session:
:returns: The SQLAlchemy scoped session instance.
"""
raise NotImplementedError("Function will be replaced during initialization")
__all__ = [
"Dataset",
"Database",
"Chart",
"Dashboard",
"User",
"Role",
"Group",
"Tag",
"KeyValue",
"Subject",
"CoreModel",
"get_session",
]
@@ -183,3 +183,10 @@ def prompt(
"MCP prompt decorator not initialized. "
"This decorator should be replaced during Superset startup."
)
__all__ = [
"tool",
"prompt",
"ToolAnnotations",
]
@@ -55,3 +55,9 @@ class SavedQueryDAO(BaseDAO[SavedQuery]):
model_cls = None
base_filter = None
id_column_name = "id"
__all__ = [
"QueryDAO",
"SavedQueryDAO",
]
@@ -71,3 +71,9 @@ class SavedQuery(CoreModel):
database_id: int | None
description: str | None
user_id: int | None
__all__ = [
"Query",
"SavedQuery",
]
@@ -46,3 +46,6 @@ def get_sqlglot_dialect(database: "Database") -> Dialects:
:returns: The SQLGlot dialect enum corresponding to the database.
"""
raise NotImplementedError("Function will be replaced during initialization")
__all__ = ["get_sqlglot_dialect"]
@@ -165,3 +165,13 @@ class AsyncQueryHandle:
:returns: True if cancellation was successful
"""
raise NotImplementedError("Method will be replaced during initialization")
__all__ = [
"QueryStatus",
"QueryOptions",
"QueryResult",
"StatementResult",
"AsyncQueryHandle",
"CacheOptions",
]
@@ -27,3 +27,6 @@ class RestApi(BaseApi):
"""
allow_browser_login = True
__all__ = ["RestApi"]
@@ -98,3 +98,6 @@ def api(
"API decorator not initialized. "
"This decorator should be replaced during Superset startup."
)
__all__ = ["api"]
@@ -164,3 +164,6 @@ class AbstractSemanticViewDAO(BaseDAO[SemanticViewModel]):
:return: SemanticViewModel instance or None
"""
...
__all__ = ["AbstractSemanticLayerDAO", "AbstractSemanticViewDAO"]
@@ -97,3 +97,6 @@ def semantic_layer(
"Semantic layer decorator not initialized. "
"This decorator should be replaced during Superset startup."
)
__all__ = ["semantic_layer"]
@@ -80,3 +80,6 @@ class SemanticViewModel(CoreModel):
semantic_layer_uuid: UUID
created_on: datetime | None
changed_on: datetime | None
__all__ = ["SemanticLayerModel", "SemanticViewModel"]
@@ -71,3 +71,6 @@ class TaskDAO(BaseDAO[Task]):
:returns: Task instance or None if not found or not active
"""
...
__all__ = ["TaskDAO"]
@@ -144,3 +144,9 @@ def get_context() -> TaskContext:
)
"""
raise NotImplementedError("Function will be replaced during initialization")
__all__ = [
"task",
"get_context",
]
@@ -161,3 +161,9 @@ class TaskSubscriber(CoreModel):
changed_on: datetime | None
created_by_fk: int | None
changed_by_fk: int | None
__all__ = [
"Task",
"TaskSubscriber",
]
@@ -226,3 +226,12 @@ class TaskContext(ABC):
cleanup_partial_work()
"""
...
__all__ = [
"TaskStatus",
"TaskScope",
"TaskProperties",
"TaskContext",
"TaskOptions",
]
@@ -28,6 +28,14 @@ const Wrapper = styled.div`
flex-direction: column;
height: 100%;
.ant-tabs {
height: 100%;
}
.ant-tabs-body {
height: 100%;
}
.ant-tabs-content {
display: flex;
flex-direction: column;
@@ -80,12 +88,7 @@ export const ResultsPaneOnDashboard = ({
return (
<Wrapper>
<Tabs
fullHeight
activeKey={activeTabKey}
onChange={setActiveTabKey}
items={items}
/>
<Tabs activeKey={activeTabKey} onChange={setActiveTabKey} items={items} />
</Wrapper>
);
};
@@ -25,15 +25,9 @@ import {
} from 'spec/helpers/testing-library';
import { ChartMetadata, ChartPlugin, VizType } from '@superset-ui/core';
import { setupAGGridModules } from '@superset-ui/core/components/ThemedAgGridReact';
import Tabs from '@superset-ui/core/components/Tabs';
import { ResultsPaneOnDashboard } from '../components';
import { createResultsPaneOnDashboardProps } from './fixture';
jest.mock('@superset-ui/core/components/Tabs', () => {
const actual = jest.requireActual('@superset-ui/core/components/Tabs');
return { __esModule: true, ...actual, default: jest.fn(actual.default) };
});
beforeAll(() => {
setupAGGridModules();
});
@@ -112,8 +106,6 @@ describe('ResultsPaneOnDashboard', () => {
expect(
await findByText('No results were returned for this query'),
).toBeVisible();
const [tabsProps] = (Tabs as unknown as jest.Mock).mock.calls[0];
expect(tabsProps).toEqual(expect.objectContaining({ fullHeight: true }));
});
test('render errorMessage', async () => {
+19 -1
View File
@@ -1539,7 +1539,25 @@ class SQLStatement(BaseSQLStatement[exp.Expression]):
:return: The parsed predicate.
"""
_check_script_length(predicate, self.engine)
return sqlglot.parse_one(predicate, dialect=self._dialect)
try:
return sqlglot.parse_one(predicate, dialect=self._dialect)
except sqlglot.errors.ParseError as ex:
kwargs = (
{
"highlight": ex.errors[0]["highlight"],
"line": ex.errors[0]["line"],
"column": ex.errors[0]["col"],
}
if ex.errors
else {}
)
raise SupersetParseError(predicate, self.engine, **kwargs) from ex
except sqlglot.errors.SqlglotError as ex:
raise SupersetParseError(
predicate,
self.engine,
message="Unable to parse predicate",
) from ex
def apply_rls(
self,
+35
View File
@@ -5578,6 +5578,41 @@ def test_parse_predicate_length_check() -> None:
stmt.parse_predicate("x" * 101)
def test_parse_predicate_invalid_sql_raises_superset_parse_error() -> None:
"""
A syntactically invalid RLS predicate raises ``SupersetParseError``.
``parse_predicate`` is reachable via ``apply_rls`` for any RLS clause
configured on a queried table; an invalid clause must surface as the
typed 422 parse error rather than leaking a raw ``sqlglot`` exception.
"""
stmt = SQLStatement("SELECT 1", "postgresql")
with pytest.raises(SupersetParseError) as excinfo:
stmt.parse_predicate("a >")
assert excinfo.value.status == 422
def test_parse_predicate_sqlglot_error_raises_superset_parse_error(
mocker: MockerFixture,
) -> None:
"""
A non-``ParseError`` ``sqlglot`` failure also surfaces as a typed error.
``parse_predicate`` catches the generic ``SqlglotError`` base class as a
fallback so any sqlglot failure (e.g. tokenize errors) is converted into a
``SupersetParseError`` rather than leaking a raw sqlglot exception.
"""
# Build the statement before patching, since the constructor also parses.
stmt = SQLStatement("SELECT 1", "postgresql")
mocker.patch(
"sqlglot.parse_one",
side_effect=sqlglot.errors.SqlglotError("boom"),
)
with pytest.raises(SupersetParseError) as excinfo:
stmt.parse_predicate("a > 1")
assert excinfo.value.status == 422
@pytest.mark.usefixtures("_small_parse_cap")
def test_transpile_to_dialect_length_check() -> None:
"""