You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by be...@apache.org on 2026/06/26 21:12:53 UTC
(superset) branch chart-samples created (now a1297d10ac5)
This is an automated email from the ASF dual-hosted git repository.
betodealmeida pushed a change to branch chart-samples
in repository https://gitbox.apache.org/repos/asf/superset.git
at a1297d10ac5 feat(semantic layers): don't show samples tab in explore
This branch includes the following new commits:
new a1297d10ac5 feat(semantic layers): don't show samples tab in explore
The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
(superset) 01/01: feat(semantic layers): don't show samples tab in explore
Posted by be...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
betodealmeida pushed a commit to branch chart-samples
in repository https://gitbox.apache.org/repos/asf/superset.git
commit a1297d10ac5f13bcaec445c4e3f803f713c0aa2c
Author: Beto Dealmeida <ro...@dealmeida.net>
AuthorDate: Fri Jun 26 17:12:37 2026 -0400
feat(semantic layers): don't show samples tab in explore
---
.../components/DataTablesPane/DataTablesPane.tsx | 43 +++++++++------
.../DataTablesPane/components/SamplesPane.tsx | 12 ++++-
.../DataTablesPane/components/useResultsPane.tsx | 12 ++++-
.../DataTablesPane/test/DataTablesPane.test.tsx | 11 ++++
.../DataTablesPane/test/SamplesPane.test.tsx | 6 ++-
superset-frontend/src/explore/types.ts | 6 +++
superset/connectors/sqla/models.py | 6 +++
superset/semantic_layers/models.py | 5 ++
superset/superset_typing.py | 3 ++
superset/views/datasource/views.py | 17 ++++++
tests/unit_tests/semantic_layers/models_test.py | 7 +++
tests/unit_tests/views/datasource/views_test.py | 63 ++++++++++++++++++++++
12 files changed, 169 insertions(+), 22 deletions(-)
diff --git a/superset-frontend/src/explore/components/DataTablesPane/DataTablesPane.tsx b/superset-frontend/src/explore/components/DataTablesPane/DataTablesPane.tsx
index 74302177542..b0330c299fd 100644
--- a/superset-frontend/src/explore/components/DataTablesPane/DataTablesPane.tsx
+++ b/superset-frontend/src/explore/components/DataTablesPane/DataTablesPane.tsx
@@ -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 (
diff --git a/superset-frontend/src/explore/components/DataTablesPane/components/SamplesPane.tsx b/superset-frontend/src/explore/components/DataTablesPane/components/SamplesPane.tsx
index ae901bc23ac..33b15904a7f 100644
--- a/superset-frontend/src/explore/components/DataTablesPane/components/SamplesPane.tsx
+++ b/superset-frontend/src/explore/components/DataTablesPane/components/SamplesPane.tsx
@@ -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>
</>
);
}
diff --git a/superset-frontend/src/explore/components/DataTablesPane/components/useResultsPane.tsx b/superset-frontend/src/explore/components/DataTablesPane/components/useResultsPane.tsx
index 302be4ea51b..25e2d8ab32e 100644
--- a/superset-frontend/src/explore/components/DataTablesPane/components/useResultsPane.tsx
+++ b/superset-frontend/src/explore/components/DataTablesPane/components/useResultsPane.tsx
@@ -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);
diff --git a/superset-frontend/src/explore/components/DataTablesPane/test/DataTablesPane.test.tsx b/superset-frontend/src/explore/components/DataTablesPane/test/DataTablesPane.test.tsx
index 024602f2126..7c842554491 100644
--- a/superset-frontend/src/explore/components/DataTablesPane/test/DataTablesPane.test.tsx
+++ b/superset-frontend/src/explore/components/DataTablesPane/test/DataTablesPane.test.tsx
@@ -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',
diff --git a/superset-frontend/src/explore/components/DataTablesPane/test/SamplesPane.test.tsx b/superset-frontend/src/explore/components/DataTablesPane/test/SamplesPane.test.tsx
index 26cff940c26..02b5fed7290 100644
--- a/superset-frontend/src/explore/components/DataTablesPane/test/SamplesPane.test.tsx
+++ b/superset-frontend/src/explore/components/DataTablesPane/test/SamplesPane.test.tsx
@@ -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();
});
diff --git a/superset-frontend/src/explore/types.ts b/superset-frontend/src/explore/types.ts
index 63b3401b653..c3e54e71518 100644
--- a/superset-frontend/src/explore/types.ts
+++ b/superset-frontend/src/explore/types.ts
@@ -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 {
diff --git a/superset/connectors/sqla/models.py b/superset/connectors/sqla/models.py
index 81b5c2fc8d9..105ba298086 100644
--- a/superset/connectors/sqla/models.py
+++ b/superset/connectors/sqla/models.py
@@ -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
diff --git a/superset/semantic_layers/models.py b/superset/semantic_layers/models.py
index 34cdfbc4792..344b67c3fb6 100644
--- a/superset/semantic_layers/models.py
+++ b/superset/semantic_layers/models.py
@@ -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,
diff --git a/superset/superset_typing.py b/superset/superset_typing.py
index f88f8effb9e..9b2bc60a164 100644
--- a/superset/superset_typing.py
+++ b/superset/superset_typing.py
@@ -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
diff --git a/superset/views/datasource/views.py b/superset/views/datasource/views.py
index 9a420bb10b1..ccbfcc3394f 100644
--- a/superset/views/datasource/views.py
+++ b/superset/views/datasource/views.py
@@ -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"]:
diff --git a/tests/unit_tests/semantic_layers/models_test.py b/tests/unit_tests/semantic_layers/models_test.py
index dd8c1e67dd1..dd560aedee4 100644
--- a/tests/unit_tests/semantic_layers/models_test.py
+++ b/tests/unit_tests/semantic_layers/models_test.py
@@ -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(
diff --git a/tests/unit_tests/views/datasource/views_test.py b/tests/unit_tests/views/datasource/views_test.py
index 3a54932d80c..345d498df2f 100644
--- a/tests/unit_tests/views/datasource/views_test.py
+++ b/tests/unit_tests/views/datasource/views_test.py
@@ -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": []}})