fix(sqllab): Invalid schema fetch by deprecated value (#22968)

This commit is contained in:
JUST.in DO IT 2023-02-13 08:36:47 -08:00 committed by GitHub
parent a7bb14e433
commit d3d59ee0ae
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 651 additions and 325 deletions

View File

@ -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(),
);
});

View File

@ -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(<DatabaseSelector {...props} />, { 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(
<DatabaseSelector

View File

@ -24,10 +24,7 @@ import Label from 'src/components/Label';
import { FormLabel } from 'src/components/Form';
import RefreshLabel from 'src/components/RefreshLabel';
import { useToasts } from 'src/components/MessageToasts/withToasts';
import {
getClientErrorMessage,
getClientErrorObject,
} from 'src/utils/getClientErrorObject';
import { useSchemas, SchemaOption } from 'src/hooks/apiResources';
const DatabaseSelectorWrapper = styled.div`
${({ theme }) => `
@ -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 = ({
</LabelStyle>
);
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<SchemaValue[]>([]);
const [currentDb, setCurrentDb] = useState<DatabaseValue | undefined>();
const [currentSchema, setCurrentSchema] = useState<SchemaValue | undefined>(
schema ? { label: schema, value: schema } : undefined,
const [currentSchema, setCurrentSchema] = useState<SchemaOption | undefined>(
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 && (
<RefreshLabel
onClick={() => 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}

View File

@ -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(<TableSelector {...props} />, { 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(<TableSelector {...props} />, { 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(<TableSelector {...props} schema={undefined} />, { 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');
});

View File

@ -275,26 +275,6 @@ const TableSelector: FunctionComponent<TableSelectorProps> = ({
internalTableChange(value);
};
function renderDatabaseSelector() {
return (
<DatabaseSelector
db={database}
emptyState={emptyState}
formMode={formMode}
getDbList={getDbList}
handleError={handleError}
onDbChange={readOnly ? undefined : internalDbChange}
onEmptyResults={onEmptyResults}
onSchemaChange={readOnly ? undefined : internalSchemaChange}
onSchemasLoad={onSchemasLoad}
schema={currentSchema}
sqlLabMode={sqlLabMode}
isDatabaseSelectEnabled={isDatabaseSelectEnabled && !readOnly}
readOnly={readOnly}
/>
);
}
const handleFilterOption = useMemo(
() => (search: string, option: TableOption) => {
const searchValue = search.trim().toLowerCase();
@ -346,7 +326,21 @@ const TableSelector: FunctionComponent<TableSelectorProps> = ({
return (
<TableSelectorWrapper>
{renderDatabaseSelector()}
<DatabaseSelector
db={database}
emptyState={emptyState}
formMode={formMode}
getDbList={getDbList}
handleError={handleError}
onDbChange={readOnly ? undefined : internalDbChange}
onEmptyResults={onEmptyResults}
onSchemaChange={readOnly ? undefined : internalSchemaChange}
onSchemasLoad={onSchemasLoad}
schema={currentSchema}
sqlLabMode={sqlLabMode}
isDatabaseSelectEnabled={isDatabaseSelectEnabled && !readOnly}
readOnly={readOnly}
/>
{sqlLabMode && !formMode && <div className="divider" />}
{renderTableSelect()}
</TableSelectorWrapper>

View File

@ -29,3 +29,4 @@ export {
export * from './charts';
export * from './dashboards';
export * from './tables';
export * from './schemas';

View File

@ -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);
});
});

View File

@ -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<QueryData>;
}
type Params = FetchSchemasQueryParams &
Pick<UseQueryOptions<SchemaOption[]>, 'onSuccess' | 'onError'>;
export function useSchemas(options: Params) {
const { dbId, onSuccess, onError } = options || {};
const forceRefreshRef = useRef(false);
const params = { dbId };
const result = useQuery<QueryData, Error, SchemaOption[]>(
['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();
},
};
}

View File

@ -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 expectedSchema = 'schemaA';
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 = '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);
});
});

View File

@ -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<UseQueryOptions, 'onSuccess' | 'onError'>;
Pick<UseQueryOptions<Data>, '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: () => {