diff --git a/superset-frontend/src/SqlLab/components/SaveDatasetModal/SaveDatasetModal.test.tsx b/superset-frontend/src/SqlLab/components/SaveDatasetModal/SaveDatasetModal.test.tsx index e3dc951948a..29531a652e4 100644 --- a/superset-frontend/src/SqlLab/components/SaveDatasetModal/SaveDatasetModal.test.tsx +++ b/superset-frontend/src/SqlLab/components/SaveDatasetModal/SaveDatasetModal.test.tsx @@ -61,6 +61,10 @@ jest.mock('src/SqlLab/actions/sqlLab', () => ({ jest.mock('src/explore/exploreUtils/formData', () => ({ postFormData: jest.fn(), })); +jest.mock('src/utils/cachedSupersetGet', () => ({ + ...jest.requireActual('src/utils/cachedSupersetGet'), + clearDatasetCache: jest.fn(), +})); // eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks describe('SaveDatasetModal', () => { @@ -336,4 +340,42 @@ describe('SaveDatasetModal', () => { templateParams: undefined, }); }); + + test('clears dataset cache when creating new dataset', async () => { + const clearDatasetCache = jest.spyOn( + require('src/utils/cachedSupersetGet'), + 'clearDatasetCache', + ); + const postFormData = jest.spyOn( + require('src/explore/exploreUtils/formData'), + 'postFormData', + ); + + const dummyDispatch = jest.fn().mockResolvedValue({ id: 123 }); + useDispatchMock.mockReturnValue(dummyDispatch); + useSelectorMock.mockReturnValue({ ...user }); + postFormData.mockResolvedValue('chart_key_123'); + + render(, { useRedux: true }); + + const inputFieldText = screen.getByDisplayValue(/unimportant/i); + fireEvent.change(inputFieldText, { target: { value: 'my dataset' } }); + + const saveConfirmationBtn = screen.getByRole('button', { + name: /save/i, + }); + userEvent.click(saveConfirmationBtn); + + await waitFor(() => { + expect(clearDatasetCache).toHaveBeenCalledWith(123); + }); + }); + + test('clearDatasetCache is imported and available', () => { + const clearDatasetCache = + require('src/utils/cachedSupersetGet').clearDatasetCache; + + expect(clearDatasetCache).toBeDefined(); + expect(typeof clearDatasetCache).toBe('function'); + }); }); diff --git a/superset-frontend/src/SqlLab/components/SaveDatasetModal/index.tsx b/superset-frontend/src/SqlLab/components/SaveDatasetModal/index.tsx index 721b9081873..17c5de8085e 100644 --- a/superset-frontend/src/SqlLab/components/SaveDatasetModal/index.tsx +++ b/superset-frontend/src/SqlLab/components/SaveDatasetModal/index.tsx @@ -59,6 +59,7 @@ import { mountExploreUrl } from 'src/explore/exploreUtils'; import { postFormData } from 'src/explore/exploreUtils/formData'; import { URL_PARAMS } from 'src/constants'; import { isEmpty } from 'lodash'; +import { clearDatasetCache } from 'src/utils/cachedSupersetGet'; interface QueryDatabase { id?: number; @@ -170,6 +171,9 @@ const updateDataset = async ( headers, body, }); + + clearDatasetCache(datasetId); + return data.json.result; }; @@ -347,15 +351,17 @@ export const SaveDatasetModal = ({ datasourceName: datasetName, }), ) - .then((data: { id: number }) => - postFormData(data.id, 'table', { + .then((data: { id: number }) => { + clearDatasetCache(data.id); + + return postFormData(data.id, 'table', { ...formDataWithDefaults, datasource: `${data.id}__table`, ...(defaultVizType === VizType.Table && { all_columns: selectedColumns.map(column => column.column_name), }), - }), - ) + }); + }) .then((key: string) => { setLoading(false); const url = mountExploreUrl(null, { diff --git a/superset-frontend/src/components/Datasource/DatasourceModal/index.tsx b/superset-frontend/src/components/Datasource/DatasourceModal/index.tsx index 32fcf6f977d..96e271f9e59 100644 --- a/superset-frontend/src/components/Datasource/DatasourceModal/index.tsx +++ b/superset-frontend/src/components/Datasource/DatasourceModal/index.tsx @@ -193,6 +193,7 @@ const DatasourceModal: FunctionComponent = ({ const { json } = await SupersetClient.get({ endpoint: `/api/v1/dataset/${currentDatasource?.id}`, }); + addSuccessToast(t('The dataset has been saved')); // eslint-disable-next-line no-param-reassign json.result.type = 'table'; diff --git a/superset-frontend/src/components/Datasource/components/DatasourceEditor/DatasourceEditor.jsx b/superset-frontend/src/components/Datasource/components/DatasourceEditor/DatasourceEditor.jsx index d5a9b26dcce..30d5a824281 100644 --- a/superset-frontend/src/components/Datasource/components/DatasourceEditor/DatasourceEditor.jsx +++ b/superset-frontend/src/components/Datasource/components/DatasourceEditor/DatasourceEditor.jsx @@ -71,6 +71,7 @@ import { resetDatabaseState, } from 'src/database/actions'; import Mousetrap from 'mousetrap'; +import { clearDatasetCache } from 'src/utils/cachedSupersetGet'; import { DatabaseSelector } from '../../../DatabaseSelector'; import CollectionTable from '../CollectionTable'; import Fieldset from '../Fieldset'; @@ -840,6 +841,9 @@ class DatasourceEditor extends PureComponent { col => !col.expression, // remove calculated columns ), }); + + clearDatasetCache(datasource.id); + this.props.addSuccessToast(t('Metadata has been synced')); this.setState({ metadataLoading: false }); } catch (error) { diff --git a/superset-frontend/src/utils/cachedSupersetGet.test.ts b/superset-frontend/src/utils/cachedSupersetGet.test.ts new file mode 100644 index 00000000000..1ec63437c8b --- /dev/null +++ b/superset-frontend/src/utils/cachedSupersetGet.test.ts @@ -0,0 +1,190 @@ +/** + * 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 { + supersetGetCache, + clearDatasetCache, + clearAllDatasetCache, +} from './cachedSupersetGet'; + +describe('cachedSupersetGet', () => { + beforeEach(() => { + supersetGetCache.clear(); + }); + + describe('clearDatasetCache', () => { + test('clears cache entries for specific dataset ID', () => { + supersetGetCache.set('/api/v1/dataset/123', { data: 'dataset123' }); + supersetGetCache.set('/api/v1/dataset/123/', { data: 'dataset123slash' }); + supersetGetCache.set('/api/v1/dataset/123?query=1', { + data: 'dataset123query', + }); + supersetGetCache.set('/api/v1/dataset/456', { data: 'dataset456' }); + supersetGetCache.set('/api/v1/other/123', { data: 'other' }); + + clearDatasetCache(123); + + expect(supersetGetCache.has('/api/v1/dataset/123')).toBe(false); + expect(supersetGetCache.has('/api/v1/dataset/123/')).toBe(false); + expect(supersetGetCache.has('/api/v1/dataset/123?query=1')).toBe(false); + expect(supersetGetCache.has('/api/v1/dataset/456')).toBe(true); + expect(supersetGetCache.has('/api/v1/other/123')).toBe(true); + }); + + test('clears cache entries for string dataset ID', () => { + supersetGetCache.set('/api/v1/dataset/abc-123', { data: 'datasetAbc' }); + supersetGetCache.set('/api/v1/dataset/abc-123/', { + data: 'datasetAbcSlash', + }); + supersetGetCache.set('/api/v1/dataset/def-456', { data: 'datasetDef' }); + + clearDatasetCache('abc-123'); + + expect(supersetGetCache.has('/api/v1/dataset/abc-123')).toBe(false); + expect(supersetGetCache.has('/api/v1/dataset/abc-123/')).toBe(false); + expect(supersetGetCache.has('/api/v1/dataset/def-456')).toBe(true); + }); + + test('handles null dataset ID gracefully', () => { + supersetGetCache.set('/api/v1/dataset/123', { data: 'dataset123' }); + + clearDatasetCache(null as any); + + expect(supersetGetCache.has('/api/v1/dataset/123')).toBe(true); + }); + + test('handles undefined dataset ID gracefully', () => { + supersetGetCache.set('/api/v1/dataset/123', { data: 'dataset123' }); + + clearDatasetCache(undefined as any); + + expect(supersetGetCache.has('/api/v1/dataset/123')).toBe(true); + }); + + test('handles empty string dataset ID gracefully', () => { + supersetGetCache.set('/api/v1/dataset/123', { data: 'dataset123' }); + + clearDatasetCache(''); + + expect(supersetGetCache.has('/api/v1/dataset/123')).toBe(true); + }); + + test('does not clear unrelated cache entries', () => { + supersetGetCache.set('/api/v1/chart/123', { data: 'chart123' }); + supersetGetCache.set('/api/v1/dashboard/123', { data: 'dashboard123' }); + supersetGetCache.set('/api/v1/database/123', { data: 'database123' }); + supersetGetCache.set('/api/v1/dataset/123', { data: 'dataset123' }); + + clearDatasetCache(123); + + expect(supersetGetCache.has('/api/v1/chart/123')).toBe(true); + expect(supersetGetCache.has('/api/v1/dashboard/123')).toBe(true); + expect(supersetGetCache.has('/api/v1/database/123')).toBe(true); + expect(supersetGetCache.has('/api/v1/dataset/123')).toBe(false); + }); + + test('only clears exact dataset ID matches', () => { + supersetGetCache.set('/api/v1/dataset/1', { data: 'dataset1' }); + supersetGetCache.set('/api/v1/dataset/12', { data: 'dataset12' }); + supersetGetCache.set('/api/v1/dataset/123', { data: 'dataset123' }); + supersetGetCache.set('/api/v1/dataset/1234', { data: 'dataset1234' }); + supersetGetCache.set('/api/v1/dataset/456', { data: 'dataset456' }); + + clearDatasetCache(123); + + expect(supersetGetCache.has('/api/v1/dataset/1')).toBe(true); + expect(supersetGetCache.has('/api/v1/dataset/12')).toBe(true); + expect(supersetGetCache.has('/api/v1/dataset/123')).toBe(false); + expect(supersetGetCache.has('/api/v1/dataset/1234')).toBe(true); // Should not be cleared - different ID + expect(supersetGetCache.has('/api/v1/dataset/456')).toBe(true); + }); + + test('clears cache entries with various URL patterns', () => { + supersetGetCache.set('/api/v1/dataset/789', { data: 'base' }); + supersetGetCache.set('/api/v1/dataset/789/columns', { data: 'columns' }); + supersetGetCache.set('/api/v1/dataset/789/related', { data: 'related' }); + supersetGetCache.set('/api/v1/dataset/789?full=true', { + data: 'withQuery', + }); + + clearDatasetCache(789); + + expect(supersetGetCache.has('/api/v1/dataset/789')).toBe(false); + expect(supersetGetCache.has('/api/v1/dataset/789/columns')).toBe(false); + expect(supersetGetCache.has('/api/v1/dataset/789/related')).toBe(false); + expect(supersetGetCache.has('/api/v1/dataset/789?full=true')).toBe(false); + }); + }); + + describe('clearAllDatasetCache', () => { + test('clears all dataset cache entries', () => { + supersetGetCache.set('/api/v1/dataset/123', { data: 'dataset123' }); + supersetGetCache.set('/api/v1/dataset/456', { data: 'dataset456' }); + supersetGetCache.set('/api/v1/dataset/789/columns', { data: 'columns' }); + supersetGetCache.set('/api/v1/chart/123', { data: 'chart123' }); + supersetGetCache.set('/api/v1/dashboard/456', { data: 'dashboard456' }); + + clearAllDatasetCache(); + + expect(supersetGetCache.has('/api/v1/dataset/123')).toBe(false); + expect(supersetGetCache.has('/api/v1/dataset/456')).toBe(false); + expect(supersetGetCache.has('/api/v1/dataset/789/columns')).toBe(false); + expect(supersetGetCache.has('/api/v1/chart/123')).toBe(true); + expect(supersetGetCache.has('/api/v1/dashboard/456')).toBe(true); + }); + + test('handles empty cache gracefully', () => { + expect(supersetGetCache.size).toBe(0); + + clearAllDatasetCache(); + + expect(supersetGetCache.size).toBe(0); + }); + + test('preserves non-dataset cache entries', () => { + supersetGetCache.set('/api/v1/chart/list', { data: 'chartList' }); + supersetGetCache.set('/api/v1/dashboard/list', { data: 'dashboardList' }); + supersetGetCache.set('/api/v1/database/list', { data: 'databaseList' }); + supersetGetCache.set('/api/v1/query/list', { data: 'queryList' }); + + clearAllDatasetCache(); + + expect(supersetGetCache.has('/api/v1/chart/list')).toBe(true); + expect(supersetGetCache.has('/api/v1/dashboard/list')).toBe(true); + expect(supersetGetCache.has('/api/v1/database/list')).toBe(true); + expect(supersetGetCache.has('/api/v1/query/list')).toBe(true); + }); + + test('clears all variations of dataset endpoints', () => { + supersetGetCache.set('/api/v1/dataset/', { data: 'list' }); + supersetGetCache.set('/api/v1/dataset/export', { data: 'export' }); + supersetGetCache.set('/api/v1/dataset/import', { data: 'import' }); + supersetGetCache.set('/api/v1/dataset/duplicate', { data: 'duplicate' }); + supersetGetCache.set('/api/v1/dataset/1/refresh', { data: 'refresh' }); + + clearAllDatasetCache(); + + expect(supersetGetCache.has('/api/v1/dataset/')).toBe(false); + expect(supersetGetCache.has('/api/v1/dataset/export')).toBe(false); + expect(supersetGetCache.has('/api/v1/dataset/import')).toBe(false); + expect(supersetGetCache.has('/api/v1/dataset/duplicate')).toBe(false); + expect(supersetGetCache.has('/api/v1/dataset/1/refresh')).toBe(false); + }); + }); +}); diff --git a/superset-frontend/src/utils/cachedSupersetGet.ts b/superset-frontend/src/utils/cachedSupersetGet.ts index 2f319cac350..c4ab529c0f4 100644 --- a/superset-frontend/src/utils/cachedSupersetGet.ts +++ b/superset-frontend/src/utils/cachedSupersetGet.ts @@ -27,3 +27,59 @@ export const cachedSupersetGet = cacheWrapper( supersetGetCache, ({ endpoint }) => endpoint || '', ); + +/** + * Clear cached responses for dataset-related endpoints + * @param datasetId - The ID of the dataset to clear from cache + */ +export function clearDatasetCache(datasetId: number | string): void { + if (datasetId === null || datasetId === undefined || datasetId === '') return; + + const datasetIdStr = String(datasetId); + + supersetGetCache.forEach((_value, key) => { + // Match exact dataset ID patterns: + // - /api/v1/dataset/123 (exact match or end of URL) + // - /api/v1/dataset/123/ (with trailing slash) + // - /api/v1/dataset/123? (with query params) + const patterns = [ + `/api/v1/dataset/${datasetIdStr}`, + `/api/v1/dataset/${datasetIdStr}/`, + `/api/v1/dataset/${datasetIdStr}?`, + ]; + + for (const pattern of patterns) { + if (key.includes(pattern)) { + // Additional check to ensure we don't match longer IDs + const afterPattern = key.substring( + key.indexOf(pattern) + pattern.length, + ); + // If pattern ends with slash or query, it's already precise + if (pattern.endsWith('/') || pattern.endsWith('?')) { + supersetGetCache.delete(key); + break; + } + // For the base pattern, ensure nothing follows or only valid separators + if ( + afterPattern === '' || + afterPattern.startsWith('/') || + afterPattern.startsWith('?') + ) { + supersetGetCache.delete(key); + break; + } + } + } + }); +} + +/** + * Clear all cached dataset responses + */ +export function clearAllDatasetCache(): void { + supersetGetCache.forEach((_value, key) => { + if (key.includes('/api/v1/dataset/')) { + supersetGetCache.delete(key); + } + }); +}