mirror of
https://github.com/apache/superset.git
synced 2026-08-28 11:01:17 +00:00
Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
feee3dea2f | ||
|
|
192ddf9a6d | ||
|
|
a59b96c4f5 | ||
|
|
12cd259c55 | ||
|
|
3ddc3b1d56 | ||
|
|
98ec6018df | ||
|
|
2ebd415b8a | ||
|
|
81b3e85522 | ||
|
|
fd64efd72d | ||
|
|
e39bfb255b | ||
|
|
b7301ac88a | ||
|
|
fa59b44cfe | ||
|
|
53b88da3b9 | ||
|
|
3f4fdf5f07 | ||
|
|
5a6c1b977b | ||
|
|
e8577368d3 | ||
|
|
540f8cb2d0 | ||
|
|
aae997e546 |
+1
-1
@@ -196,7 +196,7 @@ excel = ["xlrd>=2.0.2, <2.1"]
|
||||
excel-export = ["boto3"]
|
||||
fastmcp = [
|
||||
"fastmcp>=3.4.7,<4.0",
|
||||
"mcp>=1.29.1,<2.0",
|
||||
"mcp>=1.29.1,<3.0",
|
||||
# tiktoken backs the response-size-guard token estimator. Without
|
||||
# it, the middleware falls back to a coarser character-based
|
||||
# heuristic that under-counts JSON-heavy MCP responses.
|
||||
|
||||
Generated
+5
-5
@@ -108,7 +108,7 @@
|
||||
"json-stringify-pretty-compact": "^4.0.0",
|
||||
"lodash": "^4.18.1",
|
||||
"lodash-es": "^4.18.1",
|
||||
"mapbox-gl": "^3.28.1",
|
||||
"mapbox-gl": "^3.29.0",
|
||||
"markdown-to-jsx": "^9.10.2",
|
||||
"match-sorter": "^8.3.0",
|
||||
"memoize-one": "^6.0.0",
|
||||
@@ -28488,9 +28488,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/mapbox-gl": {
|
||||
"version": "3.28.1",
|
||||
"resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-3.28.1.tgz",
|
||||
"integrity": "sha512-f8bCHFzZ51bKig7rnD7e08aoFLOV3MNFZduspZ4lgOgiaNVp9sw4NSWcgo3IWTyekYKKXjiICD2BP2o4DiYfxw==",
|
||||
"version": "3.29.0",
|
||||
"resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-3.29.0.tgz",
|
||||
"integrity": "sha512-Fnh1WLsZMfihwRZY5scp456iQuZo9G97tTpb26bf/Ejsi/L7O+4dE9+I03VoOor2ul3DEOp6F2P3273omyVsNw==",
|
||||
"license": "SEE LICENSE IN LICENSE.txt",
|
||||
"workspaces": [
|
||||
"src/style-spec",
|
||||
@@ -43490,7 +43490,7 @@
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@math.gl/web-mercator": "^4.1.0",
|
||||
"mapbox-gl": "^3.28.1",
|
||||
"mapbox-gl": "^3.29.0",
|
||||
"maplibre-gl": "^5.24.0",
|
||||
"react-map-gl": "^8.1.2",
|
||||
"supercluster": "^9.0.0"
|
||||
|
||||
@@ -185,7 +185,7 @@
|
||||
"json-stringify-pretty-compact": "^4.0.0",
|
||||
"lodash": "^4.18.1",
|
||||
"lodash-es": "^4.18.1",
|
||||
"mapbox-gl": "^3.28.1",
|
||||
"mapbox-gl": "^3.29.0",
|
||||
"markdown-to-jsx": "^9.10.2",
|
||||
"match-sorter": "^8.3.0",
|
||||
"memoize-one": "^6.0.0",
|
||||
|
||||
@@ -65,6 +65,7 @@ export type AntdExposedProps = Pick<
|
||||
| 'onOpenChange'
|
||||
| 'optionRender'
|
||||
| 'placeholder'
|
||||
| 'prefix'
|
||||
| 'showArrow'
|
||||
| 'showSearch'
|
||||
| 'tokenSeparators'
|
||||
|
||||
@@ -119,6 +119,20 @@ export function retrieveErrorMessage(
|
||||
return statusError || parseStringResponse(str);
|
||||
}
|
||||
|
||||
function getFirstValidationError(message: JsonObject): string | undefined {
|
||||
const [firstError] = Object.values(message);
|
||||
|
||||
if (typeof firstError === 'string') {
|
||||
return firstError;
|
||||
}
|
||||
|
||||
if (Array.isArray(firstError)) {
|
||||
return firstError.find((item): item is string => typeof item === 'string');
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function parseErrorJson(responseJson: JsonObject): ClientErrorObject {
|
||||
let error = { ...responseJson };
|
||||
// Backwards compatibility for old error renderers with the new error object
|
||||
@@ -126,13 +140,12 @@ export function parseErrorJson(responseJson: JsonObject): ClientErrorObject {
|
||||
error.error = error.description = error.errors[0].message;
|
||||
error.link = error.errors[0]?.extra?.link;
|
||||
}
|
||||
// Marshmallow field validation returns the error message in the format
|
||||
// of { message: { field1: [msg1, msg2], field2: [msg], } }
|
||||
// Marshmallow field validation returns arrays for string messages, but
|
||||
// serializes lazy translation messages as strings instead.
|
||||
if (!error.error && error.message) {
|
||||
if (typeof error.message === 'object') {
|
||||
error.error =
|
||||
Object.values(error.message as Record<string, string[]>)[0]?.[0] ||
|
||||
t('Invalid input');
|
||||
getFirstValidationError(error.message) || t('Invalid input');
|
||||
}
|
||||
if (typeof error.message === 'string') {
|
||||
if (checkForHtml(error.message)) {
|
||||
|
||||
@@ -244,6 +244,24 @@ test('parseErrorJson with message', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('parseErrorJson preserves string-valued validation messages', () => {
|
||||
const calculatedColumnError =
|
||||
'Custom SQL fields cannot be parsed as a single SQL statement.';
|
||||
|
||||
expect(
|
||||
parseErrorJson({
|
||||
message: {
|
||||
'columns.0.expression': calculatedColumnError,
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
message: {
|
||||
'columns.0.expression': calculatedColumnError,
|
||||
},
|
||||
error: calculatedColumnError,
|
||||
});
|
||||
});
|
||||
|
||||
test('parseErrorJson with HTML message', () => {
|
||||
expect(
|
||||
parseErrorJson({
|
||||
|
||||
@@ -47,6 +47,9 @@ const getCrossFilterDataMask =
|
||||
) =>
|
||||
(value: string) => {
|
||||
const selected = Object.values(selectedValues);
|
||||
if (!labelMap[value] && !selected.includes(value)) {
|
||||
return undefined;
|
||||
}
|
||||
let values: string[];
|
||||
if (selected.includes(value)) {
|
||||
values = selected.filter(v => v !== value);
|
||||
|
||||
@@ -180,3 +180,46 @@ test('cross-filter does nothing when emitCrossFilters is false', () => {
|
||||
|
||||
expect(setDataMask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('cross-filter does nothing when name is missing from labelMap', () => {
|
||||
const setDataMask = jest.fn();
|
||||
const props = buildProps({
|
||||
groupby: ['topics'],
|
||||
labelMap: {
|
||||
cancellations: ['cancellations'],
|
||||
},
|
||||
selectedValues: {},
|
||||
setDataMask,
|
||||
});
|
||||
|
||||
const handlers = allEventHandlers(props);
|
||||
// e.g. Pie "Other" category is not present in labelMap
|
||||
handlers.click({ name: 'Other' });
|
||||
|
||||
expect(setDataMask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('cross-filter still deselects a previously selected value that is missing from labelMap', () => {
|
||||
const setDataMask = jest.fn();
|
||||
const props = buildProps({
|
||||
groupby: ['topics'],
|
||||
labelMap: {
|
||||
cancellations: ['cancellations'],
|
||||
},
|
||||
// "Other" was selected before it dropped out of labelMap (e.g. a stale
|
||||
// cross-filter from an earlier render or dashboard state).
|
||||
selectedValues: { 0: 'Other' },
|
||||
setDataMask,
|
||||
});
|
||||
|
||||
const handlers = allEventHandlers(props);
|
||||
handlers.click({ name: 'Other' });
|
||||
|
||||
expect(setDataMask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
extraFormData: {
|
||||
filters: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@math.gl/web-mercator": "^4.1.0",
|
||||
"mapbox-gl": "^3.28.1",
|
||||
"mapbox-gl": "^3.29.0",
|
||||
"maplibre-gl": "^5.24.0",
|
||||
"react-map-gl": "^8.1.2",
|
||||
"supercluster": "^9.0.0"
|
||||
|
||||
+17
@@ -43,6 +43,23 @@ describe('SaveDatasetActionButton', () => {
|
||||
expect(saveDatasetBtn).toBeVisible();
|
||||
});
|
||||
|
||||
test('disables only the dataset button when canSaveDataset is false', () => {
|
||||
const onSaveAsExplore = jest.fn();
|
||||
render(
|
||||
<SaveDatasetActionButton
|
||||
setShowSave={() => true}
|
||||
onSaveAsExplore={onSaveAsExplore}
|
||||
canSaveDataset={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Saving the query needs no results.
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /save dataset/i }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
test('disables the save dataset button when the query did not run successfully', async () => {
|
||||
render(
|
||||
<SaveDatasetActionButton
|
||||
|
||||
@@ -19,12 +19,14 @@
|
||||
import { act, type ComponentProps } from 'react';
|
||||
import {
|
||||
cleanup,
|
||||
createStore,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
userEvent,
|
||||
waitFor,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import reducerIndex from 'spec/helpers/reducerIndex';
|
||||
import fetchMock from 'fetch-mock';
|
||||
import { SaveDatasetModal } from 'src/SqlLab/components/SaveDatasetModal';
|
||||
import { createDatasource } from 'src/SqlLab/actions/sqlLab';
|
||||
@@ -63,6 +65,12 @@ beforeEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// In-body restores are skipped when an assertion throws, leaking a
|
||||
// configured spy into later tests.
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
// Mock createDatasource to return a thunk that resolves with the dataset's
|
||||
// new id. The test's mock store includes redux-thunk middleware (from RTK's
|
||||
// getDefaultMiddleware), so dispatch(createDatasource(...)) properly unwraps
|
||||
@@ -518,6 +526,39 @@ describe('SaveDatasetModal', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('surfaces the error and keeps the modal open when saving fails', async () => {
|
||||
// The chart-payload step's toast was built but never dispatched, so a
|
||||
// failure there was silent.
|
||||
const postFormData = jest.spyOn(
|
||||
require('src/explore/exploreUtils/formData'),
|
||||
'postFormData',
|
||||
);
|
||||
postFormData.mockRejectedValue(new Error('Boom'));
|
||||
const onHide = jest.fn();
|
||||
const store = createStore({ user }, reducerIndex);
|
||||
|
||||
render(<SaveDatasetModal {...mockedProps} onHide={onHide} />, { store });
|
||||
|
||||
fireEvent.change(screen.getByDisplayValue(/unimportant/i), {
|
||||
target: { value: 'my dataset' },
|
||||
});
|
||||
userEvent.click(screen.getByRole('button', { name: /save/i }));
|
||||
|
||||
// `createStore` builds its reducer map at runtime, so state isn't typed.
|
||||
const toasts = () =>
|
||||
(
|
||||
store.getState() as unknown as {
|
||||
messageToasts: { toastType: string }[];
|
||||
}
|
||||
).messageToasts;
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toasts()).toHaveLength(1);
|
||||
});
|
||||
expect(toasts()[0].toastType).toBe('DANGER_TOAST');
|
||||
expect(onHide).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('clearDatasetCache is imported and available', () => {
|
||||
const { clearDatasetCache } = require('src/utils/cachedSupersetGet');
|
||||
|
||||
|
||||
@@ -61,6 +61,9 @@ import type Subject from 'src/types/Subject';
|
||||
import { openInNewTab, redirect } from 'src/utils/navigationUtils';
|
||||
import { mapSubjectValuesToIds } from 'src/features/subjects/SubjectPicker';
|
||||
|
||||
// Derived so it can't drift from what `getClientErrorObject` accepts.
|
||||
type SaveErrorSource = Parameters<typeof getClientErrorObject>[0];
|
||||
|
||||
interface QueryDatabase {
|
||||
id?: number;
|
||||
}
|
||||
@@ -391,9 +394,18 @@ export const SaveDatasetModal = ({
|
||||
setDatasetName(getDefaultDatasetName());
|
||||
onHide();
|
||||
})
|
||||
.catch(() => {
|
||||
.catch((error?: SaveErrorSource) => {
|
||||
setLoading(false);
|
||||
addDangerToast(t('An error occurred saving dataset'));
|
||||
// `createDatasource` already toasted the server's message and rejects
|
||||
// with nothing; only the chart-payload step needs its own.
|
||||
if (!error) {
|
||||
return;
|
||||
}
|
||||
getClientErrorObject(error).then(e =>
|
||||
dispatch(
|
||||
addDangerToast(e.error || t('An error occurred saving dataset')),
|
||||
),
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -27,6 +27,8 @@ import {
|
||||
import SaveQuery from 'src/SqlLab/components/SaveQuery';
|
||||
import { initialState, databases } from 'src/SqlLab/fixtures';
|
||||
|
||||
const RESULT_COLUMNS = [{ column_name: 'col', type: 'STRING' }];
|
||||
|
||||
const mockedProps = {
|
||||
queryEditorId: '123',
|
||||
animation: false,
|
||||
@@ -35,7 +37,6 @@ const mockedProps = {
|
||||
onSave: () => {},
|
||||
saveQueryWarning: null,
|
||||
columns: [],
|
||||
canSaveDataset: true,
|
||||
};
|
||||
|
||||
const mockState = {
|
||||
@@ -60,8 +61,31 @@ const splitSaveBtnProps = {
|
||||
...mockedProps.database,
|
||||
allows_virtual_table_explore: true,
|
||||
},
|
||||
columns: RESULT_COLUMNS,
|
||||
};
|
||||
|
||||
const EDITOR_SQL = 'SELECT * FROM t';
|
||||
|
||||
const stateWithLatestQuery = ({
|
||||
id,
|
||||
state,
|
||||
sql = EDITOR_SQL,
|
||||
}: {
|
||||
id: string;
|
||||
state: string;
|
||||
sql?: string;
|
||||
}) => ({
|
||||
...mockState,
|
||||
sqlLab: {
|
||||
...mockState.sqlLab,
|
||||
queryEditors: mockState.sqlLab.queryEditors.map(qe => ({
|
||||
...qe,
|
||||
latestQueryId: id,
|
||||
})),
|
||||
queries: { [id]: { id, state, sql } },
|
||||
},
|
||||
});
|
||||
|
||||
const middlewares = [thunk];
|
||||
const mockStore = configureStore(middlewares);
|
||||
|
||||
@@ -97,6 +121,71 @@ describe('SavedQuery', () => {
|
||||
expect(saveBtn).toBeVisible();
|
||||
});
|
||||
|
||||
test('blocks "Save dataset" until the query has run successfully', () => {
|
||||
// Without a successful run the save can only fail server-side.
|
||||
render(<SaveQuery {...splitSaveBtnProps} />, {
|
||||
useRedux: true,
|
||||
store: mockStore(stateWithLatestQuery({ id: 'qid-1', state: 'failed' })),
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: /save dataset/i }),
|
||||
).toBeDisabled();
|
||||
// Saving the query itself is unaffected.
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled();
|
||||
});
|
||||
|
||||
test('blocks "Save dataset" when no query has been run at all', () => {
|
||||
render(<SaveQuery {...splitSaveBtnProps} />, {
|
||||
useRedux: true,
|
||||
store: mockStore(mockState),
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: /save dataset/i }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
test('blocks "Save dataset" when the SQL changed after a successful run', () => {
|
||||
// The run succeeded, but not for what is in the editor now -- and it is
|
||||
// the editor's SQL that gets saved.
|
||||
render(<SaveQuery {...splitSaveBtnProps} />, {
|
||||
useRedux: true,
|
||||
store: mockStore(
|
||||
stateWithLatestQuery({
|
||||
id: 'qid-1',
|
||||
state: 'success',
|
||||
sql: 'SELECT 1 AS ran_earlier',
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: /save dataset/i }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
test('blocks "Save dataset" when the successful query returned no columns', () => {
|
||||
// e.g. a DDL/DML statement -- there is nothing to introspect into a dataset.
|
||||
render(<SaveQuery {...splitSaveBtnProps} columns={[]} />, {
|
||||
useRedux: true,
|
||||
store: mockStore(stateWithLatestQuery({ id: 'qid-1', state: 'success' })),
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: /save dataset/i }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
test('enables "Save dataset" once the query has succeeded', () => {
|
||||
render(<SaveQuery {...splitSaveBtnProps} />, {
|
||||
useRedux: true,
|
||||
store: mockStore(stateWithLatestQuery({ id: 'qid-1', state: 'success' })),
|
||||
});
|
||||
|
||||
expect(screen.getByRole('button', { name: /save dataset/i })).toBeEnabled();
|
||||
});
|
||||
|
||||
test('renders a save query modal when user clicks save button', () => {
|
||||
render(<SaveQuery {...mockedProps} />, {
|
||||
useRedux: true,
|
||||
@@ -234,7 +323,7 @@ describe('SavedQuery', () => {
|
||||
test('renders a save dataset modal when user clicks "save dataset" menu item', async () => {
|
||||
render(<SaveQuery {...splitSaveBtnProps} />, {
|
||||
useRedux: true,
|
||||
store: mockStore(mockState),
|
||||
store: mockStore(stateWithLatestQuery({ id: 'qid-1', state: 'success' })),
|
||||
});
|
||||
|
||||
const saveDatasetMenuItem = await screen.findByLabelText(/save dataset/i);
|
||||
@@ -248,7 +337,7 @@ describe('SavedQuery', () => {
|
||||
test('renders the save dataset modal UI', async () => {
|
||||
render(<SaveQuery {...splitSaveBtnProps} />, {
|
||||
useRedux: true,
|
||||
store: mockStore(mockState),
|
||||
store: mockStore(stateWithLatestQuery({ id: 'qid-1', state: 'success' })),
|
||||
});
|
||||
const saveDatasetMenuItem = await screen.findByLabelText(/save dataset/i);
|
||||
userEvent.click(saveDatasetMenuItem);
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { useState, useEffect, useMemo, ChangeEvent } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Query, QueryState } from '@superset-ui/core';
|
||||
import type { DatabaseObject } from 'src/features/databases/types';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { styled } from '@apache-superset/core/theme';
|
||||
@@ -37,7 +39,7 @@ import {
|
||||
} from 'src/SqlLab/components/SaveDatasetModal';
|
||||
import { getDatasourceAsSaveableDataset } from 'src/utils/datasourceUtils';
|
||||
import useQueryEditor from 'src/SqlLab/hooks/useQueryEditor';
|
||||
import { QueryEditor } from 'src/SqlLab/types';
|
||||
import { QueryEditor, SqlLabRootState } from 'src/SqlLab/types';
|
||||
import useLogAction from 'src/logger/useLogAction';
|
||||
import {
|
||||
LOG_ACTIONS_SQLLAB_CREATE_CHART,
|
||||
@@ -52,7 +54,6 @@ interface SaveQueryProps {
|
||||
onUpdate: (arg0: QueryPayload, id: string) => void;
|
||||
saveQueryWarning: string | null;
|
||||
database: Partial<DatabaseObject> | undefined;
|
||||
canSaveDataset: boolean;
|
||||
}
|
||||
|
||||
export type QueryPayload = {
|
||||
@@ -82,7 +83,6 @@ const SaveQuery = ({
|
||||
saveQueryWarning,
|
||||
database,
|
||||
columns,
|
||||
canSaveDataset,
|
||||
}: SaveQueryProps) => {
|
||||
const queryEditor = useQueryEditor(queryEditorId, [
|
||||
'autorun',
|
||||
@@ -113,6 +113,17 @@ const SaveQuery = ({
|
||||
const [label, setLabel] = useState<string>(defaultLabel);
|
||||
const [showSave, setShowSave] = useState<boolean>(false);
|
||||
const [showSaveDatasetModal, setShowSaveDatasetModal] = useState(false);
|
||||
// Saving a dataset runs the SQL to introspect columns, so it needs a
|
||||
// successful run of the SQL being saved that produced at least one column
|
||||
// -- editing after a run invalidates it, and running a selection only
|
||||
// validates that selection.
|
||||
const latestQuery = useSelector<SqlLabRootState, Query | undefined>(
|
||||
({ sqlLab }) => sqlLab.queries[queryEditor.latestQueryId || ''],
|
||||
);
|
||||
const canSaveDataset =
|
||||
latestQuery?.state === QueryState.Success &&
|
||||
latestQuery.sql === queryEditor.sql &&
|
||||
columns.length > 0;
|
||||
const isSaved = !!query.remoteId;
|
||||
const isLabelEmpty = label.trim().length === 0;
|
||||
const canExploreDatabase = !!database?.allows_virtual_table_explore;
|
||||
|
||||
@@ -355,25 +355,32 @@ describe('SqlEditor', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
// findByRole('button', { name }) walks every stylesheet rule via nwsapi to
|
||||
// compute the accessible name, which can crash on an unrelated antd Tabs
|
||||
// "more" button style; findByLabelText matches the same aria-label without
|
||||
// that traversal.
|
||||
test('enables the save dataset button when the latest query succeeded', async () => {
|
||||
const { findByRole } = setupWithLatestQuery({ state: QueryState.Success });
|
||||
expect(await findByRole('button', { name: 'Save dataset' })).toBeEnabled();
|
||||
const { findByLabelText } = setupWithLatestQuery({
|
||||
state: QueryState.Success,
|
||||
sql: mockedProps.queryEditor.sql,
|
||||
});
|
||||
expect(await findByLabelText('Save dataset')).toBeEnabled();
|
||||
});
|
||||
|
||||
test('disables the save dataset button when the latest query failed', async () => {
|
||||
const { findByRole } = setupWithLatestQuery({
|
||||
const { findByLabelText } = setupWithLatestQuery({
|
||||
state: QueryState.Failed,
|
||||
results: undefined,
|
||||
});
|
||||
expect(await findByRole('button', { name: 'Save dataset' })).toBeDisabled();
|
||||
expect(await findByLabelText('Save dataset')).toBeDisabled();
|
||||
});
|
||||
|
||||
test('disables the save dataset button when the results are not loaded', async () => {
|
||||
const { findByRole } = setupWithLatestQuery({
|
||||
const { findByLabelText } = setupWithLatestQuery({
|
||||
state: QueryState.Success,
|
||||
results: undefined,
|
||||
});
|
||||
expect(await findByRole('button', { name: 'Save dataset' })).toBeDisabled();
|
||||
expect(await findByLabelText('Save dataset')).toBeDisabled();
|
||||
});
|
||||
|
||||
test('renders an Extension if provided', async () => {
|
||||
|
||||
@@ -868,7 +868,6 @@ const SqlEditor: FC<Props> = ({
|
||||
}
|
||||
saveQueryWarning={saveQueryWarning}
|
||||
database={database}
|
||||
canSaveDataset={successful && resultColumns.length > 0}
|
||||
/>
|
||||
<ShareSqlLabQuery queryEditorId={queryEditor.id} />
|
||||
</>
|
||||
|
||||
@@ -53,6 +53,22 @@ test('RowCountLabel renders limit with danger and tooltip', async () => {
|
||||
expect(tooltip).toHaveTextContent('The row limit');
|
||||
});
|
||||
|
||||
test('RowCountLabel uses a caller-provided limitReachedMessage instead of the default', async () => {
|
||||
render(
|
||||
<RowCountLabel
|
||||
rowcount={100}
|
||||
limit={100}
|
||||
limitReachedMessage="Custom limit message"
|
||||
/>,
|
||||
);
|
||||
const expectedText = '100 rows';
|
||||
expect(screen.getByText(expectedText)).toBeInTheDocument();
|
||||
userEvent.hover(screen.getByText(expectedText));
|
||||
const tooltip = await screen.findByRole('tooltip');
|
||||
expect(tooltip).toHaveTextContent('Custom limit message');
|
||||
expect(tooltip).not.toHaveTextContent('The row limit set for the chart');
|
||||
});
|
||||
|
||||
test('RowCountLabel renders loading', () => {
|
||||
render(<RowCountLabel loading />);
|
||||
const expectedText = 'Loading...';
|
||||
|
||||
@@ -26,6 +26,9 @@ type RowCountLabelProps = {
|
||||
limit?: number;
|
||||
loading?: boolean;
|
||||
label?: JSX.Element;
|
||||
// Overrides the default "chart" wording for panes (e.g. samples) where the
|
||||
// limit reached isn't the chart's own row_limit.
|
||||
limitReachedMessage?: React.ReactNode;
|
||||
};
|
||||
|
||||
const limitReachedMsg = t(
|
||||
@@ -33,7 +36,13 @@ const limitReachedMsg = t(
|
||||
);
|
||||
|
||||
export default function RowCountLabel(props: RowCountLabelProps) {
|
||||
const { rowcount = 0, limit = null, loading, label } = props;
|
||||
const {
|
||||
rowcount = 0,
|
||||
limit = null,
|
||||
loading,
|
||||
label,
|
||||
limitReachedMessage,
|
||||
} = props;
|
||||
const limitReached = limit && rowcount >= limit;
|
||||
const type =
|
||||
limitReached || (rowcount === 0 && !loading) ? 'error' : 'default';
|
||||
@@ -50,7 +59,10 @@ export default function RowCountLabel(props: RowCountLabelProps) {
|
||||
</Label>
|
||||
);
|
||||
return limitReached ? (
|
||||
<Tooltip id="tt-rowcount-tooltip" title={<span>{limitReachedMsg}</span>}>
|
||||
<Tooltip
|
||||
id="tt-rowcount-tooltip"
|
||||
title={<span>{limitReachedMessage ?? limitReachedMsg}</span>}
|
||||
>
|
||||
{label || labelText}
|
||||
</Tooltip>
|
||||
) : (
|
||||
|
||||
@@ -16,7 +16,13 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { render, screen, waitFor } from 'spec/helpers/testing-library';
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
userEvent,
|
||||
fireEvent,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import Control, { ControlProps } from 'src/explore/components/Control';
|
||||
|
||||
const defaultProps: ControlProps = {
|
||||
@@ -77,3 +83,72 @@ test('call setControlValue if isVisible is false', async () => {
|
||||
expect(defaultProps.actions.setControlValue).toHaveBeenCalled(),
|
||||
);
|
||||
});
|
||||
|
||||
test('shows the description icon while the control is hovered', async () => {
|
||||
render(
|
||||
setup({
|
||||
label: 'My checkbox',
|
||||
description: 'Help text',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Show info tooltip' }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.hover(screen.getByTestId('checkbox'));
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Show info tooltip' }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await userEvent.unhover(screen.getByTestId('checkbox'));
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Show info tooltip' }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('shows the description icon while the control has keyboard focus', () => {
|
||||
render(
|
||||
setup({
|
||||
label: 'My checkbox',
|
||||
description: 'Help text',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Show info tooltip' }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.focus(screen.getByRole('checkbox'));
|
||||
const infoIcon = screen.getByRole('button', { name: 'Show info tooltip' });
|
||||
expect(infoIcon).toBeInTheDocument();
|
||||
|
||||
fireEvent.blur(screen.getByRole('checkbox'), { relatedTarget: infoIcon });
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Show info tooltip' }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
fireEvent.blur(infoIcon, { relatedTarget: document.body });
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Show info tooltip' }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('keeps the description icon visible when the pointer leaves a focused control', () => {
|
||||
render(
|
||||
setup({
|
||||
label: 'My checkbox',
|
||||
description: 'Help text',
|
||||
}),
|
||||
);
|
||||
|
||||
fireEvent.focus(screen.getByRole('checkbox'));
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Show info tooltip' }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
fireEvent.mouseLeave(screen.getByTestId('checkbox'));
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Show info tooltip' }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { ReactNode, useCallback, useState, useEffect } from 'react';
|
||||
import { ReactNode, useCallback, useState, useEffect, FocusEvent } from 'react';
|
||||
import { isEqual } from 'lodash-es';
|
||||
import {
|
||||
ControlType,
|
||||
@@ -70,7 +70,18 @@ export default function Control(props: ControlProps) {
|
||||
} = props;
|
||||
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const [focused, setFocused] = useState(false);
|
||||
const wasVisible = usePrevious(isVisible);
|
||||
|
||||
const handleBlur = (event: FocusEvent<HTMLDivElement>) => {
|
||||
if (
|
||||
!(event.relatedTarget instanceof Node) ||
|
||||
!event.currentTarget.contains(event.relatedTarget)
|
||||
) {
|
||||
setFocused(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onChange = useCallback(
|
||||
(value: any, errors: any[]) => setControlValue(name, value, errors),
|
||||
[name, setControlValue],
|
||||
@@ -119,9 +130,15 @@ export default function Control(props: ControlProps) {
|
||||
style={hidden ? { display: 'none' } : undefined}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={handleBlur}
|
||||
>
|
||||
<ErrorBoundary>
|
||||
<ControlComponent onChange={onChange} hovered={hovered} {...props} />
|
||||
<ControlComponent
|
||||
onChange={onChange}
|
||||
hovered={hovered || focused}
|
||||
{...props}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</StyledControl>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
userEvent,
|
||||
fireEvent,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import ControlHeader from './ControlHeader';
|
||||
|
||||
const description = 'This control filters the whole chart.';
|
||||
|
||||
test('does not render the description icon until the control is hovered', () => {
|
||||
const { rerender } = render(
|
||||
<ControlHeader
|
||||
name="time_range"
|
||||
label="Date Range"
|
||||
description={description}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Show info tooltip' }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<ControlHeader
|
||||
name="time_range"
|
||||
label="Date Range"
|
||||
description={description}
|
||||
hovered
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Show info tooltip' }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('notifies onDescriptionHoverChange when the info icon is hovered', async () => {
|
||||
const onDescriptionHoverChange = jest.fn();
|
||||
render(
|
||||
<ControlHeader
|
||||
name="time_range"
|
||||
label="Date Range"
|
||||
description={description}
|
||||
hovered
|
||||
onDescriptionHoverChange={onDescriptionHoverChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
const infoIcon = screen.getByRole('button', { name: 'Show info tooltip' });
|
||||
await userEvent.hover(infoIcon);
|
||||
expect(onDescriptionHoverChange).toHaveBeenCalledWith(true);
|
||||
|
||||
await userEvent.unhover(infoIcon);
|
||||
expect(onDescriptionHoverChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
test('notifies onDescriptionHoverChange when the info icon is focused', () => {
|
||||
const onDescriptionHoverChange = jest.fn();
|
||||
render(
|
||||
<ControlHeader
|
||||
name="time_range"
|
||||
label="Date Range"
|
||||
description={description}
|
||||
hovered
|
||||
onDescriptionHoverChange={onDescriptionHoverChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
const infoIcon = screen.getByRole('button', { name: 'Show info tooltip' });
|
||||
fireEvent.focus(infoIcon);
|
||||
expect(onDescriptionHoverChange).toHaveBeenCalledWith(true);
|
||||
|
||||
fireEvent.blur(infoIcon);
|
||||
expect(onDescriptionHoverChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
test('activates tooltipOnClick from the keyboard', () => {
|
||||
const tooltipOnClick = jest.fn();
|
||||
render(
|
||||
<ControlHeader
|
||||
name="time_range"
|
||||
label="Date Range"
|
||||
description={description}
|
||||
hovered
|
||||
tooltipOnClick={tooltipOnClick}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.keyDown(screen.getByRole('button', { name: 'Show info tooltip' }), {
|
||||
key: 'Enter',
|
||||
});
|
||||
expect(tooltipOnClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -38,6 +38,7 @@ export type ControlHeaderProps = {
|
||||
tooltipOnClick?: () => void;
|
||||
warning?: string;
|
||||
danger?: string;
|
||||
onDescriptionHoverChange?: (hovered: boolean) => void;
|
||||
// Allow extra props from control spread patterns (e.g. {...this.props})
|
||||
[key: string]: unknown;
|
||||
};
|
||||
@@ -71,6 +72,7 @@ const ControlHeader: FC<ControlHeaderProps> = ({
|
||||
tooltipOnClick = () => {},
|
||||
warning,
|
||||
danger,
|
||||
onDescriptionHoverChange,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
|
||||
@@ -89,24 +91,44 @@ const ControlHeader: FC<ControlHeaderProps> = ({
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 0;
|
||||
z-index: 1;
|
||||
padding-left: ${theme.sizeUnit}px;
|
||||
transform: translate(100%, -50%);
|
||||
white-space: nowrap;
|
||||
pointer-events: auto;
|
||||
`}
|
||||
>
|
||||
{description && (
|
||||
<span>
|
||||
<>
|
||||
<Tooltip
|
||||
id="description-tooltip"
|
||||
title={description}
|
||||
placement="top"
|
||||
mouseLeaveDelay={0}
|
||||
trigger={['hover', 'focus']}
|
||||
>
|
||||
<Icons.InfoCircleOutlined
|
||||
css={iconStyles}
|
||||
{/* Same role="button" pattern as the label text: a real <button>
|
||||
is not valid inside FormLabel's <label>. */}
|
||||
<span
|
||||
// eslint-disable-next-line jsx-a11y/prefer-tag-over-role
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
data-test={`${name}-description-icon`}
|
||||
aria-label={t('Show info tooltip')}
|
||||
onMouseEnter={() => onDescriptionHoverChange?.(true)}
|
||||
onMouseLeave={() => onDescriptionHoverChange?.(false)}
|
||||
onFocus={() => onDescriptionHoverChange?.(true)}
|
||||
onBlur={() => onDescriptionHoverChange?.(false)}
|
||||
onClick={tooltipOnClick}
|
||||
/>
|
||||
onKeyDown={handleKeyboardActivation(tooltipOnClick)}
|
||||
css={css`
|
||||
cursor: pointer;
|
||||
`}
|
||||
>
|
||||
<Icons.InfoCircleOutlined css={iconStyles} />
|
||||
</span>
|
||||
</Tooltip>{' '}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{renderTrigger && (
|
||||
<span>
|
||||
|
||||
+11
-4
@@ -68,6 +68,8 @@ export const TableControls = ({
|
||||
canDownload,
|
||||
rowLimit,
|
||||
rowLimitOptions,
|
||||
effectiveRowLimit,
|
||||
limitReachedMessage,
|
||||
onRowLimitChange,
|
||||
onDownloadCSV,
|
||||
onDownloadXLSX,
|
||||
@@ -111,14 +113,19 @@ export const TableControls = ({
|
||||
value={rowLimit}
|
||||
onChange={onRowLimitChange}
|
||||
options={rowLimitOptions ?? []}
|
||||
// Labelled as the applied limit to avoid a second row count next to RowCountLabel.
|
||||
prefix={t('Limit')}
|
||||
css={css`
|
||||
min-width: 110px;
|
||||
min-width: 160px;
|
||||
`}
|
||||
/>
|
||||
)}
|
||||
{(!onRowLimitChange || rowcount < (rowLimit ?? Infinity)) && (
|
||||
<RowCountLabel rowcount={rowcount} loading={isLoading} />
|
||||
)}
|
||||
<RowCountLabel
|
||||
rowcount={rowcount}
|
||||
limit={effectiveRowLimit ?? rowLimit}
|
||||
limitReachedMessage={limitReachedMessage}
|
||||
loading={isLoading}
|
||||
/>
|
||||
{canDownload && onDownloadCSV && onDownloadXLSX && (
|
||||
<DownloadDropdown
|
||||
onDownloadCSV={onDownloadCSV}
|
||||
|
||||
@@ -136,6 +136,12 @@ export const SamplesPane = ({
|
||||
|
||||
const columns = useGridColumns(colnames, coltypes, data);
|
||||
const keywordFilter = useKeywordFilter(filterText);
|
||||
// Samples aren't capped by a chart's row_limit, just this pane's own
|
||||
// page-size selector, so RowCountLabel's default "chart" wording is wrong here.
|
||||
const limitReachedMessage = t(
|
||||
'The sample row limit was reached. This %s may contain more rows.',
|
||||
datasetLabelLower(),
|
||||
);
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
(input: string) => setFilterText(input),
|
||||
@@ -161,6 +167,7 @@ export const SamplesPane = ({
|
||||
canDownload={canDownload}
|
||||
rowLimit={rowLimit}
|
||||
rowLimitOptions={ROW_LIMIT_OPTIONS}
|
||||
limitReachedMessage={limitReachedMessage}
|
||||
onRowLimitChange={handleRowLimitChange}
|
||||
/>
|
||||
<ErrorAlertWrapper>
|
||||
@@ -197,6 +204,7 @@ export const SamplesPane = ({
|
||||
canDownload={canDownload}
|
||||
rowLimit={rowLimit}
|
||||
rowLimitOptions={ROW_LIMIT_OPTIONS}
|
||||
limitReachedMessage={limitReachedMessage}
|
||||
onRowLimitChange={handleRowLimitChange}
|
||||
/>
|
||||
<GridContainer>
|
||||
|
||||
+4
@@ -56,6 +56,8 @@ export const SingleQueryResultPane = ({
|
||||
columnDisplayNames,
|
||||
rowLimit,
|
||||
rowLimitOptions,
|
||||
effectiveRowLimit,
|
||||
limitReachedMessage,
|
||||
onRowLimitChange,
|
||||
onDownloadCSV,
|
||||
onDownloadXLSX,
|
||||
@@ -86,6 +88,8 @@ export const SingleQueryResultPane = ({
|
||||
canDownload={canDownload}
|
||||
rowLimit={rowLimit}
|
||||
rowLimitOptions={rowLimitOptions}
|
||||
effectiveRowLimit={effectiveRowLimit}
|
||||
limitReachedMessage={limitReachedMessage}
|
||||
onRowLimitChange={onRowLimitChange}
|
||||
onDownloadCSV={onDownloadCSV}
|
||||
onDownloadXLSX={onDownloadXLSX}
|
||||
|
||||
@@ -84,6 +84,17 @@ export const useResultsPane = ({
|
||||
// Never exceed the chart's own row_limit
|
||||
const effectiveRowLimit = Math.min(rowLimit, chartRowLimit);
|
||||
|
||||
// When this pane's own row-limit selector is stricter than the chart's
|
||||
// row_limit, it - not the chart - is what caps the result, so
|
||||
// RowCountLabel's default "chart" wording would be misleading (the chart's
|
||||
// configured row_limit was never actually reached).
|
||||
const limitReachedMessage =
|
||||
rowLimit < chartRowLimit
|
||||
? t(
|
||||
'The row limit selected for this pane was reached. There may be more matching rows.',
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const cappedFormData = useMemo(
|
||||
() => ({ ...queryFormData, row_limit: effectiveRowLimit }),
|
||||
[queryFormData, effectiveRowLimit],
|
||||
@@ -236,6 +247,8 @@ export const useResultsPane = ({
|
||||
columnDisplayNames={columnDisplayNames}
|
||||
rowLimit={rowLimit}
|
||||
rowLimitOptions={ROW_LIMIT_OPTIONS}
|
||||
effectiveRowLimit={effectiveRowLimit}
|
||||
limitReachedMessage={limitReachedMessage}
|
||||
onRowLimitChange={handleRowLimitChange}
|
||||
/>
|
||||
</StyledDiv>
|
||||
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
import {
|
||||
act,
|
||||
render,
|
||||
screen,
|
||||
sleep,
|
||||
userEvent,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import { GenericDataType } from '@apache-superset/core/common';
|
||||
import {
|
||||
TableControls,
|
||||
ROW_LIMIT_OPTIONS,
|
||||
} from '../components/DataTableControls';
|
||||
import { TableControlsProps } from '../types';
|
||||
|
||||
const setup = (overrides: Partial<TableControlsProps> = {}) =>
|
||||
render(
|
||||
<TableControls
|
||||
data={[]}
|
||||
columnNames={['name']}
|
||||
columnTypes={[GenericDataType.String]}
|
||||
rowcount={0}
|
||||
onInputChange={jest.fn()}
|
||||
isLoading={false}
|
||||
canDownload
|
||||
rowLimit={100}
|
||||
rowLimitOptions={ROW_LIMIT_OPTIONS}
|
||||
onRowLimitChange={jest.fn()}
|
||||
{...overrides}
|
||||
/>,
|
||||
{ useRedux: true },
|
||||
);
|
||||
|
||||
test('shows the row count when the result fills the selected row limit', () => {
|
||||
setup({ rowcount: 100, rowLimit: 100 });
|
||||
|
||||
expect(screen.getByTestId('row-count-label')).toHaveTextContent('100 rows');
|
||||
});
|
||||
|
||||
test('warns that the row limit was reached when the result fills it', async () => {
|
||||
setup({ rowcount: 100, rowLimit: 100 });
|
||||
|
||||
userEvent.hover(screen.getByTestId('row-count-label'));
|
||||
|
||||
expect(await screen.findByRole('tooltip')).toHaveTextContent(
|
||||
'The row limit set for the chart was reached',
|
||||
);
|
||||
});
|
||||
|
||||
test('does not warn when the result is smaller than the selected row limit', async () => {
|
||||
setup({ rowcount: 42, rowLimit: 100 });
|
||||
|
||||
expect(screen.getByTestId('row-count-label')).toHaveTextContent('42 rows');
|
||||
userEvent.hover(screen.getByTestId('row-count-label'));
|
||||
|
||||
// Wait past antd's 0.1s mouseEnterDelay so a regression that made the
|
||||
// tooltip appear would be caught here instead of racing the delay.
|
||||
await act(() => sleep(150));
|
||||
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("warns when the chart's own row limit truncates below the selected row limit", async () => {
|
||||
setup({ rowcount: 250, rowLimit: 1000, effectiveRowLimit: 250 });
|
||||
|
||||
userEvent.hover(screen.getByTestId('row-count-label'));
|
||||
|
||||
expect(await screen.findByRole('tooltip')).toHaveTextContent(
|
||||
'The row limit set for the chart was reached',
|
||||
);
|
||||
});
|
||||
|
||||
test('labels the row limit selector so it is not read as a second row count', () => {
|
||||
setup({ rowcount: 100, rowLimit: 100 });
|
||||
|
||||
expect(screen.getByText('Limit')).toBeInTheDocument();
|
||||
});
|
||||
+42
-1
@@ -16,7 +16,12 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { screen, render, waitFor } from 'spec/helpers/testing-library';
|
||||
import {
|
||||
screen,
|
||||
render,
|
||||
waitFor,
|
||||
userEvent,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import { setupAGGridModules } from '@superset-ui/core/components/ThemedAgGridReact';
|
||||
import { getChartDataRequest } from 'src/components/Chart/chartAction';
|
||||
import { ResultsPaneOnDashboard } from '../components';
|
||||
@@ -157,6 +162,42 @@ describe('useResultsPane query data reuse', () => {
|
||||
expect(screen.queryByText('Sci-Fi')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('2 rows')).toBeVisible();
|
||||
expect(mockedGetChartDataRequest).not.toHaveBeenCalled();
|
||||
|
||||
userEvent.hover(screen.getByText('2 rows'));
|
||||
expect(await screen.findByRole('tooltip')).toHaveTextContent(
|
||||
'The row limit set for the chart was reached',
|
||||
);
|
||||
});
|
||||
|
||||
test("warns about this pane's own row limit, not the chart's, when the pane's selector is what caps the result", async () => {
|
||||
// chart row_limit (2000) is well above this pane's default 1000-row
|
||||
// selector, so the selector - not the chart - is what truncates here.
|
||||
const props = createResultsPaneOnDashboardProps({
|
||||
sliceId: 208,
|
||||
rowLimit: 2000,
|
||||
queriesResponse: [
|
||||
{
|
||||
colnames: ['genre'],
|
||||
coltypes: [1],
|
||||
data: Array.from({ length: 1500 }, (_, i) => ({
|
||||
genre: `genre-${i}`,
|
||||
})),
|
||||
rowcount: 1500,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<ResultsPaneOnDashboard {...props} />, { useRedux: true });
|
||||
|
||||
const rowCountLabel = await screen.findByTestId('row-count-label');
|
||||
expect(rowCountLabel).toHaveTextContent('1k rows');
|
||||
|
||||
userEvent.hover(rowCountLabel);
|
||||
const tooltip = await screen.findByRole('tooltip');
|
||||
expect(tooltip).toHaveTextContent(
|
||||
'The row limit selected for this pane was reached',
|
||||
);
|
||||
expect(tooltip).not.toHaveTextContent('for the chart');
|
||||
});
|
||||
|
||||
test('renders an empty (0 rows) result from reused data without an API call', async () => {
|
||||
|
||||
@@ -84,6 +84,12 @@ export interface TableControlsProps extends DrillControlsProps {
|
||||
canDownload: boolean;
|
||||
rowLimit?: number;
|
||||
rowLimitOptions?: { value: number; label: string }[];
|
||||
// Effective result limit, capped by the chart's row limit.
|
||||
// Defaults to `rowLimit` and controls the "row limit reached" warning.
|
||||
effectiveRowLimit?: number;
|
||||
// Overrides RowCountLabel's default "chart" wording for panes (e.g.
|
||||
// samples) where the limit reached isn't the chart's own row_limit.
|
||||
limitReachedMessage?: React.ReactNode;
|
||||
onRowLimitChange?: (limit: number) => void;
|
||||
}
|
||||
|
||||
@@ -104,5 +110,9 @@ export interface SingleQueryResultPaneProp
|
||||
columnDisplayNames?: Record<string, string>;
|
||||
rowLimit?: number;
|
||||
rowLimitOptions?: { value: number; label: string }[];
|
||||
effectiveRowLimit?: number;
|
||||
// Overrides RowCountLabel's default "chart" wording when the pane's own
|
||||
// row-limit selector, not the chart's row_limit, is what capped the result.
|
||||
limitReachedMessage?: React.ReactNode;
|
||||
onRowLimitChange?: (limit: number) => void;
|
||||
}
|
||||
|
||||
+18
-2
@@ -147,6 +147,7 @@ export default function DateFilterLabel(props: DateFilterControlProps) {
|
||||
onOpenPopover = noOp,
|
||||
onClosePopover = noOp,
|
||||
isOverflowingFilterBar = false,
|
||||
hovered: isControlHovered = false,
|
||||
} = props;
|
||||
const defaultTimeFilter = useDefaultTimeFilter();
|
||||
|
||||
@@ -161,9 +162,16 @@ export default function DateFilterLabel(props: DateFilterControlProps) {
|
||||
const [validTimeRange, setValidTimeRange] = useState<boolean>(false);
|
||||
const [evalResponse, setEvalResponse] = useState<string>(value);
|
||||
const [tooltipTitle, setTooltipTitle] = useState<ReactNode | null>(t(value));
|
||||
const [isDescriptionHovered, setIsDescriptionHovered] = useState(false);
|
||||
const theme = useTheme();
|
||||
const [labelRef, labelIsTruncated] = useCSSTextTruncation<HTMLSpanElement>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isControlHovered) {
|
||||
setIsDescriptionHovered(false);
|
||||
}
|
||||
}, [isControlHovered]);
|
||||
|
||||
useEffect(() => {
|
||||
if (value === NO_TIME_RANGE) {
|
||||
setActualTimeRange(NO_TIME_RANGE);
|
||||
@@ -368,7 +376,12 @@ export default function DateFilterLabel(props: DateFilterControlProps) {
|
||||
}
|
||||
overlayClassName="time-range-popover"
|
||||
>
|
||||
<Tooltip placement="top" title={tooltipTitle}>
|
||||
<Tooltip
|
||||
placement="top"
|
||||
title={isDescriptionHovered ? null : tooltipTitle}
|
||||
mouseLeaveDelay={0}
|
||||
overlayStyle={{ pointerEvents: 'none' }}
|
||||
>
|
||||
{/* Wrap in a span so the Popover gets a stable DOM ref target;
|
||||
DateLabel forwards its ref to an inner span used for measuring
|
||||
text truncation, which would otherwise become the popover's
|
||||
@@ -390,7 +403,10 @@ export default function DateFilterLabel(props: DateFilterControlProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<ControlHeader {...props} />
|
||||
<ControlHeader
|
||||
{...props}
|
||||
onDescriptionHoverChange={setIsDescriptionHovered}
|
||||
/>
|
||||
{popoverContent}
|
||||
</>
|
||||
);
|
||||
|
||||
+86
-4
@@ -18,16 +18,35 @@
|
||||
*/
|
||||
import thunk from 'redux-thunk';
|
||||
import { Provider } from 'react-redux';
|
||||
import configureStore from 'redux-mock-store';
|
||||
import configureMockStore from 'redux-mock-store';
|
||||
|
||||
import { render, screen, userEvent } from 'spec/helpers/testing-library';
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
userEvent,
|
||||
waitFor,
|
||||
fireEvent,
|
||||
} from 'spec/helpers/testing-library';
|
||||
|
||||
import { NO_TIME_RANGE } from '@superset-ui/core';
|
||||
import { NO_TIME_RANGE, fetchTimeRange } from '@superset-ui/core';
|
||||
import DateFilterLabel from '..';
|
||||
import { DateFilterControlProps } from '../types';
|
||||
import { DateFilterTestKey } from '../utils';
|
||||
|
||||
const mockStore = configureStore([thunk]);
|
||||
jest.mock('@superset-ui/core', () => ({
|
||||
...jest.requireActual('@superset-ui/core'),
|
||||
fetchTimeRange: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockedFetchTimeRange = fetchTimeRange as jest.MockedFunction<
|
||||
typeof fetchTimeRange
|
||||
>;
|
||||
|
||||
const FIELD_TOOLTIP = '2024-01-01 ≤ col < 2024-01-08';
|
||||
const DESCRIPTION_TOOLTIP =
|
||||
'This control filters the whole chart based on the selected time range.';
|
||||
|
||||
const mockStore = configureMockStore([thunk]);
|
||||
|
||||
const defaultProps = {
|
||||
onChange: jest.fn(),
|
||||
@@ -35,6 +54,11 @@ const defaultProps = {
|
||||
onOpenPopover: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockedFetchTimeRange.mockReset();
|
||||
mockedFetchTimeRange.mockResolvedValue({ value: FIELD_TOOLTIP });
|
||||
});
|
||||
|
||||
function setup(
|
||||
props: Omit<DateFilterControlProps, 'name'> = defaultProps,
|
||||
store: any = mockStore({}),
|
||||
@@ -136,3 +160,61 @@ test('DateFilter should properly handle isOverflowingFilterBar prop changes', ()
|
||||
expect(popoverAfterRerender?.parentElement).toBe(trigger.parentElement);
|
||||
expect(popoverAfterRerender?.parentElement).not.toBe(document.body);
|
||||
});
|
||||
|
||||
test('hovering the description icon does not show the date range tooltip', async () => {
|
||||
const tooltipOnClick = jest.fn();
|
||||
render(
|
||||
setup({
|
||||
...defaultProps,
|
||||
value: 'Last week',
|
||||
label: 'Date Range',
|
||||
description: DESCRIPTION_TOOLTIP,
|
||||
hovered: true,
|
||||
tooltipOnClick,
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Last week')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await userEvent.hover(screen.getByText('Last week'));
|
||||
expect(await screen.findByRole('tooltip')).toHaveTextContent(FIELD_TOOLTIP);
|
||||
|
||||
const descriptionIcon = screen.getByRole('button', {
|
||||
name: 'Show info tooltip',
|
||||
});
|
||||
fireEvent.focus(descriptionIcon);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('tooltip')).toHaveTextContent(DESCRIPTION_TOOLTIP);
|
||||
expect(screen.getByRole('tooltip')).not.toHaveTextContent(FIELD_TOOLTIP);
|
||||
expect(screen.getAllByRole('tooltip')).toHaveLength(1);
|
||||
});
|
||||
|
||||
fireEvent.blur(descriptionIcon);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('tooltip')).toHaveTextContent(FIELD_TOOLTIP);
|
||||
expect(screen.getAllByRole('tooltip')).toHaveLength(1);
|
||||
});
|
||||
|
||||
await userEvent.unhover(screen.getByText('Last week'));
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
await userEvent.hover(descriptionIcon);
|
||||
|
||||
const tooltip = await screen.findByRole('tooltip');
|
||||
expect(tooltip).toHaveTextContent(DESCRIPTION_TOOLTIP);
|
||||
expect(tooltip).not.toHaveTextContent(FIELD_TOOLTIP);
|
||||
expect(screen.getAllByRole('tooltip')).toHaveLength(1);
|
||||
|
||||
await userEvent.unhover(descriptionIcon);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.keyDown(descriptionIcon, { key: 'Enter' });
|
||||
expect(tooltipOnClick).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
export type SelectOptionType = {
|
||||
value: string;
|
||||
label: string;
|
||||
@@ -113,4 +115,8 @@ export interface DateFilterControlProps {
|
||||
onOpenPopover?: () => void;
|
||||
onClosePopover?: () => void;
|
||||
isOverflowingFilterBar?: boolean;
|
||||
hovered?: boolean;
|
||||
description?: ReactNode;
|
||||
label?: ReactNode;
|
||||
tooltipOnClick?: () => void;
|
||||
}
|
||||
|
||||
@@ -585,8 +585,17 @@ class ChartDataRestApi(ChartRestApi):
|
||||
query["timing"] = query_result.timing.as_public_dict()
|
||||
|
||||
if security_manager.is_guest_user():
|
||||
# Guests may see the generated SQL only when the role attached to
|
||||
# their guest token has been granted "can view query on Dashboard",
|
||||
# mirroring the permission the frontend uses to expose the
|
||||
# "View query" action. Stacktraces and driver errors stay redacted
|
||||
# regardless, as those leak details of the deployment itself.
|
||||
can_view_query = security_manager.can_access(
|
||||
"can_view_query", "Dashboard"
|
||||
)
|
||||
for query in queries:
|
||||
query.pop("query", None)
|
||||
if not can_view_query:
|
||||
query.pop("query", None)
|
||||
query.pop("stacktrace", None)
|
||||
if query.get("error"):
|
||||
query["error"] = sanitize_error_message(query["error"])
|
||||
|
||||
@@ -91,8 +91,10 @@ class ExportChartsCommand(ExportModelsCommand):
|
||||
def enable_tag_export(cls) -> None:
|
||||
cls._include_tags = True
|
||||
|
||||
def run(self) -> Iterator[tuple[str, Callable[[], str]]]:
|
||||
yield from super().run()
|
||||
def run(
|
||||
self, seen: set[str] | None = None
|
||||
) -> Iterator[tuple[str, Callable[[], str]]]:
|
||||
yield from super().run(seen=seen)
|
||||
|
||||
# Tags are exported once for all requested charts (rather than per
|
||||
# chart in `_export`) so a multi-chart export doesn't lose tags to
|
||||
@@ -108,12 +110,17 @@ class ExportChartsCommand(ExportModelsCommand):
|
||||
|
||||
@staticmethod
|
||||
def _export(
|
||||
model: Slice, export_related: bool = True
|
||||
model: Slice, export_related: bool = True, seen: set[str] | None = None
|
||||
) -> Iterator[tuple[str, Callable[[], str]]]:
|
||||
# Initialize seen set if not provided
|
||||
if seen is None:
|
||||
seen = set()
|
||||
|
||||
yield (
|
||||
ExportChartsCommand._file_name(model),
|
||||
lambda: ExportChartsCommand._file_content(model),
|
||||
)
|
||||
|
||||
if model.table and export_related:
|
||||
yield from ExportDatasetsCommand([model.table.id]).run()
|
||||
# Pass the shared seen set to the dataset export command
|
||||
yield from ExportDatasetsCommand([model.table.id]).run(seen=seen)
|
||||
|
||||
@@ -383,8 +383,12 @@ class ExportDashboardsCommand(ExportModelsCommand):
|
||||
@staticmethod
|
||||
# ruff: noqa: C901
|
||||
def _export(
|
||||
model: Dashboard, export_related: bool = True
|
||||
model: Dashboard, export_related: bool = True, seen: set[str] | None = None
|
||||
) -> Iterator[tuple[str, Callable[[], str]]]:
|
||||
# Initialize seen set if not provided
|
||||
if seen is None:
|
||||
seen = set()
|
||||
|
||||
yield (
|
||||
ExportDashboardsCommand._file_name(model),
|
||||
lambda: ExportDashboardsCommand._file_content(model),
|
||||
@@ -395,8 +399,11 @@ class ExportDashboardsCommand(ExportModelsCommand):
|
||||
dashboard_ids = model.id
|
||||
command = ExportChartsCommand(chart_ids)
|
||||
command.disable_tag_export()
|
||||
yield from command.run()
|
||||
command.enable_tag_export()
|
||||
try:
|
||||
# Pass the shared seen set to the chart export command
|
||||
yield from command.run(seen=seen)
|
||||
finally:
|
||||
command.enable_tag_export()
|
||||
if feature_flag_manager.is_feature_enabled("TAGGING_SYSTEM"):
|
||||
yield from ExportTagsCommand(
|
||||
dashboard_ids=dashboard_ids, chart_ids=chart_ids
|
||||
@@ -406,7 +413,8 @@ class ExportDashboardsCommand(ExportModelsCommand):
|
||||
if model.theme:
|
||||
from superset.commands.theme.export import ExportThemesCommand
|
||||
|
||||
yield from ExportThemesCommand([model.theme.id]).run()
|
||||
# Pass the shared seen set to the theme export command
|
||||
yield from ExportThemesCommand([model.theme.id]).run(seen=seen)
|
||||
|
||||
payload = model.export_to_dict(
|
||||
recursive=False,
|
||||
@@ -435,7 +443,10 @@ class ExportDashboardsCommand(ExportModelsCommand):
|
||||
if dataset_id is not None:
|
||||
dataset = DatasetDAO.find_by_id(dataset_id)
|
||||
if dataset:
|
||||
yield from ExportDatasetsCommand([dataset_id]).run()
|
||||
# Pass the shared seen set to the dataset export command
|
||||
yield from ExportDatasetsCommand([dataset_id]).run(
|
||||
seen=seen
|
||||
)
|
||||
|
||||
# Export datasets referenced by display controls
|
||||
for customization in (
|
||||
@@ -446,4 +457,7 @@ class ExportDashboardsCommand(ExportModelsCommand):
|
||||
if dataset_id is not None:
|
||||
dataset = DatasetDAO.find_by_id(dataset_id)
|
||||
if dataset:
|
||||
yield from ExportDatasetsCommand([dataset_id]).run()
|
||||
# Pass the shared seen set to the dataset export command
|
||||
yield from ExportDatasetsCommand([dataset_id]).run(
|
||||
seen=seen
|
||||
)
|
||||
|
||||
@@ -113,8 +113,12 @@ class ExportDatabasesCommand(ExportModelsCommand):
|
||||
|
||||
@staticmethod
|
||||
def _export(
|
||||
model: Database, export_related: bool = True
|
||||
model: Database, export_related: bool = True, seen: set[str] | None = None
|
||||
) -> Iterator[tuple[str, Callable[[], str]]]:
|
||||
# Initialize seen set if not provided
|
||||
if seen is None:
|
||||
seen = set()
|
||||
|
||||
yield (
|
||||
ExportDatabasesCommand._file_name(model),
|
||||
lambda: ExportDatabasesCommand._file_content(model),
|
||||
|
||||
@@ -219,6 +219,8 @@ class UploadCommand(BaseCommand):
|
||||
database_id=self._model_id,
|
||||
editors=editors,
|
||||
schema=self._schema,
|
||||
# Ensure catalog is set
|
||||
catalog=self._model.get_default_catalog(),
|
||||
)
|
||||
db.session.add(sqla_table)
|
||||
|
||||
|
||||
@@ -33,7 +33,12 @@ from superset.commands.dataset.exceptions import (
|
||||
)
|
||||
from superset.commands.utils import populate_subjects
|
||||
from superset.daos.dataset import DatasetDAO
|
||||
from superset.exceptions import SupersetParseError, SupersetSecurityException
|
||||
from superset.exceptions import (
|
||||
OAuth2RedirectError,
|
||||
SupersetException,
|
||||
SupersetParseError,
|
||||
SupersetSecurityException,
|
||||
)
|
||||
from superset.extensions import security_manager
|
||||
from superset.sql.parse import Table
|
||||
from superset.utils.decorators import on_error, transaction
|
||||
@@ -50,7 +55,28 @@ class CreateDatasetCommand(CreateMixin, BaseCommand):
|
||||
self.validate()
|
||||
|
||||
dataset = DatasetDAO.create(attributes=self._properties)
|
||||
dataset.fetch_metadata()
|
||||
try:
|
||||
dataset.fetch_metadata()
|
||||
except OAuth2RedirectError:
|
||||
# Must reach the caller unchanged to start the OAuth2 dance.
|
||||
raise
|
||||
except SupersetException as ex:
|
||||
# Not a SQLAlchemyError, so ``on_error`` re-raises it untouched and
|
||||
# it escapes to FAB's ``@safe`` as an opaque 500 "Fatal error".
|
||||
# Deliberately covers the 403 ``SupersetSecurityException`` raised
|
||||
# for mutation/multi-statement SQL too: ``validate()`` already
|
||||
# reports that class of rejection as a 422 on ``sql`` via
|
||||
# ``DatasetDataAccessIsNotAllowed``.
|
||||
raise DatasetInvalidError(
|
||||
exceptions=[
|
||||
ValidationError(
|
||||
# ``lazy_gettext`` messages aren't ``str``, so
|
||||
# marshmallow won't wrap them into a list on its own.
|
||||
[str(ex.message)],
|
||||
field_name="sql" if self._properties.get("sql") else "table",
|
||||
)
|
||||
]
|
||||
) from ex
|
||||
return dataset
|
||||
|
||||
def validate(self) -> None: # noqa: C901
|
||||
|
||||
@@ -89,8 +89,12 @@ class ExportDatasetsCommand(ExportModelsCommand):
|
||||
|
||||
@staticmethod
|
||||
def _export(
|
||||
model: SqlaTable, export_related: bool = True
|
||||
model: SqlaTable, export_related: bool = True, seen: set[str] | None = None
|
||||
) -> Iterator[tuple[str, Callable[[], str]]]:
|
||||
# Initialize seen set if not provided
|
||||
if seen is None:
|
||||
seen = set()
|
||||
|
||||
yield (
|
||||
ExportDatasetsCommand._file_name(model),
|
||||
lambda: ExportDatasetsCommand._file_content(model),
|
||||
@@ -103,32 +107,41 @@ class ExportDatasetsCommand(ExportModelsCommand):
|
||||
)
|
||||
file_path = f"databases/{db_file_name}.yaml"
|
||||
|
||||
payload = model.database.export_to_dict(
|
||||
recursive=False,
|
||||
include_parent_ref=False,
|
||||
include_defaults=True,
|
||||
export_uuids=True,
|
||||
)
|
||||
# TODO (betodealmeida): move this logic to export_to_dict once this
|
||||
# becomes the default export endpoint
|
||||
if payload.get("extra"):
|
||||
try:
|
||||
payload["extra"] = json.loads(payload["extra"])
|
||||
except json.JSONDecodeError:
|
||||
logger.info("Unable to decode `extra` field: %s", payload["extra"])
|
||||
|
||||
if ssh_tunnel := model.database.ssh_tunnel:
|
||||
ssh_tunnel_payload = ssh_tunnel.export_to_dict(
|
||||
# Only yield the database file if not already seen. This is
|
||||
# critical to fix the issue where databases were being
|
||||
# duplicated and potentially overwritten when charts from
|
||||
# different databases were exported.
|
||||
if file_path not in seen:
|
||||
payload = model.database.export_to_dict(
|
||||
recursive=False,
|
||||
include_parent_ref=False,
|
||||
include_defaults=True,
|
||||
export_uuids=False,
|
||||
export_uuids=True,
|
||||
)
|
||||
payload["ssh_tunnel"] = mask_password_info(ssh_tunnel_payload)
|
||||
# TODO (betodealmeida): move this logic to export_to_dict once this
|
||||
# becomes the default export endpoint
|
||||
if payload.get("extra"):
|
||||
try:
|
||||
payload["extra"] = json.loads(payload["extra"])
|
||||
except json.JSONDecodeError:
|
||||
logger.info(
|
||||
"Unable to decode `extra` field: %s", payload["extra"]
|
||||
)
|
||||
|
||||
payload["version"] = EXPORT_VERSION
|
||||
if ssh_tunnel := model.database.ssh_tunnel:
|
||||
ssh_tunnel_payload = ssh_tunnel.export_to_dict(
|
||||
recursive=False,
|
||||
include_parent_ref=False,
|
||||
include_defaults=True,
|
||||
export_uuids=False,
|
||||
)
|
||||
payload["ssh_tunnel"] = mask_password_info(ssh_tunnel_payload)
|
||||
|
||||
yield (
|
||||
file_path,
|
||||
lambda: yaml.safe_dump(payload, sort_keys=False, allow_unicode=True),
|
||||
)
|
||||
payload["version"] = EXPORT_VERSION
|
||||
|
||||
yield (
|
||||
file_path,
|
||||
lambda: yaml.safe_dump(
|
||||
payload, sort_keys=False, allow_unicode=True
|
||||
),
|
||||
)
|
||||
|
||||
@@ -47,27 +47,45 @@ class ExportModelsCommand(BaseCommand):
|
||||
|
||||
@staticmethod
|
||||
def _file_content(model: Model) -> str:
|
||||
raise NotImplementedError("Subclasses MUST implement _export")
|
||||
raise NotImplementedError("Subclasses MUST implement _file_content")
|
||||
|
||||
@staticmethod
|
||||
def _export(
|
||||
model: Model, export_related: bool = True
|
||||
model: Model, export_related: bool = True, seen: set[str] | None = None
|
||||
) -> Iterator[tuple[str, Callable[[], str]]]:
|
||||
raise NotImplementedError("Subclasses MUST implement _export")
|
||||
|
||||
def run(self) -> Iterator[tuple[str, Callable[[], str]]]:
|
||||
def run(
|
||||
self, seen: set[str] | None = None
|
||||
) -> Iterator[tuple[str, Callable[[], str]]]:
|
||||
self.validate()
|
||||
|
||||
metadata = {
|
||||
"version": EXPORT_VERSION,
|
||||
"type": self.dao.model_cls.__name__, # type: ignore
|
||||
"timestamp": datetime.now(tz=timezone.utc).isoformat(),
|
||||
}
|
||||
yield METADATA_FILE_NAME, lambda: yaml.safe_dump(metadata, sort_keys=False)
|
||||
# Use provided seen set or create new one
|
||||
if seen is None:
|
||||
seen = set()
|
||||
should_add_metadata = True
|
||||
else:
|
||||
# If seen set is provided, we're being called from another command
|
||||
should_add_metadata = False
|
||||
|
||||
# Only add metadata if this is the root command
|
||||
if should_add_metadata:
|
||||
metadata = {
|
||||
"version": EXPORT_VERSION,
|
||||
"type": self.dao.model_cls.__name__, # type: ignore
|
||||
"timestamp": datetime.now(tz=timezone.utc).isoformat(),
|
||||
}
|
||||
if METADATA_FILE_NAME not in seen:
|
||||
yield (
|
||||
METADATA_FILE_NAME,
|
||||
lambda: yaml.safe_dump(metadata, sort_keys=False),
|
||||
)
|
||||
seen.add(METADATA_FILE_NAME)
|
||||
|
||||
seen = {METADATA_FILE_NAME}
|
||||
for model in self._models:
|
||||
for file_name, file_content in self._export(model, self.export_related):
|
||||
for file_name, file_content in self._export(
|
||||
model, self.export_related, seen
|
||||
):
|
||||
if file_name not in seen:
|
||||
yield file_name, file_content
|
||||
seen.add(file_name)
|
||||
|
||||
@@ -67,8 +67,12 @@ class ExportSavedQueriesCommand(ExportModelsCommand):
|
||||
|
||||
@staticmethod
|
||||
def _export(
|
||||
model: SavedQuery, export_related: bool = True
|
||||
model: SavedQuery, export_related: bool = True, seen: set[str] | None = None
|
||||
) -> Iterator[tuple[str, Callable[[], str]]]:
|
||||
# Initialize seen set if not provided
|
||||
if seen is None:
|
||||
seen = set()
|
||||
|
||||
yield (
|
||||
ExportSavedQueriesCommand._file_name(model),
|
||||
lambda: ExportSavedQueriesCommand._file_content(model),
|
||||
@@ -79,21 +83,25 @@ class ExportSavedQueriesCommand(ExportModelsCommand):
|
||||
database_slug = secure_filename(model.database.database_name)
|
||||
file_name = f"databases/{database_slug}.yaml"
|
||||
|
||||
payload = model.database.export_to_dict(
|
||||
recursive=False,
|
||||
include_parent_ref=False,
|
||||
include_defaults=True,
|
||||
export_uuids=True,
|
||||
)
|
||||
# TODO (betodealmeida): move this logic to export_to_dict once this
|
||||
# becomes the default export endpoint
|
||||
if "extra" in payload:
|
||||
try:
|
||||
payload["extra"] = json.loads(payload["extra"])
|
||||
except json.JSONDecodeError:
|
||||
logger.info("Unable to decode `extra` field: %s", payload["extra"])
|
||||
# Only yield if not already seen (similar to dataset export)
|
||||
if file_name not in seen:
|
||||
payload = model.database.export_to_dict(
|
||||
recursive=False,
|
||||
include_parent_ref=False,
|
||||
include_defaults=True,
|
||||
export_uuids=True,
|
||||
)
|
||||
# TODO (betodealmeida): move this logic to export_to_dict once this
|
||||
# becomes the default export endpoint
|
||||
if "extra" in payload:
|
||||
try:
|
||||
payload["extra"] = json.loads(payload["extra"])
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
logger.info(
|
||||
"Unable to decode `extra` field: %s", payload["extra"]
|
||||
)
|
||||
|
||||
payload["version"] = EXPORT_VERSION
|
||||
payload["version"] = EXPORT_VERSION
|
||||
|
||||
file_content = yaml.safe_dump(payload, sort_keys=False)
|
||||
yield file_name, lambda: file_content
|
||||
file_content = yaml.safe_dump(payload, sort_keys=False)
|
||||
yield file_name, lambda: file_content
|
||||
|
||||
@@ -46,7 +46,9 @@ class ExportTagsCommand(ExportModelsCommand):
|
||||
self.dashboard_ids = dashboard_ids
|
||||
self.chart_ids = chart_ids
|
||||
|
||||
def run(self) -> Iterator[tuple[str, Callable[[], str]]]:
|
||||
def run(
|
||||
self, seen: set[str] | None = None
|
||||
) -> Iterator[tuple[str, Callable[[], str]]]:
|
||||
if not feature_flag_manager.is_feature_enabled("TAGGING_SYSTEM"):
|
||||
return
|
||||
|
||||
|
||||
@@ -67,8 +67,12 @@ class ExportThemesCommand(ExportModelsCommand):
|
||||
|
||||
@staticmethod
|
||||
def _export(
|
||||
model: Theme, export_related: bool = True
|
||||
model: Theme, export_related: bool = True, seen: set[str] | None = None
|
||||
) -> Iterator[tuple[str, Callable[[], str]]]:
|
||||
# Initialize seen set if not provided (for consistency)
|
||||
if seen is None:
|
||||
seen = set()
|
||||
|
||||
yield (
|
||||
ExportThemesCommand._file_name(model),
|
||||
lambda: ExportThemesCommand._file_content(model),
|
||||
|
||||
@@ -125,12 +125,14 @@ def get_virtual_table_metadata(dataset: SqlaTable) -> list[ResultSetColumnType]:
|
||||
# rest (sandbox violations, malformed template syntax, encoding
|
||||
# errors) indicate a real problem with the template that must
|
||||
# surface. See #38012.
|
||||
# str(ex) stringifies the raw SupersetError list (enum reprs and all).
|
||||
error_message = "; ".join(err.message for err in ex.errors)
|
||||
if isinstance(ex.__cause__, UndefinedError):
|
||||
raise SupersetVirtualTableParseException(
|
||||
message=_("Template processing error: %(error)s", error=str(ex)),
|
||||
message=_("Template processing error: %(error)s", error=error_message),
|
||||
) from ex
|
||||
raise SupersetGenericDBErrorException(
|
||||
message=_("Template processing error: %(error)s", error=str(ex)),
|
||||
message=_("Template processing error: %(error)s", error=error_message),
|
||||
) from ex
|
||||
try:
|
||||
parsed_script = SQLScript(sql, engine=db_engine_spec.engine)
|
||||
|
||||
@@ -87,6 +87,7 @@ from superset.datasets.schemas import (
|
||||
openapi_spec_methods_override,
|
||||
)
|
||||
from superset.exceptions import (
|
||||
OAuth2RedirectError,
|
||||
SupersetSyntaxErrorException,
|
||||
SupersetTemplateException,
|
||||
)
|
||||
@@ -440,7 +441,6 @@ class DatasetRestApi(SoftDeleteApiMixin, BaseSupersetModelRestApi):
|
||||
|
||||
@expose("/", methods=("POST",))
|
||||
@protect()
|
||||
@safe
|
||||
@statsd_metrics
|
||||
@event_logger.log_this_with_context(
|
||||
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.post",
|
||||
@@ -495,6 +495,12 @@ class DatasetRestApi(SoftDeleteApiMixin, BaseSupersetModelRestApi):
|
||||
data=new_model.data,
|
||||
uuid=new_model.uuid,
|
||||
)
|
||||
except OAuth2RedirectError:
|
||||
# Must reach the client unchanged to start the OAuth2 dance;
|
||||
# ``@safe`` isn't used on this endpoint since it would otherwise
|
||||
# swallow this into an opaque 500 that drops the ``url``/``tab_id``
|
||||
# extras the frontend needs.
|
||||
raise
|
||||
except DatasetSoftDeletedTwinExistsError as ex:
|
||||
return self.response_422(message=str(ex))
|
||||
except DatasetInvalidError as ex:
|
||||
|
||||
@@ -220,6 +220,8 @@ async def create_virtual_dataset( # noqa: C901
|
||||
error=f"Failed to update dataset metadata (creation rolled back): {exc}",
|
||||
)
|
||||
except SupersetGenericDBErrorException as exc:
|
||||
# Defensive backstop for direct raises (see
|
||||
# test_create_virtual_dataset_sql_error_is_actionable).
|
||||
logger.warning("Virtual dataset SQL validation failed", exc_info=True)
|
||||
await ctx.warning(f"Virtual dataset SQL failed validation: {exc}")
|
||||
return CreateVirtualDatasetResponse(
|
||||
|
||||
+11
-3
@@ -162,13 +162,21 @@ def memoized_func(key: str, cache: Cache = cache_manager.cache) -> Callable[...,
|
||||
def wrapped_f(*args: Any, **kwargs: Any) -> Any:
|
||||
should_cache = kwargs.pop("cache", True)
|
||||
force = kwargs.pop("force", False)
|
||||
cache_timeout = kwargs.pop(
|
||||
"cache_timeout", app.config["CACHE_DEFAULT_TIMEOUT"]
|
||||
)
|
||||
# always popped, even when caching is skipped, so it is never forwarded
|
||||
# to the decorated function as an unexpected keyword argument.
|
||||
cache_timeout = kwargs.pop("cache_timeout", None)
|
||||
|
||||
if not should_cache:
|
||||
return f(*args, **kwargs)
|
||||
|
||||
# callers may explicitly pass ``cache_timeout=None`` (eg, when a database
|
||||
# has no custom metadata cache timeout configured), which should fall back
|
||||
# to the default timeout rather than be forwarded to the cache backend.
|
||||
# the config lookup happens here so the uncached path stays independent
|
||||
# of the Flask app config.
|
||||
if cache_timeout is None:
|
||||
cache_timeout = app.config["CACHE_DEFAULT_TIMEOUT"]
|
||||
|
||||
# format the key using args/kwargs passed to the decorated function
|
||||
signature = inspect.signature(f)
|
||||
bound_args = signature.bind(*args, **kwargs)
|
||||
|
||||
@@ -34,6 +34,7 @@ import pytest
|
||||
from flask import g, Response
|
||||
from flask.ctx import AppContext
|
||||
|
||||
from superset import security_manager
|
||||
from superset.charts.data.api import ChartDataRestApi
|
||||
from superset.commands.chart.data.get_data_command import ChartDataCommand
|
||||
from superset.common.chart_data import ChartDataResultFormat, ChartDataResultType
|
||||
@@ -98,6 +99,25 @@ INCOMPATIBLE_ADHOC_COLUMN_FIXTURE: AdhocColumn = {
|
||||
}
|
||||
|
||||
|
||||
def _override_view_query_permission(granted: bool) -> Any:
|
||||
"""
|
||||
Answer ("can_view_query", "Dashboard") with ``granted`` and let every other
|
||||
permission check fall through to the real security manager, so the rest of
|
||||
the request keeps its normal access rules.
|
||||
"""
|
||||
real_can_access = security_manager.can_access
|
||||
|
||||
def can_access(permission_name: str, view_name: str) -> bool:
|
||||
if (permission_name, view_name) == ("can_view_query", "Dashboard"):
|
||||
return granted
|
||||
return real_can_access(permission_name, view_name)
|
||||
|
||||
return mock.patch(
|
||||
"superset.charts.data.api.security_manager.can_access",
|
||||
side_effect=can_access,
|
||||
)
|
||||
|
||||
|
||||
def _query_timing() -> QueryTiming:
|
||||
return QueryTiming(
|
||||
query_planning_ns=0,
|
||||
@@ -1572,19 +1592,40 @@ class TestGetChartDataApi(BaseTestChartDataApi):
|
||||
@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
|
||||
def test_chart_data_as_guest_user(self, is_guest_user, has_guest_access):
|
||||
"""
|
||||
Chart data API: Test response does not inlcude the SQL query for embedded
|
||||
users.
|
||||
Chart data API: Test response does not include the SQL query for embedded
|
||||
users whose role lacks "can view query on Dashboard".
|
||||
"""
|
||||
g.user.rls = []
|
||||
is_guest_user.return_value = True
|
||||
has_guest_access.return_value = True
|
||||
|
||||
rv = self.client.post(CHART_DATA_URI, json=self.query_context_payload)
|
||||
with _override_view_query_permission(granted=False):
|
||||
rv = self.client.post(CHART_DATA_URI, json=self.query_context_payload)
|
||||
data = json.loads(rv.data.decode("utf-8"))
|
||||
result = data["result"]
|
||||
excluded_key = "query"
|
||||
assert all([excluded_key not in query for query in result]) # noqa: C419
|
||||
|
||||
@mock.patch("superset.security.manager.SupersetSecurityManager.has_guest_access")
|
||||
@mock.patch("superset.security.manager.SupersetSecurityManager.is_guest_user")
|
||||
@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
|
||||
def test_chart_data_as_guest_user_allowed_to_view_query(
|
||||
self, is_guest_user, has_guest_access
|
||||
):
|
||||
"""
|
||||
Chart data API: Test response includes the SQL query for embedded users
|
||||
whose role carries "can view query on Dashboard".
|
||||
"""
|
||||
g.user.rls = []
|
||||
is_guest_user.return_value = True
|
||||
has_guest_access.return_value = True
|
||||
|
||||
with _override_view_query_permission(granted=True):
|
||||
rv = self.client.post(CHART_DATA_URI, json=self.query_context_payload)
|
||||
data = json.loads(rv.data.decode("utf-8"))
|
||||
result = data["result"]
|
||||
assert all("query" in query for query in result)
|
||||
|
||||
def test_chart_data_table_chart_with_time_grain_filter(self):
|
||||
"""
|
||||
Chart data API: Test that a table chart that's not using a temporal column can
|
||||
|
||||
@@ -533,6 +533,110 @@ class TestExportDashboardsCommand(SupersetTestCase):
|
||||
{"dashboard_title": "World Bank's Data"},
|
||||
)
|
||||
|
||||
@pytest.mark.usefixtures("load_world_bank_dashboard_with_slices")
|
||||
@patch("superset.security.manager.g")
|
||||
@patch("superset.views.base.g")
|
||||
def test_export_dashboard_cross_database_charts(self, mock_g1, mock_g2):
|
||||
"""
|
||||
Test that dashboards with charts from multiple databases export correctly.
|
||||
This reproduces issue #37113 where charts from different databases were missing.
|
||||
"""
|
||||
mock_g1.user = security_manager.find_user("admin")
|
||||
mock_g2.user = security_manager.find_user("admin")
|
||||
|
||||
# Create a second database for testing
|
||||
second_db = Database(database_name="test_db_2", sqlalchemy_uri="sqlite://")
|
||||
db.session.add(second_db)
|
||||
|
||||
# Create a dataset in the second database
|
||||
second_dataset = SqlaTable(
|
||||
table_name="second_dataset",
|
||||
database=second_db,
|
||||
database_id=second_db.id,
|
||||
columns=[],
|
||||
)
|
||||
db.session.add(second_dataset)
|
||||
# Flush so `second_dataset.id` is populated before it's read below;
|
||||
# otherwise the chart would be constructed with `datasource_id=None`
|
||||
# and never actually link back to this dataset.
|
||||
db.session.flush()
|
||||
|
||||
# Create a chart using the second database's dataset
|
||||
chart_from_second_db = Slice(
|
||||
slice_name="Chart from Second Database",
|
||||
datasource_type="table",
|
||||
datasource_id=second_dataset.id,
|
||||
datasource_name=second_dataset.table_name,
|
||||
viz_type="bar",
|
||||
params=json.dumps({"viz_type": "bar"}),
|
||||
)
|
||||
db.session.add(chart_from_second_db)
|
||||
|
||||
# Get the example dashboard and add the new chart
|
||||
example_dashboard = (
|
||||
db.session.query(Dashboard).filter_by(slug="world_health").one()
|
||||
)
|
||||
|
||||
# Store original charts count
|
||||
original_charts_count = len(example_dashboard.slices)
|
||||
|
||||
# Add the new chart from different database to the dashboard
|
||||
example_dashboard.slices.append(chart_from_second_db)
|
||||
db.session.commit()
|
||||
|
||||
try:
|
||||
# Export the dashboard
|
||||
command = ExportDashboardsCommand([example_dashboard.id])
|
||||
contents = dict(command.run())
|
||||
|
||||
# Verify all databases are exported
|
||||
db_files = [key for key in contents.keys() if key.startswith("databases/")]
|
||||
assert len(db_files) >= 2, (
|
||||
f"Expected at least 2 database files, got {db_files}"
|
||||
)
|
||||
|
||||
# Verify the second database is included
|
||||
assert "databases/test_db_2.yaml" in contents.keys(), (
|
||||
f"Second database not found in export. Keys: {list(contents.keys())}"
|
||||
)
|
||||
|
||||
# Verify all charts are exported (original + new one)
|
||||
chart_files = [key for key in contents.keys() if key.startswith("charts/")]
|
||||
assert len(chart_files) == original_charts_count + 1, (
|
||||
f"Expected {original_charts_count + 1} charts, got {len(chart_files)}"
|
||||
)
|
||||
|
||||
# Verify the new chart from second database is included
|
||||
chart_from_second_db_file = None
|
||||
for key in chart_files:
|
||||
if f"Chart_from_Second_Database_{chart_from_second_db.id}" in key:
|
||||
chart_from_second_db_file = key
|
||||
break
|
||||
|
||||
assert chart_from_second_db_file is not None, (
|
||||
f"Chart from second database not found in export. "
|
||||
f"Chart files: {chart_files}"
|
||||
)
|
||||
|
||||
# Verify the dataset from second database is included
|
||||
dataset_files = [
|
||||
key for key in contents.keys() if key.startswith("datasets/")
|
||||
]
|
||||
second_dataset_file = (
|
||||
f"datasets/test_db_2/second_dataset_{second_dataset.id}.yaml"
|
||||
)
|
||||
assert second_dataset_file in contents.keys(), (
|
||||
f"Second dataset not found. Dataset files: {dataset_files}"
|
||||
)
|
||||
finally:
|
||||
# Clean up, even if an assertion above failed, so a failing run
|
||||
# doesn't leave extra Database/Slice/SqlaTable rows for later tests.
|
||||
example_dashboard.slices.remove(chart_from_second_db)
|
||||
db.session.delete(chart_from_second_db)
|
||||
db.session.delete(second_dataset)
|
||||
db.session.delete(second_db)
|
||||
db.session.commit()
|
||||
|
||||
|
||||
class TestImportDashboardsCommand(SupersetTestCase):
|
||||
def test_import_v0_dashboard_cli_export(self):
|
||||
|
||||
@@ -146,6 +146,36 @@ def test_csv_upload_dataset():
|
||||
assert user_is_editor(security_manager.find_user("admin"), dataset)
|
||||
|
||||
|
||||
@only_postgresql
|
||||
@pytest.mark.usefixtures("setup_csv_upload_with_context_schema")
|
||||
def test_csv_upload_dataset_catalog():
|
||||
admin_user = security_manager.find_user(username="admin")
|
||||
upload_database = get_upload_db()
|
||||
|
||||
with override_user(admin_user):
|
||||
UploadCommand(
|
||||
upload_database.id,
|
||||
CSV_UPLOAD_TABLE_W_SCHEMA,
|
||||
create_csv_file(CSV_FILE_1),
|
||||
"public",
|
||||
CSVReader({}),
|
||||
).run()
|
||||
|
||||
dataset = (
|
||||
db.session.query(SqlaTable)
|
||||
.filter_by(
|
||||
database_id=upload_database.id,
|
||||
table_name=CSV_UPLOAD_TABLE_W_SCHEMA,
|
||||
)
|
||||
.one()
|
||||
)
|
||||
catalog = upload_database.get_default_catalog()
|
||||
assert dataset.catalog == catalog
|
||||
assert dataset.schema_perm == (
|
||||
f"[{upload_database.database_name}].[{catalog}].[public]"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("setup_csv_upload_with_context")
|
||||
def test_csv_upload_with_index():
|
||||
admin_user = security_manager.find_user(username="admin")
|
||||
|
||||
@@ -328,6 +328,10 @@ def test_send_chart_response_strips_guest_query_after_timing_projection(
|
||||
"superset.charts.data.api.security_manager.is_guest_user",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
"superset.charts.data.api.security_manager.can_access",
|
||||
return_value=False,
|
||||
),
|
||||
):
|
||||
response = api._send_chart_response(result)
|
||||
finally:
|
||||
@@ -339,6 +343,35 @@ def test_send_chart_response_strips_guest_query_after_timing_projection(
|
||||
assert "query" in query_payload
|
||||
|
||||
|
||||
def test_send_chart_response_keeps_guest_query_when_permitted(
|
||||
app: SupersetApp,
|
||||
) -> None:
|
||||
"""
|
||||
A guest whose role carries "can view query on Dashboard" must receive the
|
||||
generated SQL, otherwise "View query" is empty on embedded dashboards.
|
||||
"""
|
||||
query_payload = {"data": [{"col1": 1}], "query": "SELECT 1"}
|
||||
result = _json_execution_result(query_payload)
|
||||
|
||||
api = ChartDataRestApi()
|
||||
with (
|
||||
app.test_request_context("/api/v1/chart/data"),
|
||||
patch(
|
||||
"superset.charts.data.api.security_manager.is_guest_user",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
"superset.charts.data.api.security_manager.can_access",
|
||||
return_value=True,
|
||||
) as can_access,
|
||||
):
|
||||
response = api._send_chart_response(result)
|
||||
|
||||
query = json.loads(response.get_data(as_text=True))["result"][0]
|
||||
assert query["query"] == "SELECT 1"
|
||||
can_access.assert_called_once_with("can_view_query", "Dashboard")
|
||||
|
||||
|
||||
def test_send_chart_response_redacts_guest_query_error(app: SupersetApp) -> None:
|
||||
result = _json_execution_result(
|
||||
{
|
||||
@@ -356,6 +389,10 @@ def test_send_chart_response_redacts_guest_query_error(app: SupersetApp) -> None
|
||||
"superset.charts.data.api.security_manager.is_guest_user",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
"superset.charts.data.api.security_manager.can_access",
|
||||
return_value=False,
|
||||
),
|
||||
):
|
||||
response = api._send_chart_response(result)
|
||||
|
||||
@@ -364,6 +401,42 @@ def test_send_chart_response_redacts_guest_query_error(app: SupersetApp) -> None
|
||||
assert "stacktrace" not in query
|
||||
|
||||
|
||||
def test_send_chart_response_still_redacts_guest_errors_when_query_permitted(
|
||||
app: SupersetApp,
|
||||
) -> None:
|
||||
"""
|
||||
"can view query on Dashboard" only unlocks the generated SQL; stacktraces
|
||||
and driver errors describe the deployment and stay redacted for guests.
|
||||
"""
|
||||
result = _json_execution_result(
|
||||
{
|
||||
"error": "Table mydb.myschema.mytable was not found",
|
||||
"stacktrace": "Traceback ...",
|
||||
"query": "SELECT 1",
|
||||
},
|
||||
result_type=ChartDataResultType.QUERY,
|
||||
)
|
||||
|
||||
api = ChartDataRestApi()
|
||||
with (
|
||||
app.test_request_context("/api/v1/chart/data"),
|
||||
patch(
|
||||
"superset.charts.data.api.security_manager.is_guest_user",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
"superset.charts.data.api.security_manager.can_access",
|
||||
return_value=True,
|
||||
),
|
||||
):
|
||||
response = api._send_chart_response(result)
|
||||
|
||||
query = json.loads(response.get_data(as_text=True))["result"][0]
|
||||
assert query["query"] == "SELECT 1"
|
||||
assert query["error"] == str(GENERIC_ERROR_MESSAGE)
|
||||
assert "stacktrace" not in query
|
||||
|
||||
|
||||
def test_get_data_response_redacts_guest_query_failure(app: SupersetApp) -> None:
|
||||
command = MagicMock()
|
||||
command.execute.side_effect = ChartDataQueryFailedError(
|
||||
|
||||
@@ -18,11 +18,16 @@ from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
from marshmallow import ValidationError
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from superset.commands.dataset.create import CreateDatasetCommand
|
||||
from superset.commands.dataset.exceptions import DatasetInvalidError
|
||||
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
|
||||
from superset.exceptions import SupersetParseError
|
||||
from superset.exceptions import (
|
||||
OAuth2RedirectError,
|
||||
SupersetGenericDBErrorException,
|
||||
SupersetParseError,
|
||||
)
|
||||
from superset.models.core import Database
|
||||
|
||||
|
||||
@@ -250,3 +255,112 @@ def test_create_dataset_generic_exists_error_when_no_twin() -> None:
|
||||
)
|
||||
with pytest.raises(DatasetInvalidError):
|
||||
command.validate()
|
||||
|
||||
|
||||
def test_create_dataset_metadata_fetch_error_is_structured(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""A metadata-fetch failure must surface the engine's own message.
|
||||
|
||||
``run()`` executes the SQL to introspect columns; the resulting
|
||||
``SupersetGenericDBErrorException`` used to escape as a 500 "Fatal error".
|
||||
"""
|
||||
mocker.patch.object(CreateDatasetCommand, "validate")
|
||||
dataset = Mock()
|
||||
dataset.fetch_metadata.side_effect = SupersetGenericDBErrorException(
|
||||
message="Invalid SQL: Unable to parse: SELECT ...",
|
||||
)
|
||||
mocker.patch(
|
||||
"superset.commands.dataset.create.DatasetDAO.create",
|
||||
return_value=dataset,
|
||||
)
|
||||
|
||||
command = CreateDatasetCommand(
|
||||
{
|
||||
"database": 1,
|
||||
"table_name": "dataset wrong",
|
||||
"sql": "SELECT ...",
|
||||
}
|
||||
)
|
||||
|
||||
with pytest.raises(DatasetInvalidError) as exc_info:
|
||||
command.run()
|
||||
|
||||
validation_errors = exc_info.value._exceptions
|
||||
assert len(validation_errors) == 1
|
||||
assert validation_errors[0].field_name == "sql"
|
||||
assert "Invalid SQL: Unable to parse: SELECT ..." in str(
|
||||
validation_errors[0].messages[0]
|
||||
)
|
||||
|
||||
|
||||
def test_create_dataset_metadata_fetch_error_physical_table(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""The same conversion applies to physical datasets, keyed on ``table``."""
|
||||
mocker.patch.object(CreateDatasetCommand, "validate")
|
||||
dataset = Mock()
|
||||
dataset.fetch_metadata.side_effect = SupersetGenericDBErrorException(
|
||||
message="(psycopg2.OperationalError) could not connect to server",
|
||||
)
|
||||
mocker.patch(
|
||||
"superset.commands.dataset.create.DatasetDAO.create",
|
||||
return_value=dataset,
|
||||
)
|
||||
|
||||
command = CreateDatasetCommand({"database": 1, "table_name": "physical_table"})
|
||||
|
||||
with pytest.raises(DatasetInvalidError) as exc_info:
|
||||
command.run()
|
||||
|
||||
validation_errors = exc_info.value._exceptions
|
||||
assert validation_errors[0].field_name == "table"
|
||||
assert "could not connect to server" in str(validation_errors[0].messages[0])
|
||||
|
||||
|
||||
def test_create_dataset_oauth2_redirect_propagates_unchanged(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""OAuth2 redirects must not be flattened into a DatasetInvalidError."""
|
||||
mocker.patch.object(CreateDatasetCommand, "validate")
|
||||
dataset = Mock()
|
||||
oauth2_error = OAuth2RedirectError(
|
||||
url="https://example.org/oauth2/authorize",
|
||||
tab_id="tab-123",
|
||||
redirect_uri="https://superset.example.org/oauth2/redirect",
|
||||
)
|
||||
dataset.fetch_metadata.side_effect = oauth2_error
|
||||
mocker.patch(
|
||||
"superset.commands.dataset.create.DatasetDAO.create",
|
||||
return_value=dataset,
|
||||
)
|
||||
|
||||
command = CreateDatasetCommand(
|
||||
{"database": 1, "table_name": "good_dataset", "sql": "SELECT 1 AS a"}
|
||||
)
|
||||
|
||||
with pytest.raises(OAuth2RedirectError) as exc_info:
|
||||
command.run()
|
||||
|
||||
assert exc_info.value is oauth2_error
|
||||
assert exc_info.value.error.extra["url"] == "https://example.org/oauth2/authorize"
|
||||
assert exc_info.value.error.extra["tab_id"] == "tab-123"
|
||||
|
||||
|
||||
def test_create_dataset_run_succeeds_when_metadata_fetch_works(
|
||||
mocker: MockerFixture,
|
||||
) -> None:
|
||||
"""Control: the happy path still returns the created dataset."""
|
||||
mocker.patch.object(CreateDatasetCommand, "validate")
|
||||
dataset = Mock()
|
||||
mocker.patch(
|
||||
"superset.commands.dataset.create.DatasetDAO.create",
|
||||
return_value=dataset,
|
||||
)
|
||||
|
||||
command = CreateDatasetCommand(
|
||||
{"database": 1, "table_name": "good_dataset", "sql": "SELECT 1 AS a"}
|
||||
)
|
||||
|
||||
assert command.run() is dataset
|
||||
dataset.fetch_metadata.assert_called_once()
|
||||
|
||||
@@ -191,6 +191,39 @@ def test_get_virtual_table_metadata_template_security_error_is_not_softened():
|
||||
assert "Template processing error" in str(exc_info.value.message)
|
||||
|
||||
|
||||
def test_get_virtual_table_metadata_template_error_message_is_clean():
|
||||
"""The message must be the SupersetError's own text, not str(ex)."""
|
||||
mock_dataset = Mock(spec=SqlaTable)
|
||||
mock_database = Mock(spec=Database)
|
||||
mock_dataset.database = mock_database
|
||||
mock_dataset.sql = "SELECT 1 {% if %}"
|
||||
|
||||
ex = SupersetSyntaxErrorException(
|
||||
[
|
||||
SupersetError(
|
||||
message="Malformed template, expected 'endif'",
|
||||
error_type=SupersetErrorType.GENERIC_COMMAND_ERROR,
|
||||
level=ErrorLevel.ERROR,
|
||||
)
|
||||
]
|
||||
)
|
||||
ex.__cause__ = SecurityError("unrelated cause, not UndefinedError")
|
||||
mock_template_processor = Mock()
|
||||
mock_template_processor.process_template.side_effect = ex
|
||||
mock_dataset.get_template_processor.return_value = mock_template_processor
|
||||
mock_dataset.template_params_dict = {}
|
||||
|
||||
with pytest.raises(SupersetGenericDBErrorException) as exc_info:
|
||||
get_virtual_table_metadata(mock_dataset)
|
||||
|
||||
message = str(exc_info.value.message)
|
||||
assert message == (
|
||||
"Template processing error: Malformed template, expected 'endif'"
|
||||
)
|
||||
assert "SupersetError(" not in message
|
||||
assert "error_type=<" not in message
|
||||
|
||||
|
||||
def test_get_virtual_table_metadata_multiple_statements_not_allowed():
|
||||
"""Test that multiple SQL statements raise security error."""
|
||||
mock_dataset = Mock(spec=SqlaTable)
|
||||
|
||||
@@ -214,3 +214,93 @@ def test_handle_filters_args_returns_request_scoped_filters(
|
||||
fresh_filters = api.datamodel.get_filters.return_value
|
||||
assert fresh_filters.rest_add_filters.call_count == 2
|
||||
assert fresh_filters.get_joined_filters.call_count == 2
|
||||
|
||||
|
||||
def test_post_dataset_with_invalid_sql_returns_actionable_422(
|
||||
session: Session,
|
||||
client: Any,
|
||||
full_api_access: None,
|
||||
) -> None:
|
||||
"""Saving a dataset over unrunnable SQL must explain what is wrong.
|
||||
|
||||
With blanket database access ``validate()`` never parses the SQL, so
|
||||
``run()``'s column introspection is the first thing to reject it. That
|
||||
used to surface as a bare 500 ``{"message": "Fatal error"}``.
|
||||
"""
|
||||
from superset.connectors.sqla.models import SqlaTable
|
||||
from superset.models.core import Database
|
||||
|
||||
SqlaTable.metadata.create_all(db.session.get_bind())
|
||||
|
||||
database = Database(database_name="invalid_sql_db", sqlalchemy_uri="sqlite://")
|
||||
db.session.add(database)
|
||||
db.session.flush()
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/dataset/",
|
||||
json={
|
||||
"database": database.id,
|
||||
"schema": "main",
|
||||
"table_name": "dataset wrong",
|
||||
"sql": "SELECT ...",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
message = response.json["message"]
|
||||
assert "Fatal error" not in str(message)
|
||||
# Not the parser's exact wording -- that would break on a sqlglot bump.
|
||||
assert message["sql"][0].startswith("Invalid SQL")
|
||||
|
||||
# The failed create must not leave a half-built dataset behind.
|
||||
assert (
|
||||
db.session.query(SqlaTable).filter_by(table_name="dataset wrong").one_or_none()
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def test_post_dataset_oauth2_redirect_propagates_unchanged(
|
||||
session: Session,
|
||||
client: Any,
|
||||
full_api_access: None,
|
||||
) -> None:
|
||||
"""OAuth2RedirectError must reach the client with its ``url``/``tab_id``
|
||||
extras intact so the frontend can start the OAuth2 dance.
|
||||
|
||||
``DatasetRestApi.post`` doesn't use flask-appbuilder's ``@safe``
|
||||
decorator for this reason: ``@safe`` catches any uncaught exception and
|
||||
flattens it into an opaque 500, which would strip those extras.
|
||||
"""
|
||||
from superset.connectors.sqla.models import SqlaTable
|
||||
from superset.exceptions import OAuth2RedirectError
|
||||
from superset.models.core import Database
|
||||
|
||||
SqlaTable.metadata.create_all(db.session.get_bind())
|
||||
|
||||
database = Database(database_name="oauth2_db", sqlalchemy_uri="sqlite://")
|
||||
db.session.add(database)
|
||||
db.session.flush()
|
||||
|
||||
with patch(
|
||||
"superset.datasets.api.CreateDatasetCommand.run",
|
||||
side_effect=OAuth2RedirectError(
|
||||
"http://example.org/auth", "tab-1", "/redirect"
|
||||
),
|
||||
):
|
||||
response = client.post(
|
||||
"/api/v1/dataset/",
|
||||
json={
|
||||
"database": database.id,
|
||||
"schema": "main",
|
||||
"table_name": "oauth2_table",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
error = response.json["errors"][0]
|
||||
assert error["error_type"] == "OAUTH2_REDIRECT"
|
||||
assert error["extra"] == {
|
||||
"url": "http://example.org/auth",
|
||||
"tab_id": "tab-1",
|
||||
"redirect_uri": "/redirect",
|
||||
}
|
||||
|
||||
@@ -54,6 +54,99 @@ def test_memoized_func(mocker: MockerFixture) -> None:
|
||||
assert result == 43
|
||||
|
||||
|
||||
def test_memoized_func_none_cache_timeout(mocker: MockerFixture) -> None:
|
||||
"""
|
||||
An explicit ``cache_timeout=None`` falls back to ``CACHE_DEFAULT_TIMEOUT``.
|
||||
|
||||
Databases without a custom metadata cache timeout pass ``None`` explicitly, and
|
||||
forwarding it to the cache backend breaks backends that require an integer.
|
||||
"""
|
||||
from superset.utils.cache import memoized_func
|
||||
|
||||
_patch_config(mocker)
|
||||
cache = mocker.MagicMock()
|
||||
cache.get.return_value = None
|
||||
|
||||
decorator = memoized_func("db:{self.id}:schema:{schema}:table_list", cache)
|
||||
decorated = decorator(lambda self, schema: 42)
|
||||
|
||||
self = mocker.MagicMock()
|
||||
self.id = 1
|
||||
|
||||
result = decorated(self, "public", cache_timeout=None)
|
||||
assert result == 42
|
||||
cache.set.assert_called_once_with("db:1:schema:public:table_list", 42, timeout=100)
|
||||
|
||||
|
||||
def test_memoized_func_custom_cache_timeout(mocker: MockerFixture) -> None:
|
||||
"""
|
||||
An explicit ``cache_timeout`` takes precedence over ``CACHE_DEFAULT_TIMEOUT``.
|
||||
"""
|
||||
from superset.utils.cache import memoized_func
|
||||
|
||||
_patch_config(mocker)
|
||||
cache = mocker.MagicMock()
|
||||
cache.get.return_value = None
|
||||
|
||||
decorator = memoized_func("db:{self.id}:schema:{schema}:table_list", cache)
|
||||
decorated = decorator(lambda self, schema: 42)
|
||||
|
||||
self = mocker.MagicMock()
|
||||
self.id = 1
|
||||
|
||||
result = decorated(self, "public", cache_timeout=42)
|
||||
assert result == 42
|
||||
cache.set.assert_called_once_with("db:1:schema:public:table_list", 42, timeout=42)
|
||||
|
||||
|
||||
def test_memoized_func_disabled_cache_timeout(mocker: MockerFixture) -> None:
|
||||
"""
|
||||
A timeout of -1 (``CACHE_DISABLED_TIMEOUT``) skips the cache set.
|
||||
"""
|
||||
from superset.utils.cache import memoized_func
|
||||
|
||||
_patch_config(mocker)
|
||||
cache = mocker.MagicMock()
|
||||
cache.get.return_value = None
|
||||
|
||||
decorator = memoized_func("db:{self.id}:schema:{schema}:table_list", cache)
|
||||
decorated = decorator(lambda self, schema: 42)
|
||||
|
||||
self = mocker.MagicMock()
|
||||
self.id = 1
|
||||
|
||||
result = decorated(self, "public", cache_timeout=-1)
|
||||
assert result == 42
|
||||
cache.set.assert_not_called()
|
||||
|
||||
|
||||
def test_memoized_func_skip_cache_pops_cache_timeout(mocker: MockerFixture) -> None:
|
||||
"""
|
||||
``cache=False`` skips caching without touching the config or the wrapped function.
|
||||
|
||||
``cache_timeout`` must still be popped so it is not forwarded to the decorated
|
||||
function, which does not accept it. Callers such as
|
||||
``get_all_table_names_in_schema`` pass ``cache`` and ``cache_timeout`` together.
|
||||
"""
|
||||
from superset.utils.cache import memoized_func
|
||||
|
||||
mock_config = mocker.patch("superset.utils.cache.app.config", MagicMock())
|
||||
cache = mocker.MagicMock()
|
||||
|
||||
decorator = memoized_func("db:{self.id}:schema:{schema}:table_list", cache)
|
||||
decorated = decorator(lambda self, schema: 42)
|
||||
|
||||
self = mocker.MagicMock()
|
||||
self.id = 1
|
||||
|
||||
result = decorated(self, "public", cache=False, cache_timeout=None)
|
||||
|
||||
assert result == 42
|
||||
cache.get.assert_not_called()
|
||||
cache.set.assert_not_called()
|
||||
mock_config.__getitem__.assert_not_called()
|
||||
|
||||
|
||||
def _make_cache_instance(mocker: MockerFixture) -> MagicMock:
|
||||
"""A cache instance whose ``.cache`` is not a ``NullCache``."""
|
||||
cache_instance = mocker.MagicMock()
|
||||
|
||||
Reference in New Issue
Block a user