mirror of
https://github.com/apache/superset.git
synced 2026-06-28 02:45:32 +00:00
Compare commits
1 Commits
feat/csp-r
...
chart-samp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a1297d10ac |
@@ -197,25 +197,34 @@ export const DataTablesPane = ({
|
||||
children: pane,
|
||||
}));
|
||||
|
||||
// Hide the Samples tab for datasources that don't expose raw rows
|
||||
// (e.g. semantic views). The check is intentionally ``=== false`` so that
|
||||
// datasources from older backends that don't send the flag still show the
|
||||
// tab and preserve current behavior.
|
||||
const showSamplesTab = datasource?.supports_samples !== false;
|
||||
const tabItems = [
|
||||
...queryResultsPanes,
|
||||
{
|
||||
key: ResultTypes.Samples,
|
||||
label: t('Samples'),
|
||||
children: (
|
||||
<StyledDiv>
|
||||
<SamplesPane
|
||||
datasource={datasource}
|
||||
queryFormData={queryFormData}
|
||||
queryForce={queryForce}
|
||||
isRequest={isRequest.samples}
|
||||
setForceQuery={setForceQuery}
|
||||
isVisible={ResultTypes.Samples === activeTabKey}
|
||||
canDownload={canDownload}
|
||||
/>
|
||||
</StyledDiv>
|
||||
),
|
||||
},
|
||||
...(showSamplesTab
|
||||
? [
|
||||
{
|
||||
key: ResultTypes.Samples,
|
||||
label: t('Samples'),
|
||||
children: (
|
||||
<StyledDiv>
|
||||
<SamplesPane
|
||||
datasource={datasource}
|
||||
queryFormData={queryFormData}
|
||||
queryForce={queryForce}
|
||||
isRequest={isRequest.samples}
|
||||
setForceQuery={setForceQuery}
|
||||
isVisible={ResultTypes.Samples === activeTabKey}
|
||||
canDownload={canDownload}
|
||||
/>
|
||||
</StyledDiv>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
@@ -21,6 +21,7 @@ import { t } from '@apache-superset/core/translation';
|
||||
import { ensureIsArray } from '@superset-ui/core';
|
||||
import { datasetLabelLower } from 'src/features/semanticLayers/label';
|
||||
import { styled } from '@apache-superset/core/theme';
|
||||
import { Alert } from '@apache-superset/core/components';
|
||||
import { EmptyState, Loading } from '@superset-ui/core/components';
|
||||
import { GenericDataType } from '@apache-superset/core/common';
|
||||
import { GridTable } from 'src/components/GridTable';
|
||||
@@ -35,7 +36,7 @@ import {
|
||||
import { TableControls, ROW_LIMIT_OPTIONS } from './DataTableControls';
|
||||
import { SamplesPaneProps } from '../types';
|
||||
|
||||
const Error = styled.pre`
|
||||
const ErrorAlertWrapper = styled.div`
|
||||
margin-top: ${({ theme }) => `${theme.sizeUnit * 4}px`};
|
||||
`;
|
||||
|
||||
@@ -155,7 +156,14 @@ export const SamplesPane = ({
|
||||
rowLimitOptions={ROW_LIMIT_OPTIONS}
|
||||
onRowLimitChange={handleRowLimitChange}
|
||||
/>
|
||||
<Error>{responseError}</Error>
|
||||
<ErrorAlertWrapper>
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
message={t('Failed to load samples')}
|
||||
description={responseError}
|
||||
/>
|
||||
</ErrorAlertWrapper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,13 +25,14 @@ import {
|
||||
getClientErrorObject,
|
||||
} from '@superset-ui/core';
|
||||
import { styled } from '@apache-superset/core/theme';
|
||||
import { Alert } from '@apache-superset/core/components';
|
||||
import { EmptyState, Loading } from '@superset-ui/core/components';
|
||||
import { getChartDataRequest } from 'src/components/Chart/chartAction';
|
||||
import { ResultsPaneProps, QueryResultInterface } from '../types';
|
||||
import { SingleQueryResultPane } from './SingleQueryResultPane';
|
||||
import { TableControls, ROW_LIMIT_OPTIONS } from './DataTableControls';
|
||||
|
||||
const Error = styled.pre`
|
||||
const ErrorAlertWrapper = styled.div`
|
||||
margin-top: ${({ theme }) => `${theme.sizeUnit * 4}px`};
|
||||
`;
|
||||
|
||||
@@ -157,7 +158,14 @@ export const useResultsPane = ({
|
||||
isLoading={false}
|
||||
canDownload={canDownload}
|
||||
/>
|
||||
<Error>{responseError}</Error>
|
||||
<ErrorAlertWrapper>
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
message={t('Failed to load results')}
|
||||
description={responseError}
|
||||
/>
|
||||
</ErrorAlertWrapper>
|
||||
</>
|
||||
);
|
||||
return Array(queryCount).fill(err);
|
||||
|
||||
@@ -89,6 +89,17 @@ describe('DataTablesPane', () => {
|
||||
expect(await screen.findByLabelText('Collapse data panel')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Hides Samples tab when datasource opts out via supports_samples=false', async () => {
|
||||
const props = createDataTablesPaneProps(0);
|
||||
const propsWithoutSamples = {
|
||||
...props,
|
||||
datasource: { ...props.datasource, supports_samples: false },
|
||||
};
|
||||
render(<DataTablesPane {...propsWithoutSamples} />, { useRedux: true });
|
||||
expect(await screen.findByText('Results')).toBeVisible();
|
||||
expect(screen.queryByText('Samples')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Should copy data table content correctly', async () => {
|
||||
fetchMock.post(
|
||||
'glob:*/api/v1/chart/data?form_data=%7B%22slice_id%22%3A456%7D',
|
||||
|
||||
@@ -84,10 +84,14 @@ describe('SamplesPane', () => {
|
||||
const props = createSamplesPaneProps({
|
||||
datasourceId: 36,
|
||||
});
|
||||
const { findByText } = render(<SamplesPane {...props} />, {
|
||||
const { findByText, findByRole } = render(<SamplesPane {...props} />, {
|
||||
useRedux: true,
|
||||
});
|
||||
|
||||
// The error is now rendered inside an Alert component, with a clear
|
||||
// headline message and the raw error text as the description.
|
||||
expect(await findByRole('alert')).toBeVisible();
|
||||
expect(await findByText('Failed to load samples')).toBeVisible();
|
||||
expect(await findByText('Error: Bad request')).toBeVisible();
|
||||
});
|
||||
|
||||
|
||||
@@ -78,6 +78,12 @@ export type Datasource = Dataset & {
|
||||
schema?: string;
|
||||
is_sqllab_view?: boolean;
|
||||
extra?: string | object;
|
||||
/**
|
||||
* False when the datasource (e.g. a semantic view) doesn't model raw rows
|
||||
* and therefore can't return a row sample. Defaults to true on the server
|
||||
* side; missing here means the explore UI keeps current behavior.
|
||||
*/
|
||||
supports_samples?: boolean;
|
||||
};
|
||||
|
||||
export interface ExplorePageInitialData {
|
||||
|
||||
@@ -193,6 +193,11 @@ class BaseDatasource(
|
||||
# Only some datasources support Row Level Security
|
||||
is_rls_supported: bool = False
|
||||
|
||||
# Datasources that can return raw row samples (anything backed by a SQL
|
||||
# table can; semantic-layer abstractions cannot, since they only expose
|
||||
# pre-defined metrics and dimensions).
|
||||
supports_samples: bool = True
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
# can be a Column or a property pointing to one
|
||||
@@ -500,6 +505,7 @@ class BaseDatasource(
|
||||
"owners": [owner.id for owner in self.owners],
|
||||
"verbose_map": self.verbose_map,
|
||||
"select_star": self.select_star,
|
||||
"supports_samples": self.supports_samples,
|
||||
}
|
||||
|
||||
def data_for_slices( # pylint: disable=too-many-locals # noqa: C901
|
||||
|
||||
@@ -196,6 +196,10 @@ class SemanticView(AuditMixinNullable, Model):
|
||||
|
||||
__tablename__ = "semantic_views"
|
||||
|
||||
# Semantic views expose pre-defined metrics and dimensions, not raw rows,
|
||||
# so the "Samples" affordance in Explore does not apply.
|
||||
supports_samples: bool = False
|
||||
|
||||
# Use integer as the primary key for cross-database auto-increment
|
||||
# compatibility (sa.Identity() is not supported in MySQL or SQLite).
|
||||
# The uuid column is a secondary unique identifier used in URLs and perms.
|
||||
@@ -393,6 +397,7 @@ class SemanticView(AuditMixinNullable, Model):
|
||||
"filter_select_enabled": True,
|
||||
"sql": None,
|
||||
"select_star": None,
|
||||
"supports_samples": self.supports_samples,
|
||||
"owners": [],
|
||||
"description": self.description,
|
||||
"table_name": self.name,
|
||||
|
||||
@@ -338,6 +338,9 @@ class ExplorableData(TypedDict, total=False):
|
||||
extra: str | None
|
||||
always_filter_main_dttm: bool
|
||||
normalize_columns: bool
|
||||
# Set by datasources that cannot return raw row samples (e.g. semantic
|
||||
# views, which only expose pre-defined metrics and dimensions).
|
||||
supports_samples: bool
|
||||
|
||||
|
||||
VizData: TypeAlias = list[Any] | dict[Any, Any] | None
|
||||
|
||||
@@ -208,6 +208,23 @@ class Datasource(BaseSupersetView):
|
||||
payload = SamplesPayloadSchema().load(request.json)
|
||||
except ValidationError as err:
|
||||
return json_error_response(err.messages, status=400)
|
||||
|
||||
# Refuse early for datasource types that don't model raw rows
|
||||
# (e.g. semantic views, which only expose pre-defined metrics and
|
||||
# dimensions). Without this gate the request would still go through
|
||||
# the standard query pipeline and fail with an opaque 500.
|
||||
# ``supports_samples`` defaults to True for any datasource class that
|
||||
# doesn't explicitly opt out, so SqlaTable/Query/SavedQuery continue
|
||||
# to work without needing the attribute declared on each class.
|
||||
ds_class = DatasourceDAO.sources.get(
|
||||
DatasourceType(params["datasource_type"]),
|
||||
)
|
||||
if ds_class is not None and not getattr(ds_class, "supports_samples", True):
|
||||
return json_error_response(
|
||||
_("Samples are not available for this datasource type."),
|
||||
status=400,
|
||||
)
|
||||
|
||||
dashboard_id = None
|
||||
if security_manager.is_guest_user():
|
||||
if not params["dashboard_id"]:
|
||||
|
||||
@@ -653,6 +653,13 @@ def test_semantic_view_data(
|
||||
assert data["table_name"] == "Orders View"
|
||||
assert data["datasource_name"] == "Orders View"
|
||||
assert data["offset"] == 0
|
||||
# Semantic views don't model raw rows, so samples aren't available.
|
||||
assert data["supports_samples"] is False
|
||||
|
||||
|
||||
def test_semantic_view_supports_samples_is_false() -> None:
|
||||
"""The class-level flag opts SemanticView out of the Samples affordance."""
|
||||
assert SemanticView.supports_samples is False
|
||||
|
||||
|
||||
def test_semantic_view_get_query_result(
|
||||
|
||||
@@ -312,3 +312,66 @@ def test_save_non_owner_with_owners_field_is_rejected(
|
||||
raw_save(_view_self())
|
||||
|
||||
mock_security_manager.raise_for_ownership.assert_called_once_with(mock_orm)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Datasource.samples
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@patch("superset.views.datasource.views._", lambda s: s)
|
||||
@patch("superset.views.datasource.views.get_samples")
|
||||
@patch("superset.views.datasource.views.json_error_response")
|
||||
@patch("superset.views.datasource.views.security_manager", new_callable=MagicMock)
|
||||
def test_samples_returns_400_for_unsupported_datasource_type(
|
||||
mock_security_manager: MagicMock,
|
||||
mock_json_error_response: MagicMock,
|
||||
mock_get_samples: MagicMock,
|
||||
) -> None:
|
||||
"""Semantic views can't return raw samples — endpoint should refuse with 400."""
|
||||
from flask import Flask
|
||||
|
||||
mock_security_manager.is_guest_user.return_value = False
|
||||
mock_json_error_response.return_value = "error-response"
|
||||
|
||||
raw_samples = _get_view_func("samples")
|
||||
app = Flask(__name__)
|
||||
with app.test_request_context(
|
||||
"/datasource/samples?datasource_type=semantic_view&datasource_id=1",
|
||||
method="POST",
|
||||
json={},
|
||||
):
|
||||
result = raw_samples(_view_self())
|
||||
|
||||
assert result == "error-response"
|
||||
mock_json_error_response.assert_called_once()
|
||||
_, kwargs = mock_json_error_response.call_args
|
||||
assert kwargs.get("status") == 400
|
||||
# The bail-out must happen before any sample fetching is attempted.
|
||||
mock_get_samples.assert_not_called()
|
||||
|
||||
|
||||
@patch("superset.views.datasource.views.get_samples")
|
||||
@patch("superset.views.datasource.views.security_manager", new_callable=MagicMock)
|
||||
def test_samples_proceeds_for_supported_datasource_type(
|
||||
mock_security_manager: MagicMock,
|
||||
mock_get_samples: MagicMock,
|
||||
) -> None:
|
||||
"""A `query` datasource (supports_samples=True) bypasses the 400 short-circuit."""
|
||||
from flask import Flask
|
||||
|
||||
mock_security_manager.is_guest_user.return_value = False
|
||||
mock_get_samples.return_value = {"rows": []}
|
||||
|
||||
view = _view_self()
|
||||
raw_samples = _get_view_func("samples")
|
||||
app = Flask(__name__)
|
||||
with app.test_request_context(
|
||||
"/datasource/samples?datasource_type=query&datasource_id=1",
|
||||
method="POST",
|
||||
json={},
|
||||
):
|
||||
raw_samples(view)
|
||||
|
||||
mock_get_samples.assert_called_once()
|
||||
view.json_response.assert_called_once_with({"result": {"rows": []}})
|
||||
|
||||
Reference in New Issue
Block a user