Compare commits

..

3 Commits

Author SHA1 Message Date
dependabot[bot]
ebdd3f29f7 chore(deps-dev): bump databricks-sqlalchemy from 1.0.5 to 2.0.9
Bumps [databricks-sqlalchemy](https://github.com/databricks/databricks-sqlalchemy) from 1.0.5 to 2.0.9.
- [Release notes](https://github.com/databricks/databricks-sqlalchemy/releases)
- [Changelog](https://github.com/databricks/databricks-sqlalchemy/blob/main/CHANGELOG.md)
- [Commits](https://github.com/databricks/databricks-sqlalchemy/compare/v1.0.5...v2.0.9)

---
updated-dependencies:
- dependency-name: databricks-sqlalchemy
  dependency-version: 2.0.9
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-06 18:52:40 +00:00
Đỗ Trọng Hải
ad8e6d4140 build(deps): upgrade react-markdown to v9 and remark-gfm to v4 (#41778)
Signed-off-by: hainenber <dotronghai96@gmail.com>
2026-07-06 11:50:09 -07:00
marun
83d93b8b42 fix(view-query): Add ownState parameter to ViewQueryModal for query consistency (#35208)
Co-authored-by: Evan Rusackas <evan@preset.io>
2026-07-06 11:16:55 -07:00
32 changed files with 749 additions and 2544 deletions

View File

@@ -10,11 +10,7 @@ updates:
- package-ecosystem: "npm"
ignore:
- dependency-name: "react-error-boundary"
- dependency-name: "@rjsf/*"
# remark-gfm v4+ requires react-markdown v9+, which needs React 18
- dependency-name: "remark-gfm"
- dependency-name: "react-markdown"
# TODO: remove below entries until React >= 19.0.0
- dependency-name: "react-icons"
# JSDOM v30 doesn't play well with Jest v30
@@ -78,8 +74,6 @@ updates:
- package-ecosystem: "npm"
directory: "/docs/"
ignore:
- dependency-name: "react-error-boundary"
schedule:
interval: "daily"
open-pull-requests-limit: 10
@@ -376,11 +370,6 @@ updates:
- package-ecosystem: "npm"
directory: "/superset-frontend/packages/superset-ui-core/"
ignore:
# not until React >= 18.0.0
- dependency-name: "react-markdown"
- dependency-name: "remark-gfm"
- dependency-name: "react-error-boundary"
schedule:
interval: "daily"
labels:

View File

@@ -140,7 +140,7 @@ d1 = [
databend = ["databend-sqlalchemy>=0.3.2, <1.0"]
databricks = [
"databricks-sql-connector==4.2.6",
"databricks-sqlalchemy==1.0.5",
"databricks-sqlalchemy==2.0.9",
]
db2 = ["ibm-db-sa>0.3.8, <=0.4.4"]
denodo = ["denodo-sqlalchemy>=2.0.5,<2.1.0"]

File diff suppressed because it is too large Load Diff

View File

@@ -393,7 +393,6 @@
"http-proxy-middleware": "^2.0.10",
"tar": "^7.5.16",
"puppeteer": "^22.4.1",
"remark-gfm": "^3.0.1",
"underscore": "^1.13.7",
"jspdf": "^4.2.0",
"nwsapi": "^2.2.13",

View File

@@ -53,16 +53,16 @@
"re-resizable": "^6.11.2",
"react-ace": "^14.0.1",
"react-draggable": "^4.7.0",
"react-error-boundary": "6.0.0",
"react-error-boundary": "^6.1.2",
"react-js-cron": "^5.2.0",
"react-markdown": "^8.0.7",
"react-markdown": "^9.0.7",
"react-resize-detector": "^7.1.2",
"react-syntax-highlighter": "^16.1.1",
"react-ultimate-pagination": "^1.3.2",
"regenerator-runtime": "^0.14.1",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^3.0.1",
"remark-gfm": "^4.0.1",
"reselect": "^5.2.0",
"rison": "^0.1.1",
"seedrandom": "^3.0.5",

View File

@@ -18,9 +18,6 @@
*/
import { useEffect, useMemo, useState } from 'react';
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize';
// TODO: Upgrade to remark-gfm v4+ after migrating to React 18.
// remark-gfm v4+ requires react-markdown v9+, which requires React 18.
// Currently pinned to v3.0.1 for compatibility with react-markdown v8 and React 17.
import remarkGfm from 'remark-gfm';
import { cloneDeep, mergeWith } from 'lodash-es';
import { FeatureFlag, isFeatureEnabled } from '../../utils';
@@ -139,7 +136,7 @@ export function SafeMarkdown({
rehypePlugins={rehypePlugins}
remarkPlugins={[remarkGfm]}
skipHtml={false}
transformLinkUri={transformLinkUri}
urlTransform={transformLinkUri}
>
{source}
</ReactMarkdown>

View File

@@ -221,12 +221,6 @@ test('should render the error', async () => {
.spyOn(SupersetClient, 'post')
.mockRejectedValue(new Error('Something went wrong'));
await waitForRender();
// The error is wrapped in an Alert component with a stable headline and the
// raw error text in the description — no more bare ``<pre>`` elements.
expect(await screen.findByRole('alert')).toBeVisible();
expect(
await screen.findByText('Failed to load drill-to-detail rows'),
).toBeVisible();
expect(screen.getByText('Error: Something went wrong')).toBeInTheDocument();
});

View File

@@ -42,7 +42,6 @@ import BooleanCell from '@superset-ui/core/components/Table/cell-renderers/Boole
import NullCell from '@superset-ui/core/components/Table/cell-renderers/NullCell';
import TimeCell from '@superset-ui/core/components/Table/cell-renderers/TimeCell';
import { EmptyState, Loading } from '@superset-ui/core/components';
import { Alert } from '@apache-superset/core/components';
import { getDatasourceSamples } from 'src/components/Chart/chartAction';
import Table, {
ColumnsType,
@@ -363,18 +362,13 @@ export default function DrillDetailPane({
if (responseError) {
// Render error if page download failed
tableContent = (
<div
<pre
css={css`
margin-top: ${theme.sizeUnit * 4}px;
`}
>
<Alert
type="error"
showIcon
message={t('Failed to load drill-to-detail rows')}
description={responseError}
/>
</div>
{responseError}
</pre>
);
} else if (bootstrapping) {
// Render loading if first page hasn't loaded

View File

@@ -50,7 +50,6 @@ const DISABLED_REASONS = {
DATABASE: t(
'Drill to detail is disabled for this database. Change the database settings to enable it.',
),
DATASOURCE: t('Drill to detail is not available for this datasource type.'),
NO_AGGREGATIONS: t(
'Drill to detail is disabled because this chart does not group data by dimension value.',
),
@@ -117,17 +116,6 @@ export const useDrillDetailMenuItems = ({
datasources[formData.datasource]?.database?.disable_drill_to_detail,
);
// Capability flag on the datasource itself. Datasources that don't model
// raw rows (e.g. semantic views) opt out via ``supports_drill_to_detail``
// in the explore data payload.
const datasourceSupportsDrillToDetail = useSelector<
RootState,
boolean | undefined
>(
({ datasources }) =>
datasources[formData.datasource]?.supports_drill_to_detail,
);
const openModal = useCallback(
(filters: BinaryQueryObjectFilterClause[], event: MouseEvent) => {
onClick(event);
@@ -170,10 +158,7 @@ export const useDrillDetailMenuItems = ({
let drillDisabled;
let drillByDisabled;
if (datasourceSupportsDrillToDetail === false) {
drillDisabled = DISABLED_REASONS.DATASOURCE;
drillByDisabled = DISABLED_REASONS.DATASOURCE;
} else if (drillToDetailDisabled) {
if (drillToDetailDisabled) {
drillDisabled = DISABLED_REASONS.DATABASE;
drillByDisabled = DISABLED_REASONS.DATABASE;
} else if (handlesDimensionContextMenu) {

View File

@@ -444,45 +444,3 @@ test('context menu renders <NULL> for null dimension values', async () => {
await expectDrillToDetailByEnabled();
await expectDrillToDetailByDimension(filterNull);
});
const buildStateWithUnsupportedDatasource = () => {
const baseState = getMockStoreWithNativeFilters().getState();
const datasourceKey = defaultFormData.datasource as string;
return {
...baseState,
datasources: {
...baseState.datasources,
[datasourceKey]: {
...baseState.datasources[datasourceKey],
supports_drill_to_detail: false,
},
},
};
};
test('dropdown menu when datasource opts out via supports_drill_to_detail=false', async () => {
cleanup();
render(<MockRenderChart formData={defaultFormData} />, {
useRouter: true,
useRedux: true,
initialState: buildStateWithUnsupportedDatasource(),
});
await expectDrillToDetailDisabled(
'Drill to detail is not available for this datasource type.',
);
await expectNoDrillToDetailBy();
});
test('context menu when datasource opts out via supports_drill_to_detail=false', async () => {
cleanup();
render(<MockRenderChart formData={defaultFormData} isContextMenu />, {
useRouter: true,
useRedux: true,
initialState: buildStateWithUnsupportedDatasource(),
});
const message = 'Drill to detail is not available for this datasource type.';
await expectDrillToDetailDisabled(message);
await expectDrillToDetailByDisabled(message);
});

View File

@@ -26,7 +26,12 @@ import {
useState,
} from 'react';
import { t } from '@apache-superset/core/translation';
import { getExtensionsRegistry, QueryData, VizType } from '@superset-ui/core';
import {
getExtensionsRegistry,
JsonObject,
QueryData,
VizType,
} from '@superset-ui/core';
import {
css,
styled,
@@ -58,6 +63,7 @@ type SliceHeaderProps = SliceHeaderControlsProps & {
filters: object;
handleToggleFullSize: () => void;
formData: object;
ownState?: JsonObject;
width: number;
height: number;
queriedDttm?: string | null;
@@ -174,6 +180,7 @@ const SliceHeader = forwardRef<HTMLDivElement, SliceHeaderProps>(
height,
exportPivotExcel = () => ({}),
chartHolderRef,
ownState,
},
ref,
) => {
@@ -381,6 +388,7 @@ const SliceHeader = forwardRef<HTMLDivElement, SliceHeaderProps>(
crossFiltersEnabled={isCrossFiltersEnabled}
exportPivotExcel={exportPivotExcel}
chartHolderRef={chartHolderRef}
ownState={ownState}
/>
)}
</>

View File

@@ -36,6 +36,7 @@ import {
getChartMetadataRegistry,
VizType,
BinaryQueryObjectFilterClause,
JsonObject,
QueryFormData,
} from '@superset-ui/core';
import { css, useTheme, styled } from '@apache-superset/core/theme';
@@ -140,6 +141,8 @@ export interface SliceHeaderControlsProps {
supersetCanDownload?: boolean;
crossFiltersEnabled?: boolean;
ownState?: JsonObject;
}
type SliceHeaderControlsPropsWithRouter = SliceHeaderControlsProps &
RouteComponentProps;
@@ -486,7 +489,12 @@ const SliceHeaderControls = (
<div data-test="view-query-menu-item">{t('View query')}</div>
}
modalTitle={t('View query')}
modalBody={<ViewQueryModal latestQueryFormData={props.formData} />}
modalBody={
<ViewQueryModal
latestQueryFormData={props.formData}
ownState={props.ownState}
/>
}
draggable
resizable
responsive

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { QueryFormData } from '@superset-ui/core';
import { JsonObject, QueryFormData } from '@superset-ui/core';
export interface SliceHeaderControlsProps {
slice: {
@@ -60,4 +60,5 @@ export interface SliceHeaderControlsProps {
supersetCanDownload?: boolean;
crossFiltersEnabled?: boolean;
ownState?: JsonObject;
}

View File

@@ -480,6 +480,26 @@ const Chart = (props: ChartProps) => {
(formData as JsonObject).dashboardId = dashboardInfo.id;
// Memoize ownState so it keeps a stable reference across re-renders that
// don't change its logical value. ViewQueryModal depends on ownState; a fresh
// object on every render would refetch the query unnecessarily.
const ownState = useMemo(
() =>
createOwnStateWithChartState(
(dataMaskOwnState as JsonObject) || EMPTY_OBJECT,
{
state:
getChartStateWithFallback(
chartState as { state?: JsonObject } | undefined,
formData as JsonObject,
sliceVizType,
) ?? undefined,
},
sliceVizType,
),
[dataMaskOwnState, chartState, formData, sliceVizType],
);
const exportTable = useCallback(
async (format: string, isFullCSV: boolean, isPivot = false) => {
const logAction =
@@ -727,6 +747,7 @@ const Chart = (props: ChartProps) => {
height={getHeaderHeight()}
exportPivotExcel={exportPivotExcel as unknown as (arg0: string) => void}
chartHolderRef={props.chartHolderRef}
ownState={ownState}
/>
{/*
@@ -777,18 +798,7 @@ const Chart = (props: ChartProps) => {
formData={
formData as unknown as import('@superset-ui/core').QueryFormData
}
ownState={createOwnStateWithChartState(
(dataMask[props.id]?.ownState as JsonObject) || EMPTY_OBJECT,
{
state:
getChartStateWithFallback(
chartState as { state?: JsonObject } | undefined,
formData as JsonObject,
slice.viz_type,
) ?? undefined,
},
slice.viz_type,
)}
ownState={ownState}
queriesResponse={chart.queriesResponse ?? null}
timeout={timeout}
triggerQuery={chart.triggerQuery}

View File

@@ -231,10 +231,6 @@ export type Datasource = Dataset & {
column_types: GenericDataType[];
table_name: string;
database?: Database;
/** False when the datasource can't return row samples (e.g. semantic views). */
supports_samples?: boolean;
/** False when the datasource can't answer drill-to-detail requests. */
supports_drill_to_detail?: boolean;
};
export type DatasourcesState = {
[key: string]: Datasource;

View File

@@ -234,44 +234,25 @@ export const DataTablesPane = ({
}
}, [resultsTabFallback]);
// 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;
// If the datasource swaps to one that doesn't support samples while the
// Samples tab is active (e.g. the user picks a semantic view), the tab
// disappears from ``tabItems`` and ``activeTabKey`` is orphaned. Fall back
// to Results so the panel keeps rendering content.
useEffect(() => {
if (!showSamplesTab && activeTabKey === ResultTypes.Samples) {
setActiveTabKey(ResultTypes.Results);
}
}, [showSamplesTab, activeTabKey]);
const tabItems = [
...queryResultsPanes,
...(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>
),
},
]
: []),
{
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 (

View File

@@ -21,7 +21,6 @@ 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';
@@ -36,7 +35,7 @@ import {
import { TableControls, ROW_LIMIT_OPTIONS } from './DataTableControls';
import { SamplesPaneProps } from '../types';
const ErrorAlertWrapper = styled.div`
const Error = styled.pre`
margin-top: ${({ theme }) => `${theme.sizeUnit * 4}px`};
`;
@@ -156,14 +155,7 @@ export const SamplesPane = ({
rowLimitOptions={ROW_LIMIT_OPTIONS}
onRowLimitChange={handleRowLimitChange}
/>
<ErrorAlertWrapper>
<Alert
type="error"
showIcon
message={t('Failed to load samples')}
description={responseError}
/>
</ErrorAlertWrapper>
<Error>{responseError}</Error>
</>
);
}

View File

@@ -27,14 +27,13 @@ import {
QueryData,
} 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 ErrorAlertWrapper = styled.div`
const Error = styled.pre`
margin-top: ${({ theme }) => `${theme.sizeUnit * 4}px`};
`;
@@ -200,14 +199,7 @@ export const useResultsPane = ({
isLoading={false}
canDownload={canDownload}
/>
<ErrorAlertWrapper>
<Alert
type="error"
showIcon
message={t('Failed to load results')}
description={responseError}
/>
</ErrorAlertWrapper>
<Error>{responseError}</Error>
</>
);
return Array(queryCount).fill(err);

View File

@@ -19,12 +19,7 @@
import fetchMock from 'fetch-mock';
import { FeatureFlag } from '@superset-ui/core';
import * as copyUtils from 'src/utils/copy';
import {
render,
screen,
userEvent,
waitFor,
} from 'spec/helpers/testing-library';
import { render, screen, userEvent } from 'spec/helpers/testing-library';
import { setupAGGridModules } from '@superset-ui/core/components/ThemedAgGridReact';
import { setItem, LocalStorageKeys } from 'src/utils/localStorageHelpers';
import { DataTablesPane } from '..';
@@ -94,48 +89,6 @@ 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('Falls back to Results when active Samples tab disappears mid-session', async () => {
// Regression for codeant Major finding on PR #41509: a datasource swap
// that hides the Samples tab while it was the active tab used to leave
// ``activeTabKey === 'samples'`` orphaned, rendering a blank panel.
const props = createDataTablesPaneProps(0);
const { rerender } = render(<DataTablesPane {...props} />, {
useRedux: true,
});
// Open the panel and pick the Samples tab.
userEvent.click(screen.getByLabelText('Expand data panel'));
userEvent.click(await screen.findByText('Samples'));
expect(await screen.findByLabelText('Collapse data panel')).toBeVisible();
// Swap to a datasource that doesn't support samples (e.g. a semantic
// view). The Samples tab should disappear and the panel should land on
// Results with content still rendered.
rerender(
<DataTablesPane
{...props}
datasource={{ ...props.datasource, supports_samples: false }}
/>,
);
await waitFor(() => {
expect(screen.queryByText('Samples')).not.toBeInTheDocument();
});
expect(screen.getByText('Results')).toBeVisible();
// Panel stays expanded and renders Results content rather than going blank.
expect(screen.getByLabelText('Collapse data panel')).toBeVisible();
});
test('Should copy data table content correctly', async () => {
fetchMock.post(
'glob:*/api/v1/chart/data?form_data=%7B%22slice_id%22%3A456%7D',

View File

@@ -84,14 +84,10 @@ describe('SamplesPane', () => {
const props = createSamplesPaneProps({
datasourceId: 36,
});
const { findByText, findByRole } = render(<SamplesPane {...props} />, {
const { findByText } = 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();
});

View File

@@ -19,6 +19,8 @@
import { screen, render, waitFor } from 'spec/helpers/testing-library';
import fetchMock from 'fetch-mock';
import * as chartAction from 'src/components/Chart/chartAction';
import type { ChartDataRequestResponse } from 'src/components/Chart/chartAction';
import ViewQueryModal from './ViewQueryModal';
const mockFormData = {
@@ -26,9 +28,19 @@ const mockFormData = {
viz_type: 'table',
};
// Minimal, type-correct response that satisfies ChartDataRequestResponse.
// A real Response instance avoids the 16 required Response fields that an
// empty object ({}) fails to overlap. The assertions only inspect the call
// arguments, never the resolved value's contents.
const mockChartDataResponse: ChartDataRequestResponse = {
response: new Response(),
json: { result: [] },
};
const chartDataEndpoint = 'glob:*/api/v1/chart/data*';
afterEach(() => {
jest.restoreAllMocks();
jest.resetAllMocks();
fetchMock.clearHistory().removeRoutes();
});
@@ -117,3 +129,91 @@ test('renders both Alert and SQL query when parsing error occurs', async () => {
{ timeout: 5000 },
);
});
test('passes ownState through to getChartDataRequest', async () => {
/**
* Regression test for PR #35208 - the ViewQueryModal must forward the
* chart's ownState (e.g. table search text, order_by) to the data request
* so that the displayed SQL reflects the same filters applied to the chart.
*/
const getChartDataRequestSpy = jest
.spyOn(chartAction, 'getChartDataRequest')
.mockResolvedValue(mockChartDataResponse);
const ownState = { searchText: 'foo', order_by: [['col', 'asc']] };
render(
<ViewQueryModal latestQueryFormData={mockFormData} ownState={ownState} />,
{ useRedux: true },
);
await waitFor(() => {
expect(getChartDataRequestSpy).toHaveBeenCalledTimes(1);
});
expect(getChartDataRequestSpy).toHaveBeenCalledWith(
expect.objectContaining({
formData: mockFormData,
ownState,
}),
);
});
test('strips clientView from ownState before the query request', async () => {
/**
* clientView holds the full client-side row/column snapshot (added by
* TableChart) and is irrelevant to SQL generation. It must be stripped
* before the request - matching ExploreViewContainer and Dashboard - to
* avoid bloating the payload (or triggering 413) on large tables.
*/
const getChartDataRequestSpy = jest
.spyOn(chartAction, 'getChartDataRequest')
.mockResolvedValue(mockChartDataResponse);
const ownState = {
searchText: 'foo',
// Simulate a large client-side snapshot that TableChart writes
clientView: { rows: [{ a: 1 }, { a: 2 }], columns: ['a'] },
};
render(
<ViewQueryModal latestQueryFormData={mockFormData} ownState={ownState} />,
{ useRedux: true },
);
await waitFor(() => {
expect(getChartDataRequestSpy).toHaveBeenCalledTimes(1);
});
const calledOwnState = getChartDataRequestSpy.mock.calls[0][0].ownState;
expect(calledOwnState).not.toHaveProperty('clientView');
expect(calledOwnState).toEqual(
expect.objectContaining({ searchText: 'foo' }),
);
});
test('falls back to empty ownState when prop is omitted', async () => {
/**
* Covers the `ownState || {}` fallback branch in ViewQueryModal - when no
* ownState is provided, the data request must still be called with an empty
* object rather than undefined, matching getChartDataRequest's contract.
*/
const getChartDataRequestSpy = jest
.spyOn(chartAction, 'getChartDataRequest')
.mockResolvedValue(mockChartDataResponse);
render(<ViewQueryModal latestQueryFormData={mockFormData} />, {
useRedux: true,
});
await waitFor(() => {
expect(getChartDataRequestSpy).toHaveBeenCalledTimes(1);
});
expect(getChartDataRequestSpy).toHaveBeenCalledWith(
expect.objectContaining({
formData: mockFormData,
ownState: {},
}),
);
});

View File

@@ -16,12 +16,14 @@
* specific language governing permissions and limitations
* under the License.
*/
import { FC, Fragment, useEffect, useState } from 'react';
import { FC, Fragment, useCallback, useEffect, useState } from 'react';
import { omit } from 'lodash';
import { t } from '@apache-superset/core/translation';
import {
ensureIsArray,
getClientErrorObject,
JsonObject,
QueryFormData,
} from '@superset-ui/core';
import { Alert } from '@apache-superset/core/components';
@@ -33,6 +35,7 @@ import ViewQuery from 'src/explore/components/controls/ViewQuery';
interface Props {
latestQueryFormData: QueryFormData;
ownState?: JsonObject;
}
type Result = {
@@ -48,38 +51,47 @@ const ViewQueryModalContainer = styled.div`
gap: ${({ theme }) => theme.sizeUnit * 4}px;
`;
const ViewQueryModal: FC<Props> = ({ latestQueryFormData }) => {
const ViewQueryModal: FC<Props> = ({ latestQueryFormData, ownState }) => {
const [result, setResult] = useState<Result[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadChartData = (resultType: string) => {
setIsLoading(true);
getChartDataRequest({
formData: latestQueryFormData,
resultFormat: 'json',
resultType,
})
.then(({ json }) => {
setResult(ensureIsArray(json.result) as Result[]);
setIsLoading(false);
setError(null);
const loadChartData = useCallback(
(resultType: string) => {
setIsLoading(true);
// Strip clientView (client-side row/column snapshot) from ownState before
// requesting the query, matching the chart query path in ExploreViewContainer
// and Dashboard's activeAllDashboardFilters. clientView is irrelevant to SQL
// generation and can bloat the payload (or trigger 413) on large tables.
const ownStateForQuery = omit(ownState, ['clientView']) || {};
getChartDataRequest({
formData: latestQueryFormData,
resultFormat: 'json',
resultType,
ownState: ownStateForQuery,
})
.catch(response => {
getClientErrorObject(response).then(({ error, message }) => {
setError(
error ||
message ||
response.statusText ||
t('Sorry, An error occurred'),
);
.then(({ json }) => {
setResult(ensureIsArray(json.result) as Result[]);
setIsLoading(false);
setError(null);
})
.catch(response => {
getClientErrorObject(response).then(({ error, message }) => {
setError(
error ||
message ||
response.statusText ||
t('Sorry, An error occurred'),
);
setIsLoading(false);
});
});
});
};
},
[latestQueryFormData, ownState],
);
useEffect(() => {
loadChartData('query');
}, [JSON.stringify(latestQueryFormData)]);
}, [loadChartData]);
if (isLoading) {
return <Loading />;

View File

@@ -1067,6 +1067,7 @@ export const useExploreAdditionalActionsMenu = (
modalBody={
<ViewQueryModal
latestQueryFormData={latestQueryFormData as QueryFormData}
ownState={ownState}
/>
}
draggable

View File

@@ -78,18 +78,6 @@ 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;
/**
* False when the datasource doesn't model raw rows and therefore can't
* answer a drill-to-detail query. Tracked separately from
* ``supports_samples`` so the two capabilities can diverge.
*/
supports_drill_to_detail?: boolean;
};
export interface ExplorePageInitialData {

View File

@@ -230,15 +230,6 @@ def _get_drill_detail(
# todo(yongjie): Remove this function,
# when determining whether samples should be applied to the time filter.
datasource = _get_datasource(query_context, query_obj)
# Refuse for datasource types that don't model raw rows (e.g. semantic
# views). Mirrors the ``supports_samples`` gate on the ``/samples``
# endpoint so drill-detail is hard-blocked on the backend, not just
# hidden in the frontend menu. Defaults to ``True`` for any datasource
# class that doesn't explicitly opt out.
if not getattr(datasource, "supports_drill_to_detail", True):
raise QueryObjectValidationError(
_("Drill to detail is not available for this datasource type.")
)
query_obj = copy.copy(query_obj)
query_obj.is_timeseries = False
query_obj.metrics = None

View File

@@ -194,16 +194,6 @@ 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
# Datasources that can answer "drill to detail" requests — i.e. fetch the
# raw rows underlying a chart cell. Conceptually similar to ``samples``
# but kept as a separate capability so the two can diverge.
supports_drill_to_detail: bool = True
@property
def name(self) -> str:
# can be a Column or a property pointing to one
@@ -511,8 +501,6 @@ 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,
"supports_drill_to_detail": self.supports_drill_to_detail,
}
def data_for_slices( # pylint: disable=too-many-locals # noqa: C901

View File

@@ -196,12 +196,6 @@ class SemanticView(AuditMixinNullable, Model):
__tablename__ = "semantic_views"
# Semantic views expose pre-defined metrics and dimensions, not raw rows,
# so neither the "Samples" tab in Explore nor the "Drill to detail"
# affordance from the chart 3-dots menu can return anything meaningful.
supports_samples: bool = False
supports_drill_to_detail: 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.
@@ -399,8 +393,6 @@ class SemanticView(AuditMixinNullable, Model):
"filter_select_enabled": True,
"sql": None,
"select_star": None,
"supports_samples": self.supports_samples,
"supports_drill_to_detail": self.supports_drill_to_detail,
"owners": [],
"description": self.description,
"table_name": self.name,

View File

@@ -338,11 +338,6 @@ 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
# Set by datasources that cannot answer drill-to-detail requests.
supports_drill_to_detail: bool
VizData: TypeAlias = list[Any] | dict[Any, Any] | None

View File

@@ -208,23 +208,6 @@ 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"]:

View File

@@ -1,70 +0,0 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from unittest.mock import MagicMock, patch
import pytest
from superset.common.query_actions import _get_drill_detail
from superset.exceptions import QueryObjectValidationError
def test_get_drill_detail_refuses_datasource_that_opts_out() -> None:
"""
A datasource with ``supports_drill_to_detail = False`` (e.g. semantic
views) must be hard-blocked on the server. Without this gate the request
would fall through to ``_get_full`` and fail with an opaque error, and
the flag would only be enforced by the frontend menu — leaving the
chart-data API endpoint accepting drill-detail requests it shouldn't.
"""
datasource = MagicMock()
datasource.supports_drill_to_detail = False
query_obj = MagicMock()
query_obj.datasource = datasource
query_context = MagicMock()
with pytest.raises(
QueryObjectValidationError,
match="Drill to detail is not available",
):
_get_drill_detail(query_context, query_obj)
def test_get_drill_detail_allows_datasource_without_flag() -> None:
"""
Datasources that don't declare the flag (e.g. legacy ``SqlaTable``
subclasses via ``getattr`` default) must continue to work — the gate
only fires when the flag is explicitly ``False``.
"""
datasource = MagicMock(spec=["columns"])
column = MagicMock()
column.column_name = "id"
datasource.columns = [column]
query_obj = MagicMock()
query_obj.datasource = datasource
query_obj.columns = []
query_context = MagicMock()
expected_payload: dict[str, list[dict[str, str]]] = {"data": []}
with patch(
"superset.common.query_actions._get_full", return_value=expected_payload
) as mock_get_full:
assert _get_drill_detail(query_context, query_obj) is expected_payload
mock_get_full.assert_called_once()

View File

@@ -653,20 +653,6 @@ 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 neither samples nor
# drill-to-detail are available.
assert data["supports_samples"] is False
assert data["supports_drill_to_detail"] 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_supports_drill_to_detail_is_false() -> None:
"""The class-level flag opts SemanticView out of Drill to detail."""
assert SemanticView.supports_drill_to_detail is False
def test_semantic_view_get_query_result(

View File

@@ -312,66 +312,3 @@ 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": []}})