mirror of
https://github.com/apache/superset.git
synced 2026-09-01 13:01:33 +00:00
Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7e4c5f57e | ||
|
|
1bac9807aa | ||
|
|
5f79743799 | ||
|
|
2516bf0166 | ||
|
|
3f61dd8bdc | ||
|
|
36f6c22660 | ||
|
|
c9eebc0744 | ||
|
|
304b9c10e0 | ||
|
|
b0528f0bf2 | ||
|
|
b913ee27a0 | ||
|
|
7de38c2af1 | ||
|
|
9ebbbc87f3 | ||
|
|
13dd39abb1 | ||
|
|
7870fda6ab | ||
|
|
4011845d92 | ||
|
|
50f4802bbf | ||
|
|
fd07663dc1 |
+6
-9
@@ -28,14 +28,6 @@ const Wrapper = styled.div`
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|
||||||
.ant-tabs {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ant-tabs-body {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ant-tabs-content {
|
.ant-tabs-content {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -88,7 +80,12 @@ export const ResultsPaneOnDashboard = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Wrapper>
|
<Wrapper>
|
||||||
<Tabs activeKey={activeTabKey} onChange={setActiveTabKey} items={items} />
|
<Tabs
|
||||||
|
fullHeight
|
||||||
|
activeKey={activeTabKey}
|
||||||
|
onChange={setActiveTabKey}
|
||||||
|
items={items}
|
||||||
|
/>
|
||||||
</Wrapper>
|
</Wrapper>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+8
@@ -25,9 +25,15 @@ import {
|
|||||||
} from 'spec/helpers/testing-library';
|
} from 'spec/helpers/testing-library';
|
||||||
import { ChartMetadata, ChartPlugin, VizType } from '@superset-ui/core';
|
import { ChartMetadata, ChartPlugin, VizType } from '@superset-ui/core';
|
||||||
import { setupAGGridModules } from '@superset-ui/core/components/ThemedAgGridReact';
|
import { setupAGGridModules } from '@superset-ui/core/components/ThemedAgGridReact';
|
||||||
|
import Tabs from '@superset-ui/core/components/Tabs';
|
||||||
import { ResultsPaneOnDashboard } from '../components';
|
import { ResultsPaneOnDashboard } from '../components';
|
||||||
import { createResultsPaneOnDashboardProps } from './fixture';
|
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(() => {
|
beforeAll(() => {
|
||||||
setupAGGridModules();
|
setupAGGridModules();
|
||||||
});
|
});
|
||||||
@@ -106,6 +112,8 @@ describe('ResultsPaneOnDashboard', () => {
|
|||||||
expect(
|
expect(
|
||||||
await findByText('No results were returned for this query'),
|
await findByText('No results were returned for this query'),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
|
const [tabsProps] = (Tabs as unknown as jest.Mock).mock.calls[0];
|
||||||
|
expect(tabsProps).toEqual(expect.objectContaining({ fullHeight: true }));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('render errorMessage', async () => {
|
test('render errorMessage', async () => {
|
||||||
|
|||||||
@@ -18,14 +18,12 @@ import logging
|
|||||||
from functools import partial
|
from functools import partial
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from jinja2.exceptions import TemplateError
|
|
||||||
from marshmallow import ValidationError
|
from marshmallow import ValidationError
|
||||||
|
|
||||||
from superset import security_manager
|
from superset import security_manager
|
||||||
from superset.commands.base import BaseCommand
|
from superset.commands.base import BaseCommand
|
||||||
from superset.commands.exceptions import TagNotFoundValidationError
|
from superset.commands.exceptions import TagNotFoundValidationError
|
||||||
from superset.commands.tag.exceptions import (
|
from superset.commands.tag.exceptions import (
|
||||||
TagAccessValidationError,
|
|
||||||
TagDeleteFailedError,
|
TagDeleteFailedError,
|
||||||
TagDeleteForbiddenValidationError,
|
TagDeleteForbiddenValidationError,
|
||||||
TaggedObjectDeleteFailedError,
|
TaggedObjectDeleteFailedError,
|
||||||
@@ -34,7 +32,7 @@ from superset.commands.tag.exceptions import (
|
|||||||
)
|
)
|
||||||
from superset.commands.tag.utils import to_object_model, to_object_type
|
from superset.commands.tag.utils import to_object_model, to_object_type
|
||||||
from superset.daos.tag import TagDAO
|
from superset.daos.tag import TagDAO
|
||||||
from superset.exceptions import SupersetParseError, SupersetSecurityException
|
from superset.exceptions import SupersetSecurityException
|
||||||
from superset.tags.models import ObjectType, TagType
|
from superset.tags.models import ObjectType, TagType
|
||||||
from superset.utils.decorators import on_error, transaction
|
from superset.utils.decorators import on_error, transaction
|
||||||
from superset.views.base import DeleteMixin
|
from superset.views.base import DeleteMixin
|
||||||
@@ -112,29 +110,7 @@ class DeleteTaggedObjectCommand(DeleteMixin, BaseCommand):
|
|||||||
elif object_type == ObjectType.chart:
|
elif object_type == ObjectType.chart:
|
||||||
security_manager.raise_for_access(chart=target_object)
|
security_manager.raise_for_access(chart=target_object)
|
||||||
elif object_type == ObjectType.query:
|
elif object_type == ObjectType.query:
|
||||||
# Authorizing a query without blanket database access parses
|
security_manager.raise_for_access(query=target_object)
|
||||||
# its Jinja-templated SQL. Malformed Jinja (``TemplateError``)
|
|
||||||
# or a partition macro that references a table which cannot be
|
|
||||||
# resolved statically (``SupersetParseError``) is a validation
|
|
||||||
# failure, not an opaque 500. Append a ``ValidationError`` so it
|
|
||||||
# composites cleanly into ``TagInvalidError`` (the delete route
|
|
||||||
# calls ``normalized_messages()`` on it).
|
|
||||||
try:
|
|
||||||
security_manager.raise_for_access(query=target_object)
|
|
||||||
except (TemplateError, SupersetParseError) as ex:
|
|
||||||
logger.warning(
|
|
||||||
"Failed to render Jinja SQL while validating access "
|
|
||||||
"for %s %s: %s",
|
|
||||||
object_type,
|
|
||||||
object_id,
|
|
||||||
ex,
|
|
||||||
)
|
|
||||||
exceptions.append(
|
|
||||||
TagAccessValidationError(
|
|
||||||
f"Access validation failed for {object_type} "
|
|
||||||
f"{object_id}: {ex}"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
elif object_type == ObjectType.dataset:
|
elif object_type == ObjectType.dataset:
|
||||||
security_manager.raise_for_access(datasource=target_object)
|
security_manager.raise_for_access(datasource=target_object)
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -57,18 +57,6 @@ class TagDeleteForbiddenValidationError(ValidationError):
|
|||||||
super().__init__(message, field_name="tags")
|
super().__init__(message, field_name="tags")
|
||||||
|
|
||||||
|
|
||||||
class TagAccessValidationError(ValidationError):
|
|
||||||
"""The access check for a tagged object could not be completed -- e.g. a
|
|
||||||
saved query whose Jinja-templated SQL is malformed (``TemplateError``) or
|
|
||||||
references a table that cannot be resolved statically (``SupersetParseError``).
|
|
||||||
A ``ValidationError`` so it composites into ``TagInvalidError`` and supports
|
|
||||||
``CommandInvalidError.normalized_messages()`` rather than crashing it.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, message: str) -> None:
|
|
||||||
super().__init__(message, field_name="tags")
|
|
||||||
|
|
||||||
|
|
||||||
class TaggedObjectDeleteFailedError(DeleteFailedError):
|
class TaggedObjectDeleteFailedError(DeleteFailedError):
|
||||||
message = _("Tagged Object could not be deleted.")
|
message = _("Tagged Object could not be deleted.")
|
||||||
|
|
||||||
|
|||||||
@@ -144,14 +144,11 @@ def get_available_engine_specs() -> dict[type[BaseEngineSpec], set[str]]: # noq
|
|||||||
issubclass(dialect, DefaultDialect)
|
issubclass(dialect, DefaultDialect)
|
||||||
and hasattr(dialect, "driver")
|
and hasattr(dialect, "driver")
|
||||||
# adodbapi dialect is removed in SQLA 1.4 and doesn't implement the
|
# adodbapi dialect is removed in SQLA 1.4 and doesn't implement the
|
||||||
# DBAPI import method, hence needs to be ignored to avoid a warning
|
# `dbapi` method, hence needs to be ignored to avoid logging a warning
|
||||||
and dialect.driver != "adodbapi"
|
and dialect.driver != "adodbapi"
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
if hasattr(dialect, "import_dbapi"):
|
dialect.dbapi()
|
||||||
dialect.import_dbapi()
|
|
||||||
else:
|
|
||||||
dialect.dbapi()
|
|
||||||
except ModuleNotFoundError:
|
except ModuleNotFoundError:
|
||||||
continue
|
continue
|
||||||
except Exception as ex: # pylint: disable=broad-except
|
except Exception as ex: # pylint: disable=broad-except
|
||||||
|
|||||||
@@ -272,15 +272,6 @@ class GetDashboardInfoRequest(MetadataCacheControl):
|
|||||||
"the shared active-tab and filter context; no identifier is required."
|
"the shared active-tab and filter context; no identifier is required."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
filter_state: dict[str, Any] | None = Field(
|
|
||||||
default=None,
|
|
||||||
description=(
|
|
||||||
"Active filters supplied directly rather than via a permalink, so the "
|
|
||||||
"tool can describe the dashboard as the user currently views it, "
|
|
||||||
'filtered. Shape: {"applied_filters": [{"col", "op", "val"}]}. Ignored '
|
|
||||||
"when permalink_key is provided."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
select_columns: Annotated[
|
select_columns: Annotated[
|
||||||
List[str],
|
List[str],
|
||||||
Field(
|
Field(
|
||||||
|
|||||||
@@ -40,29 +40,25 @@ from superset.mcp_service.dashboard.schemas import (
|
|||||||
dashboard_serializer,
|
dashboard_serializer,
|
||||||
DashboardError,
|
DashboardError,
|
||||||
DashboardInfo,
|
DashboardInfo,
|
||||||
|
DEFAULT_GET_DASHBOARD_INFO_COLUMNS,
|
||||||
GetDashboardInfoRequest,
|
GetDashboardInfoRequest,
|
||||||
redact_filter_state_data_model_metadata,
|
|
||||||
)
|
)
|
||||||
from superset.mcp_service.mcp_core import ModelGetInfoCore
|
from superset.mcp_service.mcp_core import ModelGetInfoCore
|
||||||
from superset.mcp_service.privacy import user_can_view_data_model_metadata
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _apply_permalink_state(
|
def _apply_permalink_state(
|
||||||
result: DashboardInfo,
|
result: DashboardInfo,
|
||||||
permalink_key: str | None,
|
permalink_key: str,
|
||||||
permalink_state: dict[str, object],
|
permalink_state: dict[str, object],
|
||||||
is_permalink: bool = True,
|
|
||||||
) -> DashboardInfo:
|
) -> DashboardInfo:
|
||||||
"""Attach the filter state without changing its stored values.
|
"""Attach permalink fields without changing their stored values."""
|
||||||
is_permalink is False when the state was supplied directly, not resolved
|
|
||||||
from a permalink."""
|
|
||||||
return result.model_copy(
|
return result.model_copy(
|
||||||
update={
|
update={
|
||||||
"permalink_key": permalink_key,
|
"permalink_key": permalink_key,
|
||||||
"filter_state": permalink_state,
|
"filter_state": permalink_state,
|
||||||
"is_permalink_state": is_permalink,
|
"is_permalink_state": True,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -218,15 +214,6 @@ async def get_dashboard_info(
|
|||||||
"permalink_key provided but no permalink found. "
|
"permalink_key provided but no permalink found. "
|
||||||
"The permalink may have expired or is invalid."
|
"The permalink may have expired or is invalid."
|
||||||
)
|
)
|
||||||
elif request.filter_state is not None:
|
|
||||||
# Filter context supplied directly (no permalink), e.g. embedded.
|
|
||||||
await ctx.info("Applying caller-supplied filter_state")
|
|
||||||
filter_state = request.filter_state
|
|
||||||
if not user_can_view_data_model_metadata():
|
|
||||||
filter_state = redact_filter_state_data_model_metadata(filter_state)
|
|
||||||
result = _apply_permalink_state(
|
|
||||||
result, None, filter_state, is_permalink=False
|
|
||||||
)
|
|
||||||
|
|
||||||
await ctx.info(
|
await ctx.info(
|
||||||
"Dashboard information retrieved successfully: id=%s, title=%s, "
|
"Dashboard information retrieved successfully: id=%s, title=%s, "
|
||||||
@@ -239,13 +226,12 @@ async def get_dashboard_info(
|
|||||||
result.is_permalink_state,
|
result.is_permalink_state,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
# Include filter_state by default when present, but honor an explicit
|
# When permalink_key is supplied and the caller did not explicitly
|
||||||
# select_columns projection (model_fields_set = caller chose it).
|
# override select_columns, ensure filter_state is present so the
|
||||||
|
# caller gets the data they came for.
|
||||||
effective_select_columns = list(request.select_columns)
|
effective_select_columns = list(request.select_columns)
|
||||||
if (
|
if result.is_permalink_state and effective_select_columns == list(
|
||||||
result.filter_state is not None
|
DEFAULT_GET_DASHBOARD_INFO_COLUMNS
|
||||||
and "select_columns" not in request.model_fields_set
|
|
||||||
and "filter_state" not in effective_select_columns
|
|
||||||
):
|
):
|
||||||
effective_select_columns.append("filter_state")
|
effective_select_columns.append("filter_state")
|
||||||
|
|
||||||
|
|||||||
@@ -137,41 +137,6 @@ def test_get_available_engine_specs_keeps_valid_third_party_dialect(
|
|||||||
assert available[SqliteEngineSpec] == {"valid_driver"}
|
assert available[SqliteEngineSpec] == {"valid_driver"}
|
||||||
|
|
||||||
|
|
||||||
def test_get_available_engine_specs_supports_sqlalchemy_2_native_dialect(
|
|
||||||
mocker: MockerFixture,
|
|
||||||
) -> None:
|
|
||||||
"""A native SQLAlchemy 2 dialect is discovered through import_dbapi()."""
|
|
||||||
import sqlalchemy.dialects
|
|
||||||
|
|
||||||
from superset.db_engine_specs.mysql import MySQLEngineSpec
|
|
||||||
|
|
||||||
class ValidDialect(DefaultDialect):
|
|
||||||
driver = "mysqldb"
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def import_dbapi(cls) -> object:
|
|
||||||
return object()
|
|
||||||
|
|
||||||
mocker.patch.object(sqlalchemy.dialects, "__all__", ["mysql"])
|
|
||||||
mocker.patch.object(
|
|
||||||
sqlalchemy.dialects.registry,
|
|
||||||
"load",
|
|
||||||
return_value=ValidDialect,
|
|
||||||
)
|
|
||||||
mocker.patch(
|
|
||||||
"superset.db_engine_specs.load_engine_specs",
|
|
||||||
return_value=iter([MySQLEngineSpec]),
|
|
||||||
)
|
|
||||||
mocker.patch(
|
|
||||||
"superset.db_engine_specs.entry_points",
|
|
||||||
return_value=[],
|
|
||||||
)
|
|
||||||
|
|
||||||
available = get_available_engine_specs()
|
|
||||||
|
|
||||||
assert available[MySQLEngineSpec] == {"mysqldb"}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"app",
|
"app",
|
||||||
[{"DBS_AVAILABLE_DENYLIST": {"databricks": {"pyhive", "pyodbc"}}}],
|
[{"DBS_AVAILABLE_DENYLIST": {"databricks": {"pyhive", "pyodbc"}}}],
|
||||||
|
|||||||
@@ -16,12 +16,10 @@
|
|||||||
# under the License.
|
# under the License.
|
||||||
import math
|
import math
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from textwrap import dedent
|
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
from unittest.mock import Mock
|
from unittest.mock import Mock
|
||||||
|
|
||||||
import pandas as pd
|
|
||||||
import pytest
|
import pytest
|
||||||
import pytz
|
import pytz
|
||||||
from pyhive.sqlalchemy_presto import PrestoDialect
|
from pyhive.sqlalchemy_presto import PrestoDialect
|
||||||
@@ -30,15 +28,7 @@ from sqlalchemy import column, sql, text, types
|
|||||||
from sqlalchemy.engine.interfaces import Dialect
|
from sqlalchemy.engine.interfaces import Dialect
|
||||||
from sqlalchemy.engine.url import make_url
|
from sqlalchemy.engine.url import make_url
|
||||||
|
|
||||||
from superset.models.sql_types.presto_sql_types import (
|
|
||||||
Array,
|
|
||||||
Interval,
|
|
||||||
Map,
|
|
||||||
Row,
|
|
||||||
TinyInteger,
|
|
||||||
)
|
|
||||||
from superset.sql.parse import Table
|
from superset.sql.parse import Table
|
||||||
from superset.superset_typing import ResultSetColumnType
|
|
||||||
from superset.utils.core import GenericDataType
|
from superset.utils.core import GenericDataType
|
||||||
from tests.unit_tests.db_engine_specs.utils import (
|
from tests.unit_tests.db_engine_specs.utils import (
|
||||||
assert_column_spec,
|
assert_column_spec,
|
||||||
@@ -50,7 +40,6 @@ from tests.unit_tests.db_engine_specs.utils import (
|
|||||||
"target_type,dttm,expected_result",
|
"target_type,dttm,expected_result",
|
||||||
[
|
[
|
||||||
("VARCHAR", datetime(2022, 1, 1), None),
|
("VARCHAR", datetime(2022, 1, 1), None),
|
||||||
("", datetime(2022, 1, 1), None),
|
|
||||||
("DATE", datetime(2022, 1, 1), "DATE '2022-01-01'"),
|
("DATE", datetime(2022, 1, 1), "DATE '2022-01-01'"),
|
||||||
(
|
(
|
||||||
"TIMESTAMP",
|
"TIMESTAMP",
|
||||||
@@ -79,22 +68,6 @@ def test_convert_dttm(
|
|||||||
assert_convert_dttm(spec, target_type, expected_result, dttm)
|
assert_convert_dttm(spec, target_type, expected_result, dttm)
|
||||||
|
|
||||||
|
|
||||||
def test_convert_dttm_presto_spec_truncates_to_milliseconds() -> None:
|
|
||||||
from superset.db_engine_specs.presto import PrestoEngineSpec
|
|
||||||
|
|
||||||
assert PrestoEngineSpec.convert_dttm(
|
|
||||||
"TIMESTAMP", datetime(2022, 1, 1, 1, 23, 45, 600123)
|
|
||||||
) == ("TIMESTAMP '2022-01-01 01:23:45.600'")
|
|
||||||
|
|
||||||
|
|
||||||
def test_convert_dttm_base_spec_keeps_microseconds() -> None:
|
|
||||||
from superset.db_engine_specs.presto import PrestoBaseEngineSpec
|
|
||||||
|
|
||||||
assert PrestoBaseEngineSpec.convert_dttm(
|
|
||||||
"TIMESTAMP", datetime(2022, 1, 1, 1, 23, 45, 600123)
|
|
||||||
) == ("TIMESTAMP '2022-01-01 01:23:45.600123'")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"native_type,sqla_type,attrs,generic_type,is_dttm",
|
"native_type,sqla_type,attrs,generic_type,is_dttm",
|
||||||
[
|
[
|
||||||
@@ -105,26 +78,6 @@ def test_convert_dttm_base_spec_keeps_microseconds() -> None:
|
|||||||
("integer", types.Integer, None, GenericDataType.NUMERIC, False),
|
("integer", types.Integer, None, GenericDataType.NUMERIC, False),
|
||||||
("time", types.Time, None, GenericDataType.TEMPORAL, True),
|
("time", types.Time, None, GenericDataType.TEMPORAL, True),
|
||||||
("timestamp", types.TIMESTAMP, None, GenericDataType.TEMPORAL, True),
|
("timestamp", types.TIMESTAMP, None, GenericDataType.TEMPORAL, True),
|
||||||
("boolean", types.BOOLEAN, None, GenericDataType.BOOLEAN, False),
|
|
||||||
("tinyint", TinyInteger, None, GenericDataType.NUMERIC, False),
|
|
||||||
("smallint", types.SmallInteger, None, GenericDataType.NUMERIC, False),
|
|
||||||
("bigint", types.BigInteger, None, GenericDataType.NUMERIC, False),
|
|
||||||
("real", types.FLOAT, None, GenericDataType.NUMERIC, False),
|
|
||||||
("double", types.FLOAT, None, GenericDataType.NUMERIC, False),
|
|
||||||
(
|
|
||||||
"decimal(10,2)",
|
|
||||||
types.DECIMAL,
|
|
||||||
{"precision": None, "scale": None},
|
|
||||||
GenericDataType.NUMERIC,
|
|
||||||
False,
|
|
||||||
),
|
|
||||||
("varbinary", types.VARBINARY, None, GenericDataType.STRING, False),
|
|
||||||
("json", types.JSON, None, GenericDataType.STRING, False),
|
|
||||||
("date", types.Date, None, GenericDataType.TEMPORAL, True),
|
|
||||||
("interval year to month", Interval, None, GenericDataType.TEMPORAL, True),
|
|
||||||
("array(varchar)", Array, None, GenericDataType.STRING, False),
|
|
||||||
("map(varchar, integer)", Map, None, GenericDataType.STRING, False),
|
|
||||||
("row(a varchar, b integer)", Row, None, GenericDataType.STRING, False),
|
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_get_column_spec(
|
def test_get_column_spec(
|
||||||
@@ -193,203 +146,6 @@ def test_get_schema_from_engine_params() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"schema",
|
|
||||||
[
|
|
||||||
pytest.param("with/slash", id="slash"),
|
|
||||||
pytest.param("with space", id="space"),
|
|
||||||
pytest.param("with%percent", id="percent"),
|
|
||||||
pytest.param("地区", id="unicode"),
|
|
||||||
pytest.param("plain", id="plain"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_schema_survives_engine_params_round_trip(schema: str) -> None:
|
|
||||||
from superset.db_engine_specs.presto import PrestoEngineSpec
|
|
||||||
|
|
||||||
uri, _connect_args = PrestoEngineSpec.adjust_engine_params(
|
|
||||||
make_url("presto://localhost:8080/hive"),
|
|
||||||
{},
|
|
||||||
schema=schema,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert PrestoEngineSpec.get_schema_from_engine_params(uri, {}) == schema
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_catalog_names_lists_catalogs() -> None:
|
|
||||||
from superset.db_engine_specs.presto import PrestoEngineSpec
|
|
||||||
|
|
||||||
inspector = mock.MagicMock()
|
|
||||||
conn = inspector.engine.connect.return_value.__enter__.return_value
|
|
||||||
conn.execute.return_value = [("jmx",), ("tpch",), ("memory",)]
|
|
||||||
|
|
||||||
result = PrestoEngineSpec.get_catalog_names(mock.MagicMock(), inspector)
|
|
||||||
|
|
||||||
assert result == {"jmx", "tpch", "memory"}
|
|
||||||
assert str(conn.execute.call_args[0][0]) == "SHOW CATALOGS"
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_view_names_queries_information_schema_with_schema() -> None:
|
|
||||||
from superset.db_engine_specs.presto import PrestoEngineSpec
|
|
||||||
|
|
||||||
database = mock.MagicMock()
|
|
||||||
cursor = database.get_raw_connection().__enter__().cursor()
|
|
||||||
cursor.fetchall.return_value = [["a", "b,", "c"], ["d", "e"]]
|
|
||||||
|
|
||||||
result = PrestoEngineSpec.get_view_names(database, mock.Mock(), "my_schema")
|
|
||||||
|
|
||||||
assert result == {"a", "d"}
|
|
||||||
cursor.execute.assert_called_once_with(
|
|
||||||
dedent(
|
|
||||||
"""
|
|
||||||
SELECT table_name FROM information_schema.tables
|
|
||||||
WHERE table_schema = %(schema)s
|
|
||||||
AND table_type = 'VIEW'
|
|
||||||
"""
|
|
||||||
).strip(),
|
|
||||||
{"schema": "my_schema"},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_view_names_queries_information_schema_without_schema() -> None:
|
|
||||||
from superset.db_engine_specs.presto import PrestoEngineSpec
|
|
||||||
|
|
||||||
database = mock.MagicMock()
|
|
||||||
cursor = database.get_raw_connection().__enter__().cursor()
|
|
||||||
cursor.fetchall.return_value = [["a", "b,", "c"], ["d", "e"]]
|
|
||||||
|
|
||||||
result = PrestoEngineSpec.get_view_names(database, mock.Mock(), None)
|
|
||||||
|
|
||||||
assert result == {"a", "d"}
|
|
||||||
cursor.execute.assert_called_once_with(
|
|
||||||
dedent(
|
|
||||||
"""
|
|
||||||
SELECT table_name FROM information_schema.tables
|
|
||||||
WHERE table_type = 'VIEW'
|
|
||||||
"""
|
|
||||||
).strip(),
|
|
||||||
{},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_view_names_returns_empty_set_when_no_views() -> None:
|
|
||||||
from superset.db_engine_specs.presto import PrestoEngineSpec
|
|
||||||
|
|
||||||
database = mock.MagicMock()
|
|
||||||
database.get_raw_connection().__enter__().cursor().fetchall.return_value = []
|
|
||||||
|
|
||||||
assert PrestoEngineSpec.get_view_names(database, mock.Mock(), "empty") == set()
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_view_names_propagates_driver_error() -> None:
|
|
||||||
from pyhive.exc import DatabaseError
|
|
||||||
|
|
||||||
from superset.db_engine_specs.presto import PrestoEngineSpec
|
|
||||||
|
|
||||||
database = mock.MagicMock()
|
|
||||||
database.get_raw_connection().__enter__().cursor().execute.side_effect = (
|
|
||||||
DatabaseError("Access Denied: Cannot select from table information_schema")
|
|
||||||
)
|
|
||||||
|
|
||||||
with pytest.raises(DatabaseError, match="Access Denied"):
|
|
||||||
PrestoEngineSpec.get_view_names(database, mock.Mock(), "my_schema")
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_table_names_subtracts_views() -> None:
|
|
||||||
from superset.db_engine_specs.presto import PrestoEngineSpec
|
|
||||||
|
|
||||||
inspector = mock.MagicMock()
|
|
||||||
inspector.get_table_names.return_value = ["t1", "t2", "v1", "v2"]
|
|
||||||
database = mock.MagicMock()
|
|
||||||
database.get_raw_connection().__enter__().cursor().fetchall.return_value = [
|
|
||||||
["v1"],
|
|
||||||
["v2"],
|
|
||||||
]
|
|
||||||
|
|
||||||
result = PrestoEngineSpec.get_table_names(database, inspector, "my_schema")
|
|
||||||
|
|
||||||
assert result == {"t1", "t2"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_table_names_returns_empty_set_for_empty_schema() -> None:
|
|
||||||
from superset.db_engine_specs.presto import PrestoEngineSpec
|
|
||||||
|
|
||||||
inspector = mock.MagicMock()
|
|
||||||
inspector.get_table_names.return_value = []
|
|
||||||
database = mock.MagicMock()
|
|
||||||
database.get_raw_connection().__enter__().cursor().fetchall.return_value = []
|
|
||||||
|
|
||||||
assert PrestoEngineSpec.get_table_names(database, inspector, "empty") == set()
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_create_view_returns_view_definition() -> None:
|
|
||||||
from superset.db_engine_specs.presto import PrestoEngineSpec
|
|
||||||
|
|
||||||
database = mock.MagicMock()
|
|
||||||
cursor = database.get_raw_connection().__enter__().cursor()
|
|
||||||
cursor.fetchall.return_value = [["CREATE VIEW v AS SELECT 1", "b"], ["d"]]
|
|
||||||
|
|
||||||
result = PrestoEngineSpec.get_create_view(database, schema="s", table="v")
|
|
||||||
|
|
||||||
assert result == "CREATE VIEW v AS SELECT 1"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"schema,table,expected_sql",
|
|
||||||
[
|
|
||||||
pytest.param("s", "v", "SHOW CREATE VIEW s.v", id="simple"),
|
|
||||||
pytest.param(
|
|
||||||
"analytics",
|
|
||||||
"daily_users",
|
|
||||||
"SHOW CREATE VIEW analytics.daily_users",
|
|
||||||
id="schema_qualified",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"Raw_2024",
|
|
||||||
"Daily_Active_Users_v2",
|
|
||||||
"SHOW CREATE VIEW Raw_2024.Daily_Active_Users_v2",
|
|
||||||
id="mixed_case_digits_underscores",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_get_create_view_uses_schema_qualified_name(
|
|
||||||
schema: str,
|
|
||||||
table: str,
|
|
||||||
expected_sql: str,
|
|
||||||
) -> None:
|
|
||||||
from superset.db_engine_specs.presto import PrestoEngineSpec
|
|
||||||
|
|
||||||
database = mock.MagicMock()
|
|
||||||
cursor = database.get_raw_connection().__enter__().cursor()
|
|
||||||
cursor.fetchall.return_value = [["CREATE VIEW ..."]]
|
|
||||||
|
|
||||||
PrestoEngineSpec.get_create_view(database, schema=schema, table=table)
|
|
||||||
|
|
||||||
cursor.execute.assert_called_once_with(expected_sql)
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_create_view_returns_none_for_non_view() -> None:
|
|
||||||
from pyhive.exc import DatabaseError
|
|
||||||
|
|
||||||
from superset.db_engine_specs.presto import PrestoEngineSpec
|
|
||||||
|
|
||||||
database = mock.MagicMock()
|
|
||||||
cursor = database.get_raw_connection().__enter__().cursor()
|
|
||||||
cursor.fetchall.side_effect = DatabaseError()
|
|
||||||
|
|
||||||
assert PrestoEngineSpec.get_create_view(database, schema="s", table="t") is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_create_view_propagates_other_errors() -> None:
|
|
||||||
from superset.db_engine_specs.presto import PrestoEngineSpec
|
|
||||||
|
|
||||||
database = mock.MagicMock()
|
|
||||||
cursor = database.get_raw_connection().__enter__().cursor()
|
|
||||||
cursor.execute.side_effect = Exception("connection reset")
|
|
||||||
|
|
||||||
with pytest.raises(Exception, match="connection reset"):
|
|
||||||
PrestoEngineSpec.get_create_view(database, schema="s", table="v")
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("superset.db_engine_specs.presto.PrestoEngineSpec.latest_partition")
|
@mock.patch("superset.db_engine_specs.presto.PrestoEngineSpec.latest_partition")
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
["column_type", "column_value", "expected_value"],
|
["column_type", "column_value", "expected_value"],
|
||||||
@@ -689,650 +445,6 @@ def test_extract_errors_maps_401_to_access_denied() -> None:
|
|||||||
assert result[0].error_type == SupersetErrorType.CONNECTION_ACCESS_DENIED_ERROR
|
assert result[0].error_type == SupersetErrorType.CONNECTION_ACCESS_DENIED_ERROR
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"raw_message,context,expected_error_type,expected_message",
|
|
||||||
[
|
|
||||||
pytest.param(
|
|
||||||
"line 1:8: Column 'bar' cannot be resolved",
|
|
||||||
{},
|
|
||||||
"COLUMN_DOES_NOT_EXIST_ERROR",
|
|
||||||
'We can\'t seem to resolve the column "bar" at line 1:8.',
|
|
||||||
id="column_does_not_exist",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"Table 'default.foo' does not exist",
|
|
||||||
{},
|
|
||||||
"TABLE_DOES_NOT_EXIST_ERROR",
|
|
||||||
"The table \"'default.foo'\" does not exist. "
|
|
||||||
"A valid table must be used to run this query.",
|
|
||||||
id="table_does_not_exist",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"line 1:15: Schema 'bar' does not exist",
|
|
||||||
{},
|
|
||||||
"SCHEMA_DOES_NOT_EXIST_ERROR",
|
|
||||||
'The schema "bar" does not exist. '
|
|
||||||
"A valid schema must be used to run this query.",
|
|
||||||
id="schema_does_not_exist",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"Access Denied: Invalid credentials",
|
|
||||||
{"username": "bob"},
|
|
||||||
"CONNECTION_ACCESS_DENIED_ERROR",
|
|
||||||
'Either the username "bob" or the password is incorrect.',
|
|
||||||
id="access_denied_invalid_credentials",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"presto error: Unexpected status code 401 b'Unauthorized'",
|
|
||||||
{},
|
|
||||||
"CONNECTION_ACCESS_DENIED_ERROR",
|
|
||||||
"Unexpected HTTP 401 response. Check your credentials.",
|
|
||||||
id="access_denied_http_401",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"Failed to establish a new connection: [Errno 8] nodename nor "
|
|
||||||
"servname provided, or not known",
|
|
||||||
{"hostname": "badhost"},
|
|
||||||
"CONNECTION_INVALID_HOSTNAME_ERROR",
|
|
||||||
'The hostname "badhost" cannot be resolved.',
|
|
||||||
id="invalid_hostname",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"Failed to establish a new connection: [Errno 60] Operation timed out",
|
|
||||||
{"hostname": "myhost", "port": 8080},
|
|
||||||
"CONNECTION_HOST_DOWN_ERROR",
|
|
||||||
'The host "myhost" might be down, and can\'t be reached on port 8080.',
|
|
||||||
id="host_down_operation_timed_out",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"Failed to establish a new connection: [Errno 61] Connection refused",
|
|
||||||
{"hostname": "myhost", "port": 8080},
|
|
||||||
"CONNECTION_PORT_CLOSED_ERROR",
|
|
||||||
'Port 8080 on hostname "myhost" refused the connection.',
|
|
||||||
id="port_closed",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"line 1:8: Catalog 'foo' does not exist",
|
|
||||||
{},
|
|
||||||
"CONNECTION_UNKNOWN_DATABASE_ERROR",
|
|
||||||
'Unable to connect to catalog named "foo".',
|
|
||||||
id="unknown_catalog",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_extract_errors_matches_all_custom_error_patterns(
|
|
||||||
raw_message: str,
|
|
||||||
context: dict[str, Any],
|
|
||||||
expected_error_type: str,
|
|
||||||
expected_message: str,
|
|
||||||
) -> None:
|
|
||||||
from superset.db_engine_specs.presto import PrestoEngineSpec
|
|
||||||
from superset.errors import ErrorLevel, SupersetErrorType
|
|
||||||
|
|
||||||
result = PrestoEngineSpec.extract_errors(Exception(raw_message), context=context)
|
|
||||||
|
|
||||||
assert len(result) == 1
|
|
||||||
assert result[0].error_type == getattr(SupersetErrorType, expected_error_type)
|
|
||||||
assert result[0].message == expected_message
|
|
||||||
assert result[0].level == ErrorLevel.ERROR
|
|
||||||
assert result[0].extra is not None
|
|
||||||
assert result[0].extra["engine_name"] == "Presto"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"raw_message,missing_placeholder",
|
|
||||||
[
|
|
||||||
pytest.param(
|
|
||||||
"Access Denied: Invalid credentials", "username", id="access_denied"
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"Failed to establish a new connection: [Errno 8] nodename nor "
|
|
||||||
"servname provided, or not known",
|
|
||||||
"hostname",
|
|
||||||
id="invalid_hostname",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"Failed to establish a new connection: [Errno 60] Operation timed out",
|
|
||||||
"hostname",
|
|
||||||
id="host_down",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"Failed to establish a new connection: [Errno 61] Connection refused",
|
|
||||||
"port",
|
|
||||||
id="port_closed",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_extract_errors_raises_key_error_without_context(
|
|
||||||
raw_message: str,
|
|
||||||
missing_placeholder: str,
|
|
||||||
) -> None:
|
|
||||||
from superset.db_engine_specs.presto import PrestoEngineSpec
|
|
||||||
|
|
||||||
with pytest.raises(KeyError, match=missing_placeholder):
|
|
||||||
PrestoEngineSpec.extract_errors(Exception(raw_message))
|
|
||||||
|
|
||||||
|
|
||||||
def test_extract_errors_returns_first_matching_pattern() -> None:
|
|
||||||
from superset.db_engine_specs.presto import PrestoEngineSpec
|
|
||||||
from superset.errors import SupersetErrorType
|
|
||||||
|
|
||||||
msg = "line 1:8: Table 'x' does not exist and Column 'bar' cannot be resolved"
|
|
||||||
result = PrestoEngineSpec.extract_errors(Exception(msg))
|
|
||||||
|
|
||||||
assert len(result) == 1
|
|
||||||
assert result[0].error_type == SupersetErrorType.COLUMN_DOES_NOT_EXIST_ERROR
|
|
||||||
|
|
||||||
|
|
||||||
def test_extract_errors_falls_back_to_generic_error() -> None:
|
|
||||||
from superset.db_engine_specs.presto import PrestoEngineSpec
|
|
||||||
from superset.errors import ErrorLevel, SupersetErrorType
|
|
||||||
|
|
||||||
result = PrestoEngineSpec.extract_errors(Exception("Generic Error"))
|
|
||||||
|
|
||||||
assert len(result) == 1
|
|
||||||
assert result[0].error_type == SupersetErrorType.GENERIC_DB_ENGINE_ERROR
|
|
||||||
assert result[0].message == "Generic Error"
|
|
||||||
assert result[0].level == ErrorLevel.ERROR
|
|
||||||
assert result[0].extra is not None
|
|
||||||
assert result[0].extra["engine_name"] == "Presto"
|
|
||||||
assert result[0].extra["issue_codes"] == [
|
|
||||||
{
|
|
||||||
"code": 1002,
|
|
||||||
"message": "Issue 1002 - The database returned an unexpected error.",
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_extract_error_message_from_orig_database_error() -> None:
|
|
||||||
from collections import namedtuple
|
|
||||||
|
|
||||||
from superset.db_engine_specs.presto import PrestoEngineSpec
|
|
||||||
|
|
||||||
DatabaseError = namedtuple("DatabaseError", ["error_dict"]) # noqa: N806
|
|
||||||
db_err = DatabaseError(
|
|
||||||
{"errorName": "name", "errorLocation": "location", "message": "msg"}
|
|
||||||
)
|
|
||||||
exception = Exception()
|
|
||||||
exception.orig = db_err # type: ignore[attr-defined]
|
|
||||||
|
|
||||||
assert PrestoEngineSpec._extract_error_message(exception) == "name at location: msg"
|
|
||||||
|
|
||||||
|
|
||||||
def test_extract_error_message_from_database_error_args() -> None:
|
|
||||||
from pyhive.exc import DatabaseError
|
|
||||||
|
|
||||||
from superset.db_engine_specs.presto import PrestoEngineSpec
|
|
||||||
|
|
||||||
exception = DatabaseError({"message": "Err message"})
|
|
||||||
|
|
||||||
assert PrestoEngineSpec._extract_error_message(exception) == "Err message"
|
|
||||||
|
|
||||||
|
|
||||||
def test_extract_error_message_from_database_error_without_message() -> None:
|
|
||||||
from pyhive.exc import DatabaseError
|
|
||||||
|
|
||||||
from superset.db_engine_specs.presto import PrestoEngineSpec
|
|
||||||
|
|
||||||
exception = DatabaseError({"errorName": "SYNTAX_ERROR"})
|
|
||||||
|
|
||||||
assert str(PrestoEngineSpec._extract_error_message(exception)) == (
|
|
||||||
"Unknown Presto Error"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_extract_error_message_from_general_exception() -> None:
|
|
||||||
from superset.db_engine_specs.presto import PrestoEngineSpec
|
|
||||||
|
|
||||||
assert (
|
|
||||||
PrestoEngineSpec._extract_error_message(Exception("Err message"))
|
|
||||||
== "Err message"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_expand_data_returns_input_untouched_when_flag_disabled() -> None:
|
|
||||||
from superset.db_engine_specs.presto import PrestoEngineSpec
|
|
||||||
|
|
||||||
columns: list[ResultSetColumnType] = [
|
|
||||||
{
|
|
||||||
"column_name": "row_column",
|
|
||||||
"name": "row_column",
|
|
||||||
"type": "ROW(NESTED_OBJ VARCHAR)",
|
|
||||||
"is_dttm": False,
|
|
||||||
}
|
|
||||||
]
|
|
||||||
data = [{"row_column": ["a"]}]
|
|
||||||
|
|
||||||
result_columns, result_data, expanded = PrestoEngineSpec.expand_data(columns, data)
|
|
||||||
|
|
||||||
assert result_columns is columns
|
|
||||||
assert result_data is data
|
|
||||||
assert expanded == []
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch.dict(
|
|
||||||
"superset.extensions.feature_flag_manager._feature_flags",
|
|
||||||
{"PRESTO_EXPAND_DATA": True},
|
|
||||||
clear=True,
|
|
||||||
)
|
|
||||||
def test_expand_data_flattens_deeply_nested_row_columns() -> None:
|
|
||||||
from superset.db_engine_specs.presto import PrestoEngineSpec
|
|
||||||
|
|
||||||
columns: list[ResultSetColumnType] = [
|
|
||||||
{
|
|
||||||
"column_name": "r",
|
|
||||||
"name": "r",
|
|
||||||
"type": "ROW(L1 ROW(L2 ROW(L3 ROW(L4 VARCHAR))))",
|
|
||||||
"is_dttm": False,
|
|
||||||
}
|
|
||||||
]
|
|
||||||
data = [{"r": [[[["deep"]]]]}]
|
|
||||||
|
|
||||||
result_columns, result_data, expanded = PrestoEngineSpec.expand_data(columns, data)
|
|
||||||
|
|
||||||
assert [column["column_name"] for column in result_columns] == [
|
|
||||||
"r",
|
|
||||||
"r.l1",
|
|
||||||
"r.l1.l2",
|
|
||||||
"r.l1.l2.l3",
|
|
||||||
"r.l1.l2.l3.l4",
|
|
||||||
]
|
|
||||||
assert [column["column_name"] for column in expanded] == [
|
|
||||||
"r.l1",
|
|
||||||
"r.l1.l2",
|
|
||||||
"r.l1.l2.l3",
|
|
||||||
"r.l1.l2.l3.l4",
|
|
||||||
]
|
|
||||||
assert result_data[0]["r.l1.l2.l3.l4"] == "deep"
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch.dict(
|
|
||||||
"superset.extensions.feature_flag_manager._feature_flags",
|
|
||||||
{"PRESTO_EXPAND_DATA": True},
|
|
||||||
clear=True,
|
|
||||||
)
|
|
||||||
def test_expand_data_raises_on_malformed_json_in_array_column() -> None:
|
|
||||||
from superset.db_engine_specs.presto import PrestoEngineSpec
|
|
||||||
|
|
||||||
columns: list[ResultSetColumnType] = [
|
|
||||||
{
|
|
||||||
"column_name": "array_column",
|
|
||||||
"name": "array_column",
|
|
||||||
"type": "ARRAY(BIGINT)",
|
|
||||||
"is_dttm": False,
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="Expecting value"):
|
|
||||||
PrestoEngineSpec.expand_data(columns, [{"array_column": "not json"}])
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch.dict(
|
|
||||||
"superset.extensions.feature_flag_manager._feature_flags",
|
|
||||||
{"PRESTO_EXPAND_DATA": True},
|
|
||||||
clear=True,
|
|
||||||
)
|
|
||||||
def test_expand_data_raises_on_malformed_json_in_row_column() -> None:
|
|
||||||
from superset.db_engine_specs.presto import PrestoEngineSpec
|
|
||||||
|
|
||||||
columns: list[ResultSetColumnType] = [
|
|
||||||
{
|
|
||||||
"column_name": "row_column",
|
|
||||||
"name": "row_column",
|
|
||||||
"type": "ROW(NESTED_OBJ VARCHAR)",
|
|
||||||
"is_dttm": False,
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="Expecting value"):
|
|
||||||
PrestoEngineSpec.expand_data(columns, [{"row_column": "not json"}])
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_function_names_lists_presto_functions() -> None:
|
|
||||||
from superset.db_engine_specs.presto import PrestoEngineSpec
|
|
||||||
|
|
||||||
database = mock.MagicMock()
|
|
||||||
database.get_df.return_value = pd.DataFrame(
|
|
||||||
{"Function": ["abs", "avg", "cardinality"]}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert PrestoEngineSpec.get_function_names(database) == [
|
|
||||||
"abs",
|
|
||||||
"avg",
|
|
||||||
"cardinality",
|
|
||||||
]
|
|
||||||
database.get_df.assert_called_once_with("SHOW FUNCTIONS")
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_function_names_returns_empty_list_for_no_functions() -> None:
|
|
||||||
from superset.db_engine_specs.presto import PrestoEngineSpec
|
|
||||||
|
|
||||||
database = mock.MagicMock()
|
|
||||||
database.get_df.return_value = pd.DataFrame({"Function": []})
|
|
||||||
|
|
||||||
assert PrestoEngineSpec.get_function_names(database) == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_function_names_raises_on_dataframe_without_function_column() -> None:
|
|
||||||
from superset.db_engine_specs.presto import PrestoEngineSpec
|
|
||||||
|
|
||||||
database = mock.MagicMock()
|
|
||||||
database.get_df.return_value = pd.DataFrame()
|
|
||||||
|
|
||||||
with pytest.raises(KeyError, match="Function"):
|
|
||||||
PrestoEngineSpec.get_function_names(database)
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_function_names_propagates_connection_error() -> None:
|
|
||||||
from superset.db_engine_specs.presto import PrestoEngineSpec
|
|
||||||
|
|
||||||
database = mock.MagicMock()
|
|
||||||
database.get_df.side_effect = Exception("Connection refused")
|
|
||||||
|
|
||||||
with pytest.raises(Exception, match="Connection refused"):
|
|
||||||
PrestoEngineSpec.get_function_names(database)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"extra,expected",
|
|
||||||
[
|
|
||||||
pytest.param({}, False, id="no_version_key"),
|
|
||||||
pytest.param({"version": None}, False, id="version_none"),
|
|
||||||
pytest.param({"version": "0.318"}, False, id="just_below_gate"),
|
|
||||||
pytest.param({"version": "0.319"}, True, id="exactly_at_gate"),
|
|
||||||
pytest.param({"version": "0.400"}, True, id="above_gate"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_get_allow_cost_estimate_version_gate(
|
|
||||||
extra: dict[str, Any],
|
|
||||||
expected: bool,
|
|
||||||
) -> None:
|
|
||||||
from superset.db_engine_specs.presto import PrestoEngineSpec
|
|
||||||
|
|
||||||
assert PrestoEngineSpec.get_allow_cost_estimate(extra) is expected
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_allow_cost_estimate_rejects_unparseable_version() -> None:
|
|
||||||
from packaging.version import InvalidVersion
|
|
||||||
|
|
||||||
from superset.db_engine_specs.presto import PrestoEngineSpec
|
|
||||||
|
|
||||||
with pytest.raises(InvalidVersion):
|
|
||||||
PrestoEngineSpec.get_allow_cost_estimate({"version": "not-a-version"})
|
|
||||||
|
|
||||||
|
|
||||||
def test_estimate_statement_cost() -> None:
|
|
||||||
from superset.db_engine_specs.presto import PrestoEngineSpec
|
|
||||||
|
|
||||||
cursor = mock.MagicMock()
|
|
||||||
cursor.fetchone.return_value = ['{"a": "b"}']
|
|
||||||
|
|
||||||
result = PrestoEngineSpec.estimate_statement_cost(
|
|
||||||
mock.MagicMock(), "SELECT * FROM birth_names", cursor
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result == {"a": "b"}
|
|
||||||
cursor.execute.assert_called_once_with(
|
|
||||||
"EXPLAIN (TYPE IO, FORMAT JSON) SELECT * FROM birth_names"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_estimate_statement_cost_propagates_execute_failure() -> None:
|
|
||||||
from superset.db_engine_specs.presto import PrestoEngineSpec
|
|
||||||
|
|
||||||
cursor = mock.MagicMock()
|
|
||||||
cursor.execute.side_effect = Exception("line 1:1: mismatched input 'DROP'")
|
|
||||||
|
|
||||||
with pytest.raises(Exception, match="mismatched input"):
|
|
||||||
PrestoEngineSpec.estimate_statement_cost(
|
|
||||||
mock.MagicMock(), "DROP TABLE birth_names", cursor
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_query_cost_formatter() -> None:
|
|
||||||
from superset.db_engine_specs.presto import PrestoEngineSpec
|
|
||||||
|
|
||||||
raw_cost = [
|
|
||||||
{
|
|
||||||
"estimate": {
|
|
||||||
"outputRowCount": 9.04969899e8,
|
|
||||||
"outputSizeInBytes": 3.54143678301e11,
|
|
||||||
"cpuCost": 3.54143678301e11,
|
|
||||||
"maxMemory": 0.0,
|
|
||||||
"networkCost": 3.54143678301e11,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
assert PrestoEngineSpec.query_cost_formatter(raw_cost) == [
|
|
||||||
{
|
|
||||||
"Output count": "904 M rows",
|
|
||||||
"Output size": "354 GB",
|
|
||||||
"CPU cost": "354 G",
|
|
||||||
"Max memory": "0 B",
|
|
||||||
"Network cost": "354 G",
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_query_cost_formatter_omits_missing_estimate_keys() -> None:
|
|
||||||
from superset.db_engine_specs.presto import PrestoEngineSpec
|
|
||||||
|
|
||||||
raw_cost = [{"estimate": {"outputRowCount": 1234.0}}, {}]
|
|
||||||
|
|
||||||
assert PrestoEngineSpec.query_cost_formatter(raw_cost) == [
|
|
||||||
{"Output count": "1 K rows"},
|
|
||||||
{},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_query_cost_formatter_raises_on_null_estimate_value() -> None:
|
|
||||||
from superset.db_engine_specs.presto import PrestoEngineSpec
|
|
||||||
|
|
||||||
with pytest.raises(TypeError):
|
|
||||||
PrestoEngineSpec.query_cost_formatter(
|
|
||||||
[{"estimate": {"outputRowCount": None, "outputSizeInBytes": 1.0}}]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_estimate_query_cost_raises_when_version_too_old(
|
|
||||||
mocker: MockerFixture,
|
|
||||||
) -> None:
|
|
||||||
from superset.db_engine_specs.presto import PrestoEngineSpec
|
|
||||||
|
|
||||||
database = mocker.MagicMock()
|
|
||||||
database.get_extra.return_value = {"version": "0.318"}
|
|
||||||
|
|
||||||
with pytest.raises(Exception, match="Database does not support cost estimation"):
|
|
||||||
PrestoEngineSpec.estimate_query_cost(
|
|
||||||
database, "hive", "default", "SELECT 1", None
|
|
||||||
)
|
|
||||||
|
|
||||||
database.get_raw_connection.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
def test_estimate_query_cost_estimates_each_statement(
|
|
||||||
mocker: MockerFixture,
|
|
||||||
) -> None:
|
|
||||||
from superset.db_engine_specs.presto import PrestoEngineSpec
|
|
||||||
|
|
||||||
database = mocker.MagicMock()
|
|
||||||
database.get_extra.return_value = {"version": "0.400"}
|
|
||||||
database.mutate_sql_based_on_config.side_effect = lambda sql, **_kwargs: sql
|
|
||||||
cursor = mock.MagicMock()
|
|
||||||
cursor.fetchone.side_effect = [
|
|
||||||
['{"estimate": {"outputRowCount": 1.0}}'],
|
|
||||||
['{"estimate": {"outputRowCount": 2.0}}'],
|
|
||||||
]
|
|
||||||
database.get_raw_connection.return_value.__enter__.return_value.cursor.return_value = ( # noqa: E501
|
|
||||||
cursor
|
|
||||||
)
|
|
||||||
|
|
||||||
result = PrestoEngineSpec.estimate_query_cost(
|
|
||||||
database, "hive", "default", "SELECT 1; SELECT 2", None
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result == [
|
|
||||||
{"estimate": {"outputRowCount": 1.0}},
|
|
||||||
{"estimate": {"outputRowCount": 2.0}},
|
|
||||||
]
|
|
||||||
assert cursor.execute.call_args_list == [
|
|
||||||
mock.call("EXPLAIN (TYPE IO, FORMAT JSON) SELECT\n 1"),
|
|
||||||
mock.call("EXPLAIN (TYPE IO, FORMAT JSON) SELECT\n 2"),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
TRACKING_URL = (
|
|
||||||
"https://presto.example.com:8080/ui/query.html?20220101_120000_00001_abcde"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _presto_cursor() -> mock.MagicMock:
|
|
||||||
cursor = mock.MagicMock()
|
|
||||||
cursor._protocol = "https"
|
|
||||||
cursor._host = "presto.example.com"
|
|
||||||
cursor._port = 8080
|
|
||||||
cursor.last_query_id = "20220101_120000_00001_abcde"
|
|
||||||
return cursor
|
|
||||||
|
|
||||||
|
|
||||||
def _handle_cursor_query(
|
|
||||||
mocker: MockerFixture,
|
|
||||||
) -> tuple[mock.MagicMock, mock.MagicMock]:
|
|
||||||
from superset.common.db_query_status import QueryStatus
|
|
||||||
|
|
||||||
mock_db = mocker.patch("superset.db_engine_specs.presto.db")
|
|
||||||
query = mock.MagicMock()
|
|
||||||
query.id = 42
|
|
||||||
query.progress = 0
|
|
||||||
query.status = QueryStatus.RUNNING
|
|
||||||
query.database.connect_args = {"poll_interval": 0}
|
|
||||||
mock_db.session.query.return_value.filter_by.return_value.one.return_value = query
|
|
||||||
return mock_db, query
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_tracking_url_builds_presto_ui_link() -> None:
|
|
||||||
from superset.db_engine_specs.presto import PrestoEngineSpec
|
|
||||||
|
|
||||||
assert PrestoEngineSpec.get_tracking_url(_presto_cursor()) == TRACKING_URL
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_tracking_url_returns_none_for_falsy_query_id() -> None:
|
|
||||||
from superset.db_engine_specs.presto import PrestoEngineSpec
|
|
||||||
|
|
||||||
cursor = _presto_cursor()
|
|
||||||
cursor.last_query_id = None
|
|
||||||
|
|
||||||
assert PrestoEngineSpec.get_tracking_url(cursor) is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_tracking_url_returns_none_when_attribute_absent() -> None:
|
|
||||||
from superset.db_engine_specs.presto import PrestoEngineSpec
|
|
||||||
|
|
||||||
assert PrestoEngineSpec.get_tracking_url(mock.Mock(spec=[])) is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_handle_cursor_records_tracking_url_and_progress(
|
|
||||||
mocker: MockerFixture,
|
|
||||||
) -> None:
|
|
||||||
from superset.db_engine_specs.presto import PrestoEngineSpec
|
|
||||||
|
|
||||||
mock_db, query = _handle_cursor_query(mocker)
|
|
||||||
cursor = _presto_cursor()
|
|
||||||
cursor.poll.side_effect = [
|
|
||||||
{"stats": {"state": "RUNNING", "completedSplits": 5, "totalSplits": 10}},
|
|
||||||
None,
|
|
||||||
]
|
|
||||||
|
|
||||||
PrestoEngineSpec.handle_cursor(cursor, query)
|
|
||||||
|
|
||||||
assert query.tracking_url == TRACKING_URL
|
|
||||||
assert query.progress == 50.0
|
|
||||||
assert cursor.poll.call_count == 2
|
|
||||||
cursor.cancel.assert_not_called()
|
|
||||||
assert mock_db.session.commit.called
|
|
||||||
|
|
||||||
|
|
||||||
def test_handle_cursor_stops_polling_when_query_finished(
|
|
||||||
mocker: MockerFixture,
|
|
||||||
) -> None:
|
|
||||||
from superset.db_engine_specs.presto import PrestoEngineSpec
|
|
||||||
|
|
||||||
_mock_db, query = _handle_cursor_query(mocker)
|
|
||||||
cursor = _presto_cursor()
|
|
||||||
cursor.poll.side_effect = [{"stats": {"state": "FINISHED"}}]
|
|
||||||
|
|
||||||
PrestoEngineSpec.handle_cursor(cursor, query)
|
|
||||||
|
|
||||||
assert query.progress == 0
|
|
||||||
assert cursor.poll.call_count == 1
|
|
||||||
cursor.cancel.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("status", ["STOPPED", "TIMED_OUT"])
|
|
||||||
def test_handle_cursor_cancels_when_user_stops_query(
|
|
||||||
mocker: MockerFixture,
|
|
||||||
status: str,
|
|
||||||
) -> None:
|
|
||||||
from superset.common.db_query_status import QueryStatus
|
|
||||||
from superset.db_engine_specs.presto import PrestoEngineSpec
|
|
||||||
|
|
||||||
mock_db, running_query = _handle_cursor_query(mocker)
|
|
||||||
stopped_query = mock.MagicMock()
|
|
||||||
stopped_query.id = running_query.id
|
|
||||||
stopped_query.progress = 0
|
|
||||||
stopped_query.status = getattr(QueryStatus, status)
|
|
||||||
mock_db.session.query.return_value.filter_by.return_value.one.return_value = (
|
|
||||||
stopped_query
|
|
||||||
)
|
|
||||||
cursor = _presto_cursor()
|
|
||||||
cursor.poll.side_effect = [
|
|
||||||
{"stats": {"state": "RUNNING", "completedSplits": 5, "totalSplits": 10}},
|
|
||||||
]
|
|
||||||
order = mock.Mock()
|
|
||||||
order.attach_mock(mock_db.session.query, "reload")
|
|
||||||
order.attach_mock(cursor.cancel, "cancel")
|
|
||||||
|
|
||||||
PrestoEngineSpec.handle_cursor(cursor, running_query)
|
|
||||||
|
|
||||||
cursor.cancel.assert_called_once_with()
|
|
||||||
mock_db.session.query.return_value.filter_by.assert_called_once_with(
|
|
||||||
id=running_query.id
|
|
||||||
)
|
|
||||||
call_names = [call[0] for call in order.mock_calls]
|
|
||||||
assert call_names.index("cancel") > call_names.index("reload")
|
|
||||||
assert running_query.status == QueryStatus.RUNNING
|
|
||||||
assert stopped_query.progress == 0
|
|
||||||
assert cursor.poll.call_count == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_handle_cursor_ignores_empty_stats(mocker: MockerFixture) -> None:
|
|
||||||
from superset.db_engine_specs.presto import PrestoEngineSpec
|
|
||||||
|
|
||||||
_mock_db, query = _handle_cursor_query(mocker)
|
|
||||||
query.progress = 25
|
|
||||||
cursor = _presto_cursor()
|
|
||||||
cursor.poll.side_effect = [{"stats": {}}, None]
|
|
||||||
|
|
||||||
PrestoEngineSpec.handle_cursor(cursor, query)
|
|
||||||
|
|
||||||
assert query.progress == 25
|
|
||||||
assert cursor.poll.call_count == 2
|
|
||||||
|
|
||||||
|
|
||||||
def test_handle_cursor_raises_type_error_on_missing_split_counts(
|
|
||||||
mocker: MockerFixture,
|
|
||||||
) -> None:
|
|
||||||
from superset.db_engine_specs.presto import PrestoEngineSpec
|
|
||||||
|
|
||||||
_mock_db, query = _handle_cursor_query(mocker)
|
|
||||||
cursor = _presto_cursor()
|
|
||||||
cursor.poll.side_effect = [{"stats": {"state": "RUNNING"}}]
|
|
||||||
|
|
||||||
with pytest.raises(TypeError):
|
|
||||||
PrestoEngineSpec.handle_cursor(cursor, query)
|
|
||||||
|
|
||||||
|
|
||||||
def test_latest_sub_partition_rejects_unknown_field(
|
def test_latest_sub_partition_rejects_unknown_field(
|
||||||
mocker: MockerFixture,
|
mocker: MockerFixture,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -976,17 +976,6 @@ class TestRequestSchemaAliasChoices:
|
|||||||
)
|
)
|
||||||
assert req.select_columns == ["id", "dashboard_title"]
|
assert req.select_columns == ["id", "dashboard_title"]
|
||||||
|
|
||||||
def test_get_dashboard_info_accepts_filter_state(self) -> None:
|
|
||||||
applied = {"applied_filters": [{"col": "gender", "op": "IN", "val": ["F"]}]}
|
|
||||||
req = GetDashboardInfoRequest.model_validate(
|
|
||||||
{"identifier": 42, "filter_state": applied}
|
|
||||||
)
|
|
||||||
assert req.filter_state == applied
|
|
||||||
|
|
||||||
def test_get_dashboard_info_filter_state_defaults_none(self) -> None:
|
|
||||||
req = GetDashboardInfoRequest.model_validate({"identifier": 42})
|
|
||||||
assert req.filter_state is None
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"payload",
|
"payload",
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ from superset.mcp_service.app import mcp
|
|||||||
from superset.mcp_service.dashboard.schemas import (
|
from superset.mcp_service.dashboard.schemas import (
|
||||||
DashboardError,
|
DashboardError,
|
||||||
DashboardInfo,
|
DashboardInfo,
|
||||||
DEFAULT_GET_DASHBOARD_INFO_COLUMNS,
|
|
||||||
ListDashboardsRequest,
|
ListDashboardsRequest,
|
||||||
)
|
)
|
||||||
from superset.utils import json
|
from superset.utils import json
|
||||||
@@ -1587,168 +1586,3 @@ async def test_list_dashboards_no_arguments(mock_list, mcp_server):
|
|||||||
result = await client.call_tool("list_dashboards", {})
|
result = await client.call_tool("list_dashboards", {})
|
||||||
data = json.loads(result.content[0].text)
|
data = json.loads(result.content[0].text)
|
||||||
assert "dashboards" in data
|
assert "dashboards" in data
|
||||||
|
|
||||||
|
|
||||||
def _minimal_dashboard() -> Mock:
|
|
||||||
dashboard = Mock()
|
|
||||||
dashboard.id = 1
|
|
||||||
dashboard.dashboard_title = "Test Dashboard"
|
|
||||||
dashboard.slug = "test-dashboard"
|
|
||||||
dashboard.description = None
|
|
||||||
dashboard.css = None
|
|
||||||
dashboard.certified_by = None
|
|
||||||
dashboard.certification_details = None
|
|
||||||
dashboard.json_metadata = json.dumps({"native_filter_configuration": []})
|
|
||||||
dashboard.published = True
|
|
||||||
dashboard.is_managed_externally = False
|
|
||||||
dashboard.external_url = None
|
|
||||||
dashboard.created_on = None
|
|
||||||
dashboard.changed_on = None
|
|
||||||
dashboard.created_by = None
|
|
||||||
dashboard.changed_by = None
|
|
||||||
dashboard.uuid = "dashboard-uuid-1"
|
|
||||||
dashboard.url = "/dashboard/1"
|
|
||||||
dashboard.thumbnail_url = None
|
|
||||||
dashboard.created_on_humanized = None
|
|
||||||
dashboard.changed_on_humanized = None
|
|
||||||
dashboard.slices = []
|
|
||||||
dashboard.editors = []
|
|
||||||
dashboard.tags = []
|
|
||||||
dashboard.embedded = []
|
|
||||||
dashboard.charts = []
|
|
||||||
return dashboard
|
|
||||||
|
|
||||||
|
|
||||||
@patch("superset.daos.dashboard.DashboardDAO.find_by_id")
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_get_dashboard_info_direct_filter_state(mock_info, mcp_server):
|
|
||||||
"""filter_state supplied directly (no permalink) is attached to the result."""
|
|
||||||
mock_info.return_value = _minimal_dashboard()
|
|
||||||
filter_state = {"applied_filters": [{"col": "gender", "op": "IN", "val": ["F"]}]}
|
|
||||||
with patch(
|
|
||||||
"superset.mcp_service.dashboard.schemas.user_can_view_data_model_metadata",
|
|
||||||
return_value=True,
|
|
||||||
):
|
|
||||||
async with Client(mcp_server) as client:
|
|
||||||
result = await client.call_tool(
|
|
||||||
"get_dashboard_info",
|
|
||||||
{"request": {"identifier": 1, "filter_state": filter_state}},
|
|
||||||
)
|
|
||||||
assert result.data["is_permalink_state"] is False
|
|
||||||
assert result.data["permalink_key"] is None
|
|
||||||
assert result.data["filter_state"]["applied_filters"][0]["col"] == "gender"
|
|
||||||
|
|
||||||
|
|
||||||
@patch("superset.daos.dashboard.DashboardDAO.find_by_id")
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_direct_filter_state_redacts_data_model_metadata(mock_info, mcp_server):
|
|
||||||
"""A caller without data-model metadata access gets dataMask/chartStates
|
|
||||||
stripped from a directly supplied filter_state, as on the permalink path."""
|
|
||||||
mock_info.return_value = _minimal_dashboard()
|
|
||||||
filter_state = {
|
|
||||||
"applied_filters": [{"col": "gender", "op": "IN", "val": ["F"]}],
|
|
||||||
"dataMask": {"native-1": {}},
|
|
||||||
"chartStates": {"c1": {}},
|
|
||||||
}
|
|
||||||
with (
|
|
||||||
patch(
|
|
||||||
"superset.mcp_service.dashboard.schemas.user_can_view_data_model_metadata",
|
|
||||||
return_value=True,
|
|
||||||
),
|
|
||||||
patch(
|
|
||||||
"superset.mcp_service.dashboard.tool.get_dashboard_info."
|
|
||||||
"user_can_view_data_model_metadata",
|
|
||||||
return_value=False,
|
|
||||||
),
|
|
||||||
):
|
|
||||||
async with Client(mcp_server) as client:
|
|
||||||
result = await client.call_tool(
|
|
||||||
"get_dashboard_info",
|
|
||||||
{"request": {"identifier": 1, "filter_state": filter_state}},
|
|
||||||
)
|
|
||||||
assert "dataMask" not in result.data["filter_state"]
|
|
||||||
assert "chartStates" not in result.data["filter_state"]
|
|
||||||
assert "applied_filters" in result.data["filter_state"]
|
|
||||||
|
|
||||||
|
|
||||||
@patch("superset.mcp_service.dashboard.permalink.get_dashboard_permalink")
|
|
||||||
@patch("superset.daos.dashboard.DashboardDAO.find_by_id")
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_get_dashboard_info_permalink_wins_over_filter_state(
|
|
||||||
mock_info, mock_permalink, mcp_server
|
|
||||||
):
|
|
||||||
"""When both are given, permalink_key takes precedence over filter_state."""
|
|
||||||
mock_info.return_value = _minimal_dashboard()
|
|
||||||
mock_permalink.return_value = (
|
|
||||||
"permalink-1",
|
|
||||||
{"dashboardId": "1", "state": {"dataMask": {"native-filter-1": {}}}},
|
|
||||||
)
|
|
||||||
filter_state = {"applied_filters": [{"col": "gender", "op": "IN", "val": ["F"]}]}
|
|
||||||
with (
|
|
||||||
patch(
|
|
||||||
"superset.mcp_service.dashboard.schemas.user_can_view_data_model_metadata",
|
|
||||||
return_value=True,
|
|
||||||
),
|
|
||||||
patch(
|
|
||||||
"superset.mcp_service.dashboard.permalink."
|
|
||||||
"user_can_view_data_model_metadata",
|
|
||||||
return_value=True,
|
|
||||||
),
|
|
||||||
):
|
|
||||||
async with Client(mcp_server) as client:
|
|
||||||
result = await client.call_tool(
|
|
||||||
"get_dashboard_info",
|
|
||||||
{
|
|
||||||
"request": {
|
|
||||||
"identifier": 1,
|
|
||||||
"permalink_key": "permalink-1",
|
|
||||||
"filter_state": filter_state,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
assert result.data["permalink_key"] == "permalink-1"
|
|
||||||
assert "dataMask" in result.data["filter_state"]
|
|
||||||
assert "applied_filters" not in result.data["filter_state"]
|
|
||||||
|
|
||||||
|
|
||||||
@patch("superset.daos.dashboard.DashboardDAO.find_by_id")
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_direct_empty_filter_state_is_honored(mock_info, mcp_server):
|
|
||||||
"""An explicit empty {} filter_state is a cleared context, not an absent one."""
|
|
||||||
mock_info.return_value = _minimal_dashboard()
|
|
||||||
with patch(
|
|
||||||
"superset.mcp_service.dashboard.schemas.user_can_view_data_model_metadata",
|
|
||||||
return_value=True,
|
|
||||||
):
|
|
||||||
async with Client(mcp_server) as client:
|
|
||||||
result = await client.call_tool(
|
|
||||||
"get_dashboard_info",
|
|
||||||
{"request": {"identifier": 1, "filter_state": {}}},
|
|
||||||
)
|
|
||||||
assert result.data["is_permalink_state"] is False
|
|
||||||
assert result.data["filter_state"] == {}
|
|
||||||
|
|
||||||
|
|
||||||
@patch("superset.daos.dashboard.DashboardDAO.find_by_id")
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_explicit_default_columns_excludes_filter_state(mock_info, mcp_server):
|
|
||||||
"""A caller who explicitly projects the default columns keeps that projection:
|
|
||||||
filter_state is not force-appended even though the values equal the defaults."""
|
|
||||||
mock_info.return_value = _minimal_dashboard()
|
|
||||||
filter_state = {"applied_filters": [{"col": "gender", "op": "IN", "val": ["F"]}]}
|
|
||||||
with patch(
|
|
||||||
"superset.mcp_service.dashboard.schemas.user_can_view_data_model_metadata",
|
|
||||||
return_value=True,
|
|
||||||
):
|
|
||||||
async with Client(mcp_server) as client:
|
|
||||||
result = await client.call_tool(
|
|
||||||
"get_dashboard_info",
|
|
||||||
{
|
|
||||||
"request": {
|
|
||||||
"identifier": 1,
|
|
||||||
"filter_state": filter_state,
|
|
||||||
"select_columns": list(DEFAULT_GET_DASHBOARD_INFO_COLUMNS),
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
assert "filter_state" not in result.data
|
|
||||||
|
|||||||
@@ -2296,26 +2296,3 @@ def test_prequery_listener_mutation_race_deterministic(
|
|||||||
assert not t_b.is_alive(), "thread B deadlocked"
|
assert not t_b.is_alive(), "thread B deadlocked"
|
||||||
|
|
||||||
assert not errors, f"deterministic interleaving raised: {errors!r}"
|
assert not errors, f"deterministic interleaving raised: {errors!r}"
|
||||||
|
|
||||||
|
|
||||||
def test_function_names_returns_engine_spec_functions(mocker: MockerFixture) -> None:
|
|
||||||
database = Database(database_name="db", sqlalchemy_uri="sqlite://")
|
|
||||||
spec = mocker.MagicMock()
|
|
||||||
spec.get_function_names.return_value = ["abs", "avg", "cardinality"]
|
|
||||||
database.get_db_engine_spec = mocker.MagicMock(return_value=spec)
|
|
||||||
|
|
||||||
assert database.function_names == ["abs", "avg", "cardinality"]
|
|
||||||
spec.get_function_names.assert_called_once_with(database)
|
|
||||||
|
|
||||||
|
|
||||||
def test_function_names_returns_empty_list_when_engine_spec_raises(
|
|
||||||
mocker: MockerFixture,
|
|
||||||
) -> None:
|
|
||||||
database = Database(database_name="db", sqlalchemy_uri="sqlite://")
|
|
||||||
spec = mocker.MagicMock()
|
|
||||||
spec.get_function_names.side_effect = Exception("Connection refused")
|
|
||||||
database.get_db_engine_spec = mocker.MagicMock(return_value=spec)
|
|
||||||
logger = mocker.patch("superset.models.core.logger")
|
|
||||||
|
|
||||||
assert database.function_names == []
|
|
||||||
assert logger.error.called
|
|
||||||
|
|||||||
@@ -17,7 +17,6 @@
|
|||||||
from unittest.mock import PropertyMock
|
from unittest.mock import PropertyMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from jinja2.exceptions import TemplateSyntaxError
|
|
||||||
from pytest_mock import MockerFixture
|
from pytest_mock import MockerFixture
|
||||||
from sqlalchemy.orm.session import Session
|
from sqlalchemy.orm.session import Session
|
||||||
|
|
||||||
@@ -197,106 +196,3 @@ def test_delete_tags_command_not_found_reports_normalized_messages(
|
|||||||
messages = excinfo.value.normalized_messages()
|
messages = excinfo.value.normalized_messages()
|
||||||
assert "tags" in messages
|
assert "tags" in messages
|
||||||
assert "not found" in messages["tags"][0]
|
assert "not found" in messages["tags"][0]
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def session_with_data(session: Session):
|
|
||||||
from superset.models.core import Database
|
|
||||||
from superset.models.sql_lab import SavedQuery
|
|
||||||
from superset.tags.models import ObjectType, Tag, TaggedObject
|
|
||||||
|
|
||||||
engine = session.get_bind()
|
|
||||||
Tag.metadata.create_all(engine) # pylint: disable=no-member
|
|
||||||
|
|
||||||
database = Database(database_name="my_database", sqlalchemy_uri="postgresql://")
|
|
||||||
|
|
||||||
saved_query = SavedQuery(
|
|
||||||
id=1, label="test_query", database=database, sql="select {{ unclosed"
|
|
||||||
)
|
|
||||||
|
|
||||||
tag = Tag(name="test_name", description="test_description")
|
|
||||||
|
|
||||||
session.add(database)
|
|
||||||
session.add(saved_query)
|
|
||||||
session.add(tag)
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
session.add(
|
|
||||||
TaggedObject(object_id=saved_query.id, object_type=ObjectType.query, tag=tag)
|
|
||||||
)
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
return session
|
|
||||||
|
|
||||||
|
|
||||||
def test_delete_command_query_template_error_becomes_validation_error(
|
|
||||||
session_with_data: Session, mocker: MockerFixture
|
|
||||||
):
|
|
||||||
"""Regression test: a Jinja ``TemplateError`` raised while authorizing a
|
|
||||||
query must surface as ``TagInvalidError`` (422), not an opaque 500 -- and
|
|
||||||
it must be composited as a ``ValidationError`` so ``normalized_messages()``
|
|
||||||
(called by the single-object DELETE route) aggregates it instead of raising
|
|
||||||
``AttributeError``.
|
|
||||||
|
|
||||||
``raise_for_access`` is mocked directly so the test stays hermetic and does
|
|
||||||
not depend on a live database to reach ``process_jinja_sql``.
|
|
||||||
"""
|
|
||||||
from superset.commands.tag.delete import DeleteTaggedObjectCommand
|
|
||||||
from superset.commands.tag.exceptions import TagInvalidError
|
|
||||||
from superset.models.sql_lab import SavedQuery
|
|
||||||
from superset.tags.models import ObjectType
|
|
||||||
|
|
||||||
query = session_with_data.query(SavedQuery).first()
|
|
||||||
|
|
||||||
template_error_message = "unexpected end of template"
|
|
||||||
mocker.patch(
|
|
||||||
"superset.security.SupersetSecurityManager.raise_for_access",
|
|
||||||
side_effect=TemplateSyntaxError(template_error_message, lineno=1),
|
|
||||||
)
|
|
||||||
|
|
||||||
with pytest.raises(TagInvalidError) as excinfo:
|
|
||||||
DeleteTaggedObjectCommand(
|
|
||||||
object_type=ObjectType.query,
|
|
||||||
object_id=query.id,
|
|
||||||
tag="test_name",
|
|
||||||
).validate()
|
|
||||||
|
|
||||||
# Must aggregate via the public accessor (proves it is a ValidationError),
|
|
||||||
# and the real template error text must be preserved for server-side debugging.
|
|
||||||
messages = excinfo.value.normalized_messages()
|
|
||||||
assert "tags" in messages
|
|
||||||
assert template_error_message in " ".join(messages["tags"])
|
|
||||||
|
|
||||||
|
|
||||||
def test_delete_command_query_parse_error_becomes_validation_error(
|
|
||||||
session_with_data: Session, mocker: MockerFixture
|
|
||||||
):
|
|
||||||
"""A ``SupersetParseError`` (unresolvable partition macro) raised from the
|
|
||||||
same ``raise_for_access`` call is a ``SupersetErrorException`` sibling --
|
|
||||||
not a ``TemplateError`` -- so it was previously uncaught and swallowed into
|
|
||||||
a 500. It must be caught alongside ``TemplateError`` and surfaced as a 422.
|
|
||||||
"""
|
|
||||||
from superset.commands.tag.delete import DeleteTaggedObjectCommand
|
|
||||||
from superset.commands.tag.exceptions import TagInvalidError
|
|
||||||
from superset.exceptions import SupersetParseError
|
|
||||||
from superset.models.sql_lab import SavedQuery
|
|
||||||
from superset.tags.models import ObjectType
|
|
||||||
|
|
||||||
query = session_with_data.query(SavedQuery).first()
|
|
||||||
|
|
||||||
parse_error_message = "cannot statically determine table for partition macro"
|
|
||||||
mocker.patch(
|
|
||||||
"superset.security.SupersetSecurityManager.raise_for_access",
|
|
||||||
side_effect=SupersetParseError(sql="SELECT 1", message=parse_error_message),
|
|
||||||
)
|
|
||||||
|
|
||||||
with pytest.raises(TagInvalidError) as excinfo:
|
|
||||||
DeleteTaggedObjectCommand(
|
|
||||||
object_type=ObjectType.query,
|
|
||||||
object_id=query.id,
|
|
||||||
tag="test_name",
|
|
||||||
).validate()
|
|
||||||
|
|
||||||
messages = excinfo.value.normalized_messages()
|
|
||||||
assert "tags" in messages
|
|
||||||
assert parse_error_message in " ".join(messages["tags"])
|
|
||||||
|
|||||||
Reference in New Issue
Block a user