From d3d59ee0ae5aad485d24cb32c4c1754305fc5e0e Mon Sep 17 00:00:00 2001 From: "JUST.in DO IT" Date: Mon, 13 Feb 2023 08:36:47 -0800 Subject: [PATCH] fix(sqllab): Invalid schema fetch by deprecated value (#22968) --- .../SqlEditorLeftBar.test.jsx | 78 ++++- .../DatabaseSelector.test.tsx | 270 +++++++++--------- .../src/components/DatabaseSelector/index.tsx | 81 +++--- .../TableSelector/TableSelector.test.tsx | 87 +++--- .../src/components/TableSelector/index.tsx | 36 +-- .../src/hooks/apiResources/index.ts | 1 + .../src/hooks/apiResources/schemas.test.ts | 138 +++++++++ .../src/hooks/apiResources/schemas.ts | 80 ++++++ .../src/hooks/apiResources/tables.test.ts | 186 +++++++----- .../src/hooks/apiResources/tables.ts | 17 +- 10 files changed, 650 insertions(+), 324 deletions(-) create mode 100644 superset-frontend/src/hooks/apiResources/schemas.test.ts create mode 100644 superset-frontend/src/hooks/apiResources/schemas.ts diff --git a/superset-frontend/src/SqlLab/components/SqlEditorLeftBar/SqlEditorLeftBar.test.jsx b/superset-frontend/src/SqlLab/components/SqlEditorLeftBar/SqlEditorLeftBar.test.jsx index a4774cd390af..2c816d0e8447 100644 --- a/superset-frontend/src/SqlLab/components/SqlEditorLeftBar/SqlEditorLeftBar.test.jsx +++ b/superset-frontend/src/SqlLab/components/SqlEditorLeftBar/SqlEditorLeftBar.test.jsx @@ -41,15 +41,25 @@ const middlewares = [thunk]; const mockStore = configureStore(middlewares); const store = mockStore(initialState); -fetchMock.get('glob:*/api/v1/database/*/schemas/?*', { result: [] }); -fetchMock.get('glob:*/api/v1/database/*/tables/*', { - count: 1, - result: [ - { - label: 'ab_user', - value: 'ab_user', - }, - ], +beforeEach(() => { + fetchMock.get('glob:*/api/v1/database/?*', { result: [] }); + fetchMock.get('glob:*/api/v1/database/*/schemas/?*', { + count: 2, + result: ['main', 'new_schema'], + }); + fetchMock.get('glob:*/api/v1/database/*/tables/*', { + count: 1, + result: [ + { + label: 'ab_user', + value: 'ab_user', + }, + ], + }); +}); + +afterEach(() => { + fetchMock.restore(); }); const renderAndWait = (props, store) => @@ -110,8 +120,9 @@ test('should toggle the table when the header is clicked', async () => { userEvent.click(header); await waitFor(() => { - expect(store.getActions()).toHaveLength(4); - expect(store.getActions()[3].type).toEqual('COLLAPSE_TABLE'); + expect(store.getActions()[store.getActions().length - 1].type).toEqual( + 'COLLAPSE_TABLE', + ); }); }); @@ -129,14 +140,55 @@ test('When changing database the table list must be updated', async () => { database_name: 'new_db', backend: 'postgresql', }} - queryEditor={{ ...mockedProps.queryEditor, schema: 'new_schema' }} + queryEditorId={defaultQueryEditor.id} tables={[{ ...mockedProps.tables[0], dbId: 2, name: 'new_table' }]} />, { useRedux: true, - initialState, + store: mockStore({ + ...initialState, + sqlLab: { + ...initialState.sqlLab, + unsavedQueryEditor: { + id: defaultQueryEditor.id, + schema: 'new_schema', + }, + }, + }), }, ); expect(await screen.findByText(/new_db/i)).toBeInTheDocument(); expect(await screen.findByText(/new_table/i)).toBeInTheDocument(); }); + +test('ignore schema api when current schema is deprecated', async () => { + const invalidSchemaName = 'None'; + const { rerender } = await renderAndWait( + mockedProps, + mockStore({ + ...initialState, + sqlLab: { + ...initialState.sqlLab, + unsavedQueryEditor: { + id: defaultQueryEditor.id, + schema: invalidSchemaName, + }, + }, + }), + ); + + expect(await screen.findByText(/Database/i)).toBeInTheDocument(); + expect(screen.queryByText(/None/i)).toBeInTheDocument(); + expect(fetchMock.calls()).not.toContainEqual( + expect.arrayContaining([ + expect.stringContaining( + `/tables/${mockedProps.database.id}/${invalidSchemaName}/`, + ), + ]), + ); + rerender(); + // Deselect the deprecated schema selection + await waitFor(() => + expect(screen.queryByText(/None/i)).not.toBeInTheDocument(), + ); +}); diff --git a/superset-frontend/src/components/DatabaseSelector/DatabaseSelector.test.tsx b/superset-frontend/src/components/DatabaseSelector/DatabaseSelector.test.tsx index 0b2a7b521f2a..57b6a539ae18 100644 --- a/superset-frontend/src/components/DatabaseSelector/DatabaseSelector.test.tsx +++ b/superset-frontend/src/components/DatabaseSelector/DatabaseSelector.test.tsx @@ -18,14 +18,13 @@ */ import React from 'react'; +import fetchMock from 'fetch-mock'; import { render, screen, waitFor } from 'spec/helpers/testing-library'; -import { SupersetClient } from '@superset-ui/core'; +import { queryClient } from 'src/views/QueryProvider'; import userEvent from '@testing-library/user-event'; import DatabaseSelector, { DatabaseSelectorProps } from '.'; import { EmptyStateSmall } from '../EmptyState'; -const SupersetClientGet = jest.spyOn(SupersetClient, 'get'); - const createProps = (): DatabaseSelectorProps => ({ db: { id: 1, @@ -35,7 +34,7 @@ const createProps = (): DatabaseSelectorProps => ({ formMode: false, isDatabaseSelectEnabled: true, readOnly: false, - schema: undefined, + schema: 'public', sqlLabMode: true, getDbList: jest.fn(), handleError: jest.fn(), @@ -44,124 +43,133 @@ const createProps = (): DatabaseSelectorProps => ({ onSchemasLoad: jest.fn(), }); -beforeEach(() => { - jest.resetAllMocks(); - SupersetClientGet.mockImplementation( - async ({ endpoint }: { endpoint: string }) => { - if (endpoint.includes('schemas')) { - return { - json: { result: ['information_schema', 'public'] }, - } as any; - } - if (endpoint.includes('/function_names')) { - return { - json: { function_names: [] }, - } as any; - } - return { - json: { - count: 2, - description_columns: {}, - ids: [1, 2], - label_columns: { - allow_file_upload: 'Allow Csv Upload', - allow_ctas: 'Allow Ctas', - allow_cvas: 'Allow Cvas', - allow_dml: 'Allow Dml', - allow_run_async: 'Allow Run Async', - allows_cost_estimate: 'Allows Cost Estimate', - allows_subquery: 'Allows Subquery', - allows_virtual_table_explore: 'Allows Virtual Table Explore', - disable_data_preview: 'Disables SQL Lab Data Preview', - backend: 'Backend', - changed_on: 'Changed On', - changed_on_delta_humanized: 'Changed On Delta Humanized', - 'created_by.first_name': 'Created By First Name', - 'created_by.last_name': 'Created By Last Name', - database_name: 'Database Name', - explore_database_id: 'Explore Database Id', - expose_in_sqllab: 'Expose In Sqllab', - force_ctas_schema: 'Force Ctas Schema', - id: 'Id', - }, - list_columns: [ - 'allow_file_upload', - 'allow_ctas', - 'allow_cvas', - 'allow_dml', - 'allow_run_async', - 'allows_cost_estimate', - 'allows_subquery', - 'allows_virtual_table_explore', - 'disable_data_preview', - 'backend', - 'changed_on', - 'changed_on_delta_humanized', - 'created_by.first_name', - 'created_by.last_name', - 'database_name', - 'explore_database_id', - 'expose_in_sqllab', - 'force_ctas_schema', - 'id', - ], - list_title: 'List Database', - order_columns: [ - 'allow_file_upload', - 'allow_dml', - 'allow_run_async', - 'changed_on', - 'changed_on_delta_humanized', - 'created_by.first_name', - 'database_name', - 'expose_in_sqllab', - ], - result: [ - { - allow_file_upload: false, - allow_ctas: false, - allow_cvas: false, - allow_dml: false, - allow_run_async: false, - allows_cost_estimate: null, - allows_subquery: true, - allows_virtual_table_explore: true, - disable_data_preview: false, - backend: 'postgresql', - changed_on: '2021-03-09T19:02:07.141095', - changed_on_delta_humanized: 'a day ago', - created_by: null, - database_name: 'test-postgres', - explore_database_id: 1, - expose_in_sqllab: true, - force_ctas_schema: null, - id: 1, - }, - { - allow_csv_upload: false, - allow_ctas: false, - allow_cvas: false, - allow_dml: false, - allow_run_async: false, - allows_cost_estimate: null, - allows_subquery: true, - allows_virtual_table_explore: true, - disable_data_preview: false, - backend: 'mysql', - changed_on: '2021-03-09T19:02:07.141095', - changed_on_delta_humanized: 'a day ago', - created_by: null, - database_name: 'test-mysql', - explore_database_id: 1, - expose_in_sqllab: true, - force_ctas_schema: null, - id: 2, - }, - ], - }, - } as any; +const fakeDatabaseApiResult = { + count: 2, + description_columns: {}, + ids: [1, 2], + label_columns: { + allow_file_upload: 'Allow Csv Upload', + allow_ctas: 'Allow Ctas', + allow_cvas: 'Allow Cvas', + allow_dml: 'Allow Dml', + allow_run_async: 'Allow Run Async', + allows_cost_estimate: 'Allows Cost Estimate', + allows_subquery: 'Allows Subquery', + allows_virtual_table_explore: 'Allows Virtual Table Explore', + disable_data_preview: 'Disables SQL Lab Data Preview', + backend: 'Backend', + changed_on: 'Changed On', + changed_on_delta_humanized: 'Changed On Delta Humanized', + 'created_by.first_name': 'Created By First Name', + 'created_by.last_name': 'Created By Last Name', + database_name: 'Database Name', + explore_database_id: 'Explore Database Id', + expose_in_sqllab: 'Expose In Sqllab', + force_ctas_schema: 'Force Ctas Schema', + id: 'Id', + }, + list_columns: [ + 'allow_file_upload', + 'allow_ctas', + 'allow_cvas', + 'allow_dml', + 'allow_run_async', + 'allows_cost_estimate', + 'allows_subquery', + 'allows_virtual_table_explore', + 'disable_data_preview', + 'backend', + 'changed_on', + 'changed_on_delta_humanized', + 'created_by.first_name', + 'created_by.last_name', + 'database_name', + 'explore_database_id', + 'expose_in_sqllab', + 'force_ctas_schema', + 'id', + ], + list_title: 'List Database', + order_columns: [ + 'allow_file_upload', + 'allow_dml', + 'allow_run_async', + 'changed_on', + 'changed_on_delta_humanized', + 'created_by.first_name', + 'database_name', + 'expose_in_sqllab', + ], + result: [ + { + allow_file_upload: false, + allow_ctas: false, + allow_cvas: false, + allow_dml: false, + allow_run_async: false, + allows_cost_estimate: null, + allows_subquery: true, + allows_virtual_table_explore: true, + disable_data_preview: false, + backend: 'postgresql', + changed_on: '2021-03-09T19:02:07.141095', + changed_on_delta_humanized: 'a day ago', + created_by: null, + database_name: 'test-postgres', + explore_database_id: 1, + expose_in_sqllab: true, + force_ctas_schema: null, + id: 1, }, - ); + { + allow_csv_upload: false, + allow_ctas: false, + allow_cvas: false, + allow_dml: false, + allow_run_async: false, + allows_cost_estimate: null, + allows_subquery: true, + allows_virtual_table_explore: true, + disable_data_preview: false, + backend: 'mysql', + changed_on: '2021-03-09T19:02:07.141095', + changed_on_delta_humanized: 'a day ago', + created_by: null, + database_name: 'test-mysql', + explore_database_id: 1, + expose_in_sqllab: true, + force_ctas_schema: null, + id: 2, + }, + ], +}; + +const fakeSchemaApiResult = { + count: 2, + result: ['information_schema', 'public'], +}; + +const fakeFunctionNamesApiResult = { + function_names: [], +}; + +const databaseApiRoute = 'glob:*/api/v1/database/?*'; +const schemaApiRoute = 'glob:*/api/v1/database/*/schemas/?*'; +const tablesApiRoute = 'glob:*/api/v1/database/*/tables/*'; + +function setupFetchMock() { + fetchMock.get(databaseApiRoute, fakeDatabaseApiResult); + fetchMock.get(schemaApiRoute, fakeSchemaApiResult); + fetchMock.get(tablesApiRoute, fakeFunctionNamesApiResult); +} + +beforeEach(() => { + queryClient.clear(); + setupFetchMock(); +}); + +afterEach(() => { + fetchMock.reset(); }); test('Should render', async () => { @@ -175,6 +183,8 @@ test('Refresh should work', async () => { render(, { useRedux: true }); + expect(fetchMock.calls(schemaApiRoute).length).toBe(0); + const select = screen.getByRole('combobox', { name: 'Select schema or type schema name', }); @@ -182,23 +192,22 @@ test('Refresh should work', async () => { userEvent.click(select); await waitFor(() => { - expect(SupersetClientGet).toBeCalledTimes(2); - expect(props.getDbList).toBeCalledTimes(0); + expect(fetchMock.calls(databaseApiRoute).length).toBe(1); + expect(fetchMock.calls(schemaApiRoute).length).toBe(1); expect(props.handleError).toBeCalledTimes(0); expect(props.onDbChange).toBeCalledTimes(0); expect(props.onSchemaChange).toBeCalledTimes(0); - expect(props.onSchemasLoad).toBeCalledTimes(0); }); + // click schema reload userEvent.click(screen.getByRole('button', { name: 'refresh' })); await waitFor(() => { - expect(SupersetClientGet).toBeCalledTimes(3); - expect(props.getDbList).toBeCalledTimes(1); + expect(fetchMock.calls(databaseApiRoute).length).toBe(1); + expect(fetchMock.calls(schemaApiRoute).length).toBe(2); expect(props.handleError).toBeCalledTimes(0); expect(props.onDbChange).toBeCalledTimes(0); expect(props.onSchemaChange).toBeCalledTimes(0); - expect(props.onSchemasLoad).toBeCalledTimes(2); }); }); @@ -214,9 +223,10 @@ test('Should database select display options', async () => { }); test('should show empty state if there are no options', async () => { - SupersetClientGet.mockImplementation( - async () => ({ json: { result: [] } } as any), - ); + fetchMock.reset(); + fetchMock.get(databaseApiRoute, { result: [] }); + fetchMock.get(schemaApiRoute, { result: [] }); + fetchMock.get(tablesApiRoute, { result: [] }); const props = createProps(); render( ` @@ -86,8 +83,6 @@ export type DatabaseObject = { backend: string; }; -type SchemaValue = { label: string; value: string }; - export interface DatabaseSelectorProps { db?: DatabaseObject | null; emptyState?: ReactNode; @@ -119,6 +114,8 @@ const SelectLabel = ({ ); +const EMPTY_SCHEMA_OPTIONS: SchemaOption[] = []; + export default function DatabaseSelector({ db, formMode = false, @@ -134,13 +131,10 @@ export default function DatabaseSelector({ schema, sqlLabMode = false, }: DatabaseSelectorProps) { - const [loadingSchemas, setLoadingSchemas] = useState(false); - const [schemaOptions, setSchemaOptions] = useState([]); const [currentDb, setCurrentDb] = useState(); - const [currentSchema, setCurrentSchema] = useState( - schema ? { label: schema, value: schema } : undefined, + const [currentSchema, setCurrentSchema] = useState( + schema ? { label: schema, value: schema, title: schema } : undefined, ); - const [refresh, setRefresh] = useState(0); const { addSuccessToast } = useToasts(); const loadDatabases = useMemo( @@ -221,48 +215,37 @@ export default function DatabaseSelector({ ); }, [db]); - function changeSchema(schema: SchemaValue) { + function changeSchema(schema: SchemaOption | undefined) { setCurrentSchema(schema); if (onSchemaChange) { - onSchemaChange(schema.value); + onSchemaChange(schema?.value); } } - useEffect(() => { - if (currentDb) { - setLoadingSchemas(true); - const queryParams = rison.encode({ force: refresh > 0 }); - const endpoint = `/api/v1/database/${currentDb.value}/schemas/?q=${queryParams}`; + const { + data, + isFetching: loadingSchemas, + isFetched, + refetch, + } = useSchemas({ + dbId: currentDb?.value, + onSuccess: data => { + onSchemasLoad?.(data); - // TODO: Would be nice to add pagination in a follow-up. Needs endpoint changes. - SupersetClient.get({ endpoint }) - .then(({ json }) => { - const options = json.result.map((s: string) => ({ - value: s, - label: s, - title: s, - })); - if (onSchemasLoad) { - onSchemasLoad(options); - } - setSchemaOptions(options); - setLoadingSchemas(false); - if (options.length === 1) changeSchema(options[0]); - if (refresh > 0) addSuccessToast(t('List refreshed')); - }) - .catch(err => { - setLoadingSchemas(false); - getClientErrorObject(err).then(clientError => { - handleError( - getClientErrorMessage( - t('There was an error loading the schemas'), - clientError, - ), - ); - }); - }); - } - }, [currentDb, onSchemasLoad, refresh]); + if (data.length === 1) { + changeSchema(data[0]); + } else if (!data.find(schemaOption => schema === schemaOption.value)) { + changeSchema(undefined); + } + + if (isFetched) { + addSuccessToast('List refreshed'); + } + }, + onError: () => handleError(t('There was an error loading the schemas')), + }); + + const schemaOptions = data || EMPTY_SCHEMA_OPTIONS; function changeDataBase( value: { label: string; value: number }, @@ -309,7 +292,7 @@ export default function DatabaseSelector({ function renderSchemaSelect() { const refreshIcon = !readOnly && ( setRefresh(refresh + 1)} + onClick={() => refetch()} tooltipContent={t('Force refresh schema list')} /> ); @@ -323,7 +306,7 @@ export default function DatabaseSelector({ name="select-schema" notFoundContent={t('No compatible schema found')} placeholder={t('Select schema or type schema name')} - onChange={item => changeSchema(item as SchemaValue)} + onChange={item => changeSchema(item as SchemaOption)} options={schemaOptions} showSearch value={currentSchema} diff --git a/superset-frontend/src/components/TableSelector/TableSelector.test.tsx b/superset-frontend/src/components/TableSelector/TableSelector.test.tsx index 09cb29a3857f..b4b67deb92dc 100644 --- a/superset-frontend/src/components/TableSelector/TableSelector.test.tsx +++ b/superset-frontend/src/components/TableSelector/TableSelector.test.tsx @@ -19,13 +19,12 @@ import React from 'react'; import { render, screen, waitFor, within } from 'spec/helpers/testing-library'; -import { SupersetClient } from '@superset-ui/core'; +import { queryClient } from 'src/views/QueryProvider'; +import fetchMock from 'fetch-mock'; import { act } from 'react-dom/test-utils'; import userEvent from '@testing-library/user-event'; import TableSelector, { TableSelectorMultiple } from '.'; -const SupersetClientGet = jest.spyOn(SupersetClient, 'get'); - const createProps = (props = {}) => ({ database: { id: 1, @@ -37,37 +36,43 @@ const createProps = (props = {}) => ({ ...props, }); -afterEach(() => { - jest.clearAllMocks(); -}); - -const getSchemaMockFunction = async () => +const getSchemaMockFunction = () => ({ - json: { - result: ['schema_a', 'schema_b'], - }, + result: ['schema_a', 'schema_b'], } as any); -const getTableMockFunction = async () => +const getTableMockFunction = () => ({ - json: { - count: 4, - result: [ - { label: 'table_a', value: 'table_a' }, - { label: 'table_b', value: 'table_b' }, - { label: 'table_c', value: 'table_c' }, - { label: 'table_d', value: 'table_d' }, - ], - }, + count: 4, + result: [ + { label: 'table_a', value: 'table_a' }, + { label: 'table_b', value: 'table_b' }, + { label: 'table_c', value: 'table_c' }, + { label: 'table_d', value: 'table_d' }, + ], } as any); +const databaseApiRoute = 'glob:*/api/v1/database/?*'; +const schemaApiRoute = 'glob:*/api/v1/database/*/schemas/?*'; +const tablesApiRoute = 'glob:*/api/v1/database/*/tables/*'; + const getSelectItemContainer = (select: HTMLElement) => select.parentElement?.parentElement?.getElementsByClassName( 'ant-select-selection-item', ); +beforeEach(() => { + queryClient.clear(); + fetchMock.get(databaseApiRoute, { result: [] }); +}); + +afterEach(() => { + fetchMock.reset(); +}); + test('renders with default props', async () => { - SupersetClientGet.mockImplementation(getTableMockFunction); + fetchMock.get(schemaApiRoute, { result: [] }); + fetchMock.get(tablesApiRoute, getTableMockFunction()); const props = createProps(); render(, { useRedux: true }); @@ -88,7 +93,8 @@ test('renders with default props', async () => { }); test('renders table options', async () => { - SupersetClientGet.mockImplementation(getTableMockFunction); + fetchMock.get(schemaApiRoute, { result: ['test_schema'] }); + fetchMock.get(tablesApiRoute, getTableMockFunction()); const props = createProps(); render(, { useRedux: true }); @@ -105,7 +111,8 @@ test('renders table options', async () => { }); test('renders disabled without schema', async () => { - SupersetClientGet.mockImplementation(getTableMockFunction); + fetchMock.get(schemaApiRoute, { result: [] }); + fetchMock.get(tablesApiRoute, getTableMockFunction()); const props = createProps(); render(, { useRedux: true }); @@ -118,7 +125,7 @@ test('renders disabled without schema', async () => { }); test('table options are notified after schema selection', async () => { - SupersetClientGet.mockImplementation(getSchemaMockFunction); + fetchMock.get(schemaApiRoute, getSchemaMockFunction()); const callback = jest.fn(); const props = createProps({ @@ -142,7 +149,7 @@ test('table options are notified after schema selection', async () => { await screen.findByRole('option', { name: 'schema_b' }), ).toBeInTheDocument(); - SupersetClientGet.mockImplementation(getTableMockFunction); + fetchMock.get(tablesApiRoute, getTableMockFunction()); act(() => { userEvent.click(screen.getAllByText('schema_a')[1]); @@ -159,7 +166,8 @@ test('table options are notified after schema selection', async () => { }); test('table select retain value if not in SQL Lab mode', async () => { - SupersetClientGet.mockImplementation(getTableMockFunction); + fetchMock.get(schemaApiRoute, { result: ['test_schema'] }); + fetchMock.get(tablesApiRoute, getTableMockFunction()); const callback = jest.fn(); const props = createProps({ @@ -182,7 +190,7 @@ test('table select retain value if not in SQL Lab mode', async () => { await screen.findByRole('option', { name: 'table_a' }), ).toBeInTheDocument(); - act(() => { + await waitFor(() => { userEvent.click(screen.getAllByText('table_a')[1]); }); @@ -199,7 +207,8 @@ test('table select retain value if not in SQL Lab mode', async () => { }); test('table multi select retain all the values selected', async () => { - SupersetClientGet.mockImplementation(getTableMockFunction); + fetchMock.get(schemaApiRoute, { result: ['test_schema'] }); + fetchMock.get(tablesApiRoute, getTableMockFunction()); const callback = jest.fn(); const props = createProps({ @@ -217,23 +226,19 @@ test('table multi select retain all the values selected', async () => { userEvent.click(tableSelect); - act(() => { - const item = screen.getAllByText('table_b'); + await waitFor(async () => { + const item = await screen.findAllByText('table_b'); userEvent.click(item[item.length - 1]); }); - act(() => { - const item = screen.getAllByText('table_c'); + await waitFor(async () => { + const item = await screen.findAllByText('table_c'); userEvent.click(item[item.length - 1]); }); - expect(screen.getByRole('option', { name: 'table_b' })).toHaveAttribute( - 'aria-selected', - 'true', - ); + const selection1 = await screen.findByRole('option', { name: 'table_b' }); + expect(selection1).toHaveAttribute('aria-selected', 'true'); - expect(screen.getByRole('option', { name: 'table_c' })).toHaveAttribute( - 'aria-selected', - 'true', - ); + const selection2 = await screen.findByRole('option', { name: 'table_c' }); + expect(selection2).toHaveAttribute('aria-selected', 'true'); }); diff --git a/superset-frontend/src/components/TableSelector/index.tsx b/superset-frontend/src/components/TableSelector/index.tsx index 1cf65bbc688a..ffb45cc8fed9 100644 --- a/superset-frontend/src/components/TableSelector/index.tsx +++ b/superset-frontend/src/components/TableSelector/index.tsx @@ -275,26 +275,6 @@ const TableSelector: FunctionComponent = ({ internalTableChange(value); }; - function renderDatabaseSelector() { - return ( - - ); - } - const handleFilterOption = useMemo( () => (search: string, option: TableOption) => { const searchValue = search.trim().toLowerCase(); @@ -346,7 +326,21 @@ const TableSelector: FunctionComponent = ({ return ( - {renderDatabaseSelector()} + {sqlLabMode && !formMode &&
} {renderTableSelect()} diff --git a/superset-frontend/src/hooks/apiResources/index.ts b/superset-frontend/src/hooks/apiResources/index.ts index 32a6418dc07c..81d77b5d11a5 100644 --- a/superset-frontend/src/hooks/apiResources/index.ts +++ b/superset-frontend/src/hooks/apiResources/index.ts @@ -29,3 +29,4 @@ export { export * from './charts'; export * from './dashboards'; export * from './tables'; +export * from './schemas'; diff --git a/superset-frontend/src/hooks/apiResources/schemas.test.ts b/superset-frontend/src/hooks/apiResources/schemas.test.ts new file mode 100644 index 000000000000..59d00a5dc71b --- /dev/null +++ b/superset-frontend/src/hooks/apiResources/schemas.test.ts @@ -0,0 +1,138 @@ +/** + * 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 rison from 'rison'; +import fetchMock from 'fetch-mock'; +import { act, renderHook } from '@testing-library/react-hooks'; +import QueryProvider, { queryClient } from 'src/views/QueryProvider'; +import { useSchemas } from './schemas'; + +const fakeApiResult = { + result: ['test schema 1', 'test schema b'], +}; + +const expectedResult = fakeApiResult.result.map((value: string) => ({ + value, + label: value, + title: value, +})); + +describe('useSchemas hook', () => { + beforeEach(() => { + queryClient.clear(); + jest.useFakeTimers(); + }); + + afterEach(() => { + fetchMock.reset(); + jest.useRealTimers(); + }); + + test('returns api response mapping json result', async () => { + const expectDbId = 'db1'; + const forceRefresh = false; + const schemaApiRoute = `glob:*/api/v1/database/${expectDbId}/schemas/*`; + fetchMock.get(schemaApiRoute, fakeApiResult); + const { result } = renderHook( + () => + useSchemas({ + dbId: expectDbId, + }), + { + wrapper: QueryProvider, + }, + ); + await act(async () => { + jest.runAllTimers(); + }); + expect(fetchMock.calls(schemaApiRoute).length).toBe(1); + expect( + fetchMock.calls( + `end:/api/v1/database/${expectDbId}/schemas/?q=${rison.encode({ + force: forceRefresh, + })}`, + ).length, + ).toBe(1); + expect(result.current.data).toEqual(expectedResult); + await act(async () => { + result.current.refetch(); + }); + expect(fetchMock.calls(schemaApiRoute).length).toBe(2); + expect( + fetchMock.calls( + `end:/api/v1/database/${expectDbId}/schemas/?q=${rison.encode({ + force: true, + })}`, + ).length, + ).toBe(1); + expect(result.current.data).toEqual(expectedResult); + }); + + test('returns cached data without api request', async () => { + const expectDbId = 'db1'; + const schemaApiRoute = `glob:*/api/v1/database/${expectDbId}/schemas/*`; + fetchMock.get(schemaApiRoute, fakeApiResult); + const { result, rerender } = renderHook( + () => + useSchemas({ + dbId: expectDbId, + }), + { + wrapper: QueryProvider, + }, + ); + await act(async () => { + jest.runAllTimers(); + }); + expect(fetchMock.calls(schemaApiRoute).length).toBe(1); + rerender(); + expect(fetchMock.calls(schemaApiRoute).length).toBe(1); + expect(result.current.data).toEqual(expectedResult); + }); + + it('returns refreshed data after expires', async () => { + const expectDbId = 'db1'; + const schemaApiRoute = `glob:*/api/v1/database/${expectDbId}/schemas/*`; + fetchMock.get(schemaApiRoute, fakeApiResult); + const { result, rerender } = renderHook( + () => + useSchemas({ + dbId: expectDbId, + }), + { + wrapper: QueryProvider, + }, + ); + await act(async () => { + jest.runAllTimers(); + }); + expect(fetchMock.calls(schemaApiRoute).length).toBe(1); + rerender(); + await act(async () => { + jest.runAllTimers(); + }); + expect(fetchMock.calls(schemaApiRoute).length).toBe(1); + queryClient.clear(); + rerender(); + await act(async () => { + jest.runAllTimers(); + }); + expect(fetchMock.calls(schemaApiRoute).length).toBe(2); + expect(result.current.data).toEqual(expectedResult); + }); +}); diff --git a/superset-frontend/src/hooks/apiResources/schemas.ts b/superset-frontend/src/hooks/apiResources/schemas.ts new file mode 100644 index 000000000000..34a50ca4e989 --- /dev/null +++ b/superset-frontend/src/hooks/apiResources/schemas.ts @@ -0,0 +1,80 @@ +/** + * 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 { useRef } from 'react'; +import { useQuery, UseQueryOptions } from 'react-query'; +import rison from 'rison'; +import { SupersetClient } from '@superset-ui/core'; + +export type FetchSchemasQueryParams = { + dbId?: string | number; + forceRefresh?: boolean; +}; + +type QueryData = { + json: { result: string[] }; + response: Response; +}; + +export type SchemaOption = { + value: string; + label: string; + title: string; +}; + +export function fetchSchemas({ dbId, forceRefresh }: FetchSchemasQueryParams) { + const queryParams = rison.encode({ force: forceRefresh }); + // TODO: Would be nice to add pagination in a follow-up. Needs endpoint changes. + const endpoint = `/api/v1/database/${dbId}/schemas/?q=${queryParams}`; + return SupersetClient.get({ endpoint }) as Promise; +} + +type Params = FetchSchemasQueryParams & + Pick, 'onSuccess' | 'onError'>; + +export function useSchemas(options: Params) { + const { dbId, onSuccess, onError } = options || {}; + const forceRefreshRef = useRef(false); + const params = { dbId }; + const result = useQuery( + ['schemas', { dbId }], + () => fetchSchemas({ ...params, forceRefresh: forceRefreshRef.current }), + { + select: ({ json }) => + json.result.map((value: string) => ({ + value, + label: value, + title: value, + })), + enabled: Boolean(dbId), + onSuccess, + onError, + onSettled: () => { + forceRefreshRef.current = false; + }, + }, + ); + + return { + ...result, + refetch: () => { + forceRefreshRef.current = true; + return result.refetch(); + }, + }; +} diff --git a/superset-frontend/src/hooks/apiResources/tables.test.ts b/superset-frontend/src/hooks/apiResources/tables.test.ts index 49305f5f62eb..8cc0791d5069 100644 --- a/superset-frontend/src/hooks/apiResources/tables.test.ts +++ b/superset-frontend/src/hooks/apiResources/tables.test.ts @@ -16,78 +16,76 @@ * specific language governing permissions and limitations * under the License. */ +import rison from 'rison'; +import fetchMock from 'fetch-mock'; import { act, renderHook } from '@testing-library/react-hooks'; -import { SupersetClient } from '@superset-ui/core'; import QueryProvider, { queryClient } from 'src/views/QueryProvider'; import { useTables } from './tables'; const fakeApiResult = { - json: { - count: 2, - result: [ - { - id: 1, - name: 'fake api result1', - label: 'fake api label1', - }, - { - id: 2, - name: 'fake api result2', - label: 'fake api label2', - }, - ], - }, + count: 2, + result: [ + { + id: 1, + name: 'fake api result1', + label: 'fake api label1', + }, + { + id: 2, + name: 'fake api result2', + label: 'fake api label2', + }, + ], }; const fakeHasMoreApiResult = { - json: { - count: 4, - result: [ - { - id: 1, - name: 'fake api result1', - label: 'fake api label1', - }, - { - id: 2, - name: 'fake api result2', - label: 'fake api label2', - }, - ], - }, + count: 4, + result: [ + { + id: 1, + name: 'fake api result1', + label: 'fake api label1', + }, + { + id: 2, + name: 'fake api result2', + label: 'fake api label2', + }, + ], }; +const fakeSchemaApiResult = ['schema1', 'schema2']; + const expectedData = { - options: [...fakeApiResult.json.result], + options: fakeApiResult.result, hasMore: false, }; const expectedHasMoreData = { - options: [...fakeHasMoreApiResult.json.result], + options: fakeHasMoreApiResult.result, hasMore: true, }; -jest.mock('@superset-ui/core', () => ({ - SupersetClient: { - get: jest.fn().mockResolvedValue(fakeApiResult), - }, -})); - describe('useTables hook', () => { beforeEach(() => { - (SupersetClient.get as jest.Mock).mockClear(); queryClient.clear(); jest.useFakeTimers(); }); afterEach(() => { + fetchMock.reset(); jest.useRealTimers(); }); - it('returns api response mapping json options', async () => { + test('returns api response mapping json options', async () => { const expectDbId = 'db1'; - const expectedSchema = 'schemaA'; - const forceRefresh = false; + const expectedSchema = 'schema1'; + const schemaApiRoute = `glob:*/api/v1/database/${expectDbId}/schemas/*`; + const tableApiRoute = `glob:*/api/v1/database/${expectDbId}/tables/?q=*`; + fetchMock.get(tableApiRoute, fakeApiResult); + fetchMock.get(schemaApiRoute, { + result: fakeSchemaApiResult, + }); const { result } = renderHook( () => useTables({ @@ -101,29 +99,73 @@ describe('useTables hook', () => { await act(async () => { jest.runAllTimers(); }); - expect(SupersetClient.get).toHaveBeenCalledTimes(1); - expect(SupersetClient.get).toHaveBeenCalledWith({ - endpoint: `/api/v1/database/${expectDbId}/tables/?q=(force:!${ - forceRefresh ? 't' : 'f' - },schema_name:${expectedSchema})`, - }); + expect(fetchMock.calls(schemaApiRoute).length).toBe(1); + expect( + fetchMock.calls( + `end:api/v1/database/${expectDbId}/tables/?q=${rison.encode({ + force: false, + schema_name: expectedSchema, + })}`, + ).length, + ).toBe(1); expect(result.current.data).toEqual(expectedData); await act(async () => { result.current.refetch(); }); - expect(SupersetClient.get).toHaveBeenCalledTimes(2); - expect(SupersetClient.get).toHaveBeenCalledWith({ - endpoint: `/api/v1/database/${expectDbId}/tables/?q=(force:!t,schema_name:${expectedSchema})`, - }); + expect(fetchMock.calls(schemaApiRoute).length).toBe(1); + expect( + fetchMock.calls( + `end:api/v1/database/${expectDbId}/tables/?q=${rison.encode({ + force: true, + schema_name: expectedSchema, + })}`, + ).length, + ).toBe(1); expect(result.current.data).toEqual(expectedData); }); - it('returns hasMore when total is larger than result size', async () => { - (SupersetClient.get as jest.Mock).mockResolvedValueOnce( - fakeHasMoreApiResult, + test('skips the deprecated schema option', async () => { + const expectDbId = 'db1'; + const unexpectedSchema = 'invalid schema'; + const schemaApiRoute = `glob:*/api/v1/database/${expectDbId}/schemas/*`; + const tableApiRoute = `glob:*/api/v1/database/${expectDbId}/tables/?q=*`; + fetchMock.get(tableApiRoute, fakeApiResult); + fetchMock.get(schemaApiRoute, { + result: fakeSchemaApiResult, + }); + const { result } = renderHook( + () => + useTables({ + dbId: expectDbId, + schema: unexpectedSchema, + }), + { + wrapper: QueryProvider, + }, ); + await act(async () => { + jest.runAllTimers(); + }); + expect(fetchMock.calls(schemaApiRoute).length).toBe(1); + expect(result.current.data).toEqual(undefined); + expect( + fetchMock.calls( + `end:api/v1/database/${expectDbId}/tables/?q=${rison.encode({ + force: false, + schema_name: unexpectedSchema, + })}`, + ).length, + ).toBe(0); + }); + + test('returns hasMore when total is larger than result size', async () => { const expectDbId = 'db1'; - const expectedSchema = 'schemaA'; + const expectedSchema = 'schema2'; + const tableApiRoute = `glob:*/api/v1/database/${expectDbId}/tables/?q=*`; + fetchMock.get(tableApiRoute, fakeHasMoreApiResult); + fetchMock.get(`glob:*/api/v1/database/${expectDbId}/schemas/*`, { + result: fakeSchemaApiResult, + }); const { result } = renderHook( () => useTables({ @@ -137,13 +179,18 @@ describe('useTables hook', () => { await act(async () => { jest.runAllTimers(); }); - expect(SupersetClient.get).toHaveBeenCalledTimes(1); + expect(fetchMock.calls(tableApiRoute).length).toBe(1); expect(result.current.data).toEqual(expectedHasMoreData); }); - it('returns cached data without api request', async () => { + test('returns cached data without api request', async () => { const expectDbId = 'db1'; - const expectedSchema = 'schemaA'; + const expectedSchema = 'schema1'; + const tableApiRoute = `glob:*/api/v1/database/${expectDbId}/tables/?q=*`; + fetchMock.get(tableApiRoute, fakeApiResult); + fetchMock.get(`glob:*/api/v1/database/${expectDbId}/schemas/*`, { + result: fakeSchemaApiResult, + }); const { result, rerender } = renderHook( () => useTables({ @@ -157,15 +204,20 @@ describe('useTables hook', () => { await act(async () => { jest.runAllTimers(); }); - expect(SupersetClient.get).toHaveBeenCalledTimes(1); + expect(fetchMock.calls(tableApiRoute).length).toBe(1); rerender(); - expect(SupersetClient.get).toHaveBeenCalledTimes(1); + expect(fetchMock.calls(tableApiRoute).length).toBe(1); expect(result.current.data).toEqual(expectedData); }); - it('returns refreshed data after expires', async () => { + test('returns refreshed data after expires', async () => { const expectDbId = 'db1'; - const expectedSchema = 'schemaA'; + const expectedSchema = 'schema1'; + const tableApiRoute = `glob:*/api/v1/database/${expectDbId}/tables/?q=*`; + fetchMock.get(tableApiRoute, fakeApiResult); + fetchMock.get(`glob:*/api/v1/database/${expectDbId}/schemas/*`, { + result: fakeSchemaApiResult, + }); const { result, rerender } = renderHook( () => useTables({ @@ -179,18 +231,18 @@ describe('useTables hook', () => { await act(async () => { jest.runAllTimers(); }); - expect(SupersetClient.get).toHaveBeenCalledTimes(1); + expect(fetchMock.calls(tableApiRoute).length).toBe(1); rerender(); await act(async () => { jest.runAllTimers(); }); - expect(SupersetClient.get).toHaveBeenCalledTimes(1); + expect(fetchMock.calls(tableApiRoute).length).toBe(1); queryClient.clear(); rerender(); await act(async () => { jest.runAllTimers(); }); - expect(SupersetClient.get).toHaveBeenCalledTimes(2); + expect(fetchMock.calls(tableApiRoute).length).toBe(2); expect(result.current.data).toEqual(expectedData); }); }); diff --git a/superset-frontend/src/hooks/apiResources/tables.ts b/superset-frontend/src/hooks/apiResources/tables.ts index f65cdf375ad4..34d286c9456a 100644 --- a/superset-frontend/src/hooks/apiResources/tables.ts +++ b/superset-frontend/src/hooks/apiResources/tables.ts @@ -16,11 +16,13 @@ * specific language governing permissions and limitations * under the License. */ -import { useRef } from 'react'; +import { useRef, useMemo } from 'react'; import { useQuery, UseQueryOptions } from 'react-query'; import rison from 'rison'; import { SupersetClient } from '@superset-ui/core'; +import { useSchemas } from './schemas'; + export type FetchTablesQueryParams = { dbId?: string | number; schema?: string; @@ -71,9 +73,16 @@ export function fetchTables({ } type Params = FetchTablesQueryParams & - Pick; + Pick, 'onSuccess' | 'onError'>; export function useTables(options: Params) { + const { data: schemaOptions, isFetching } = useSchemas({ + dbId: options.dbId, + }); + const schemaOptionsMap = useMemo( + () => new Set(schemaOptions?.map(({ value }) => value)), + [schemaOptions], + ); const { dbId, schema, onSuccess, onError } = options || {}; const forceRefreshRef = useRef(false); const params = { dbId, schema }; @@ -85,7 +94,9 @@ export function useTables(options: Params) { options: json.result, hasMore: json.count > json.result.length, }), - enabled: Boolean(dbId && schema), + enabled: Boolean( + dbId && schema && !isFetching && schemaOptionsMap.has(schema), + ), onSuccess, onError, onSettled: () => {