chore(sqllab): Remove table metadata from state (#24371)

This commit is contained in:
JUST.in DO IT 2023-06-22 15:37:03 -07:00 committed by GitHub
parent 2a4ef5cccf
commit 51a34d7d58
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 515 additions and 410 deletions

View File

@ -26,10 +26,12 @@ import { initFeatureFlags, isFeatureEnabled } from 'src/featureFlags';
import { setupStore } from 'src/views/store';
import setupExtensions from 'src/setup/setupExtensions';
import getBootstrapData from 'src/utils/getBootstrapData';
import { api } from 'src/hooks/apiResources/queryApi';
import getInitialState from './reducers/getInitialState';
import { reducers } from './reducers/index';
import App from './components/App';
import {
emptyTablePersistData,
emptyQueryResults,
clearQueryEditors,
} from './utils/reduxStateToLocalStorageHelper';
@ -62,6 +64,7 @@ const sqlLabPersistStateConfig = {
if (path === 'sqlLab') {
subset[path] = {
...state[path],
tables: emptyTablePersistData(state[path].tables),
queries: emptyQueryResults(state[path].queries),
queryEditors: clearQueryEditors(state[path].queryEditors),
unsavedQueryEditor: clearQueryEditors([
@ -119,6 +122,28 @@ export const store = setupStore({
}),
});
// Rehydrate server side persisted table metadata
initialState.sqlLab.tables.forEach(
({ name: table, schema, dbId, persistData }) => {
if (dbId && schema && table && persistData?.columns) {
store.dispatch(
api.util.upsertQueryData(
'tableMetadata',
{ dbId, schema, table },
persistData,
),
);
store.dispatch(
api.util.upsertQueryData(
'tableExtendedMetadata',
{ dbId, schema, table },
{},
),
);
}
},
);
// Highlight the navbar menu
const menus = document.querySelectorAll('.nav.navbar-nav li.dropdown');
const sqlLabMenu = Array.prototype.slice

View File

@ -1100,65 +1100,7 @@ export function mergeTable(table, query, prepend) {
return { type: MERGE_TABLE, table, query, prepend };
}
function getTableMetadata(table, query, dispatch) {
return SupersetClient.get({
endpoint: encodeURI(
`/api/v1/database/${query.dbId}/table/${encodeURIComponent(
table.name,
)}/${encodeURIComponent(table.schema)}/`,
),
})
.then(({ json }) => {
const newTable = {
...table,
...json,
expanded: true,
isMetadataLoading: false,
};
dispatch(mergeTable(newTable)); // Merge table to tables in state
return newTable;
})
.catch(() =>
Promise.all([
dispatch(
mergeTable({
...table,
isMetadataLoading: false,
}),
),
dispatch(
addDangerToast(t('An error occurred while fetching table metadata')),
),
]),
);
}
function getTableExtendedMetadata(table, query, dispatch) {
return SupersetClient.get({
endpoint: encodeURI(
`/api/v1/database/${query.dbId}/table_extra/` +
`${encodeURIComponent(table.name)}/${encodeURIComponent(
table.schema,
)}/`,
),
})
.then(({ json }) => {
dispatch(
mergeTable({ ...table, ...json, isExtraMetadataLoading: false }),
);
return json;
})
.catch(() =>
Promise.all([
dispatch(mergeTable({ ...table, isExtraMetadataLoading: false })),
dispatch(
addDangerToast(t('An error occurred while fetching table metadata')),
),
]),
);
}
export function addTable(queryEditor, database, tableName, schemaName) {
export function addTable(queryEditor, tableName, schemaName) {
return function (dispatch, getState) {
const query = getUpToDateQuery(getState(), queryEditor, queryEditor.id);
const table = {
@ -1171,67 +1113,90 @@ export function addTable(queryEditor, database, tableName, schemaName) {
mergeTable(
{
...table,
isMetadataLoading: true,
isExtraMetadataLoading: true,
id: shortid.generate(),
expanded: true,
},
null,
true,
),
);
};
}
return Promise.all([
getTableMetadata(table, query, dispatch),
getTableExtendedMetadata(table, query, dispatch),
]).then(([newTable, json]) => {
const sync = isFeatureEnabled(FeatureFlag.SQLLAB_BACKEND_PERSISTENCE)
? SupersetClient.post({
endpoint: encodeURI('/tableschemaview/'),
postPayload: { table: { ...newTable, ...json } },
})
: Promise.resolve({ json: { id: shortid.generate() } });
export function runTablePreviewQuery(newTable) {
return function (dispatch, getState) {
const {
sqlLab: { databases },
} = getState();
const database = databases[newTable.dbId];
const { dbId } = newTable;
if (!database.disable_data_preview && database.id === query.dbId) {
const dataPreviewQuery = {
id: shortid.generate(),
dbId: query.dbId,
sql: newTable.selectStar,
tableName: table.name,
sqlEditorId: null,
tab: '',
runAsync: database.allow_run_async,
ctas: false,
isDataPreview: true,
};
Promise.all([
dispatch(
mergeTable(
{
...newTable,
dataPreviewQueryId: dataPreviewQuery.id,
},
dataPreviewQuery,
),
if (database && !database.disable_data_preview) {
const dataPreviewQuery = {
id: shortid.generate(),
dbId,
sql: newTable.selectStar,
tableName: newTable.name,
sqlEditorId: null,
tab: '',
runAsync: database.allow_run_async,
ctas: false,
isDataPreview: true,
};
return Promise.all([
dispatch(
mergeTable(
{
id: newTable.id,
dbId: newTable.dbId,
schema: newTable.schema,
name: newTable.name,
queryEditorId: newTable.queryEditorId,
dataPreviewQueryId: dataPreviewQuery.id,
},
dataPreviewQuery,
),
dispatch(runQuery(dataPreviewQuery)),
]);
}
),
dispatch(runQuery(dataPreviewQuery)),
]);
}
return Promise.resolve();
};
}
return sync
.then(({ json: resultJson }) =>
dispatch(mergeTable({ ...table, id: resultJson.id })),
)
.catch(() =>
dispatch(
addDangerToast(
t(
'An error occurred while fetching table metadata. ' +
'Please contact your administrator.',
),
),
),
export function syncTable(table, tableMetadata) {
return function (dispatch) {
const sync = isFeatureEnabled(FeatureFlag.SQLLAB_BACKEND_PERSISTENCE)
? SupersetClient.post({
endpoint: encodeURI('/tableschemaview/'),
postPayload: { table: { ...tableMetadata, ...table } },
})
: Promise.resolve({ json: { id: table.id } });
return sync
.then(({ json: resultJson }) => {
const newTable = { ...table, id: resultJson.id };
dispatch(
mergeTable({
...newTable,
expanded: true,
initialized: true,
}),
);
});
if (!table.dataPreviewQueryId) {
dispatch(runTablePreviewQuery({ ...tableMetadata, ...newTable }));
}
})
.catch(() =>
dispatch(
addDangerToast(
t(
'An error occurred while fetching table metadata. ' +
'Please contact your administrator.',
),
),
),
);
};
}

View File

@ -821,43 +821,7 @@ describe('async actions', () => {
});
describe('addTable', () => {
it('updates the table schema state in the backend', () => {
expect.assertions(6);
const database = { disable_data_preview: true };
const tableName = 'table';
const schemaName = 'schema';
const store = mockStore(initialState);
const expectedActionTypes = [
actions.MERGE_TABLE, // addTable
actions.MERGE_TABLE, // getTableMetadata
actions.MERGE_TABLE, // getTableExtendedMetadata
actions.MERGE_TABLE, // addTable
];
const request = actions.addTable(
query,
database,
tableName,
schemaName,
);
return request(store.dispatch, store.getState).then(() => {
expect(store.getActions().map(a => a.type)).toEqual(
expectedActionTypes,
);
expect(store.getActions()[0].prepend).toBeTruthy();
expect(fetchMock.calls(updateTableSchemaEndpoint)).toHaveLength(1);
expect(fetchMock.calls(getTableMetadataEndpoint)).toHaveLength(1);
expect(fetchMock.calls(getExtraTableMetadataEndpoint)).toHaveLength(
1,
);
// tab state is not updated, since no query was run
expect(fetchMock.calls(updateTabStateEndpoint)).toHaveLength(0);
});
});
it('fetches table schema state from unsaved change', () => {
const database = { disable_data_preview: true };
it('dispatches table state from unsaved change', () => {
const tableName = 'table';
const schemaName = 'schema';
const expectedDbId = 473892;
@ -871,31 +835,47 @@ describe('async actions', () => {
},
},
});
const request = actions.addTable(
query,
database,
tableName,
schemaName,
const request = actions.addTable(query, tableName, schemaName);
request(store.dispatch, store.getState);
expect(store.getActions()[0]).toEqual(
expect.objectContaining({
table: expect.objectContaining({
name: tableName,
schema: schemaName,
dbId: expectedDbId,
}),
}),
);
});
});
describe('syncTable', () => {
it('updates the table schema state in the backend', () => {
expect.assertions(4);
const tableName = 'table';
const schemaName = 'schema';
const store = mockStore(initialState);
const expectedActionTypes = [
actions.MERGE_TABLE, // syncTable
];
const request = actions.syncTable(query, tableName, schemaName);
return request(store.dispatch, store.getState).then(() => {
expect(
fetchMock.calls(
`glob:**/api/v1/database/${expectedDbId}/table/*/*/`,
),
).toHaveLength(1);
expect(
fetchMock.calls(
`glob:**/api/v1/database/${expectedDbId}/table_extra/*/*/`,
),
).toHaveLength(1);
expect(store.getActions().map(a => a.type)).toEqual(
expectedActionTypes,
);
expect(store.getActions()[0].prepend).toBeFalsy();
expect(fetchMock.calls(updateTableSchemaEndpoint)).toHaveLength(1);
// tab state is not updated, since no query was run
expect(fetchMock.calls(updateTabStateEndpoint)).toHaveLength(0);
});
});
});
describe('runTablePreviewQuery', () => {
it('updates and runs data preview query when configured', () => {
expect.assertions(5);
expect.assertions(3);
const results = {
data: mockBigNumber,
@ -906,34 +886,32 @@ describe('async actions', () => {
overwriteRoutes: true,
});
const database = { disable_data_preview: false, id: 1 };
const tableName = 'table';
const schemaName = 'schema';
const store = mockStore(initialState);
const store = mockStore({
...initialState,
sqlLab: {
...initialState.sqlLab,
databases: {
1: { disable_data_preview: false },
},
},
});
const expectedActionTypes = [
actions.MERGE_TABLE, // addTable
actions.MERGE_TABLE, // getTableMetadata
actions.MERGE_TABLE, // getTableExtendedMetadata
actions.MERGE_TABLE, // addTable (data preview)
actions.START_QUERY, // runQuery (data preview)
actions.MERGE_TABLE, // addTable
actions.QUERY_SUCCESS, // querySuccess
];
const request = actions.addTable(
query,
database,
tableName,
schemaName,
);
const request = actions.runTablePreviewQuery({
dbId: 1,
name: tableName,
schema: schemaName,
});
return request(store.dispatch, store.getState).then(() => {
expect(store.getActions().map(a => a.type)).toEqual(
expectedActionTypes,
);
expect(fetchMock.calls(updateTableSchemaEndpoint)).toHaveLength(1);
expect(fetchMock.calls(getTableMetadataEndpoint)).toHaveLength(1);
expect(fetchMock.calls(getExtraTableMetadataEndpoint)).toHaveLength(
1,
);
expect(fetchMock.calls(runQueryEndpoint)).toHaveLength(1);
// tab state is not updated, since the query is a data preview
expect(fetchMock.calls(updateTabStateEndpoint)).toHaveLength(0);
});

View File

@ -51,7 +51,6 @@ const setup = (queryEditor: QueryEditor, store?: Store) =>
queryEditorId={queryEditor.id}
height="100px"
hotkeys={[]}
database={{}}
onChange={jest.fn()}
onBlur={jest.fn()}
autocomplete

View File

@ -55,7 +55,6 @@ type AceEditorWrapperProps = {
onBlur: (sql: string) => void;
onChange: (sql: string) => void;
queryEditorId: string;
database: any;
extendedTables?: Array<{ name: string; columns: any[] }>;
height: string;
hotkeys: HotKey[];
@ -86,7 +85,6 @@ const AceEditorWrapper = ({
onBlur = () => {},
onChange = () => {},
queryEditorId,
database,
extendedTables = [],
height,
hotkeys,
@ -258,9 +256,7 @@ const AceEditorWrapper = ({
const completer = {
insertMatch: (editor: Editor, data: any) => {
if (data.meta === 'table') {
dispatch(
addTable(queryEditor, database, data.value, queryEditor.schema),
);
dispatch(addTable(queryEditor, data.value, queryEditor.schema));
}
let { caption } = data;

View File

@ -666,7 +666,6 @@ const SqlEditor = ({
onBlur={setQueryEditorAndSaveSql}
onChange={onSqlChanged}
queryEditorId={queryEditor.id}
database={database}
extendedTables={tables}
height={`${aceEditorHeight}px`}
hotkeys={hotkeys}

View File

@ -164,9 +164,9 @@ const SqlEditorLeftBar = ({
return true;
});
tablesToAdd.forEach(tableName =>
dispatch(addTable(queryEditor, database, tableName, schemaName)),
);
tablesToAdd.forEach(tableName => {
dispatch(addTable(queryEditor, tableName, schemaName));
});
dispatch(removeTables(currentTables));
};

View File

@ -1,161 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import { mount } from 'enzyme';
import { Provider } from 'react-redux';
import configureStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import { supersetTheme, ThemeProvider } from '@superset-ui/core';
import Collapse from 'src/components/Collapse';
import { IconTooltip } from 'src/components/IconTooltip';
import TableElement from 'src/SqlLab/components/TableElement';
import ColumnElement from 'src/SqlLab/components/ColumnElement';
import waitForComponentToPaint from 'spec/helpers/waitForComponentToPaint';
import { initialState, table } from 'src/SqlLab/fixtures';
const middlewares = [thunk];
const mockStore = configureStore(middlewares);
describe('TableElement', () => {
const store = mockStore(initialState);
const mockedProps = {
table,
timeout: 0,
};
it('renders', () => {
expect(React.isValidElement(<TableElement />)).toBe(true);
});
it('renders with props', () => {
expect(React.isValidElement(<TableElement {...mockedProps} />)).toBe(true);
});
it('has 5 IconTooltip elements', () => {
const wrapper = mount(
<Provider store={store}>
<TableElement {...mockedProps} />
</Provider>,
{
wrappingComponent: ThemeProvider,
wrappingComponentProps: {
theme: supersetTheme,
},
},
);
expect(wrapper.find(IconTooltip)).toHaveLength(4);
});
it('has 14 columns', () => {
const wrapper = mount(
<Provider store={store}>
<TableElement {...mockedProps} />
</Provider>,
{
wrappingComponent: ThemeProvider,
wrappingComponentProps: {
theme: supersetTheme,
},
},
);
expect(wrapper.find(ColumnElement)).toHaveLength(14);
});
it('mounts', () => {
const wrapper = mount(
<Provider store={store}>
<TableElement {...mockedProps} />
</Provider>,
{
wrappingComponent: ThemeProvider,
wrappingComponentProps: {
theme: supersetTheme,
},
},
);
expect(wrapper.find(TableElement)).toHaveLength(1);
});
it('fades table', async () => {
const wrapper = mount(
<Provider store={store}>
<TableElement {...mockedProps} />
</Provider>,
{
wrappingComponent: ThemeProvider,
wrappingComponentProps: {
theme: supersetTheme,
},
},
);
expect(wrapper.find('[data-test="fade"]').first().props().hovered).toBe(
false,
);
wrapper.find('.header-container').hostNodes().simulate('mouseEnter');
await waitForComponentToPaint(wrapper, 300);
expect(wrapper.find('[data-test="fade"]').first().props().hovered).toBe(
true,
);
});
it('sorts columns', () => {
const wrapper = mount(
<Provider store={store}>
<Collapse>
<TableElement {...mockedProps} />
</Collapse>
</Provider>,
{
wrappingComponent: ThemeProvider,
wrappingComponentProps: {
theme: supersetTheme,
},
},
);
expect(
wrapper.find(IconTooltip).at(1).hasClass('fa-sort-alpha-asc'),
).toEqual(true);
expect(
wrapper.find(IconTooltip).at(1).hasClass('fa-sort-numeric-asc'),
).toEqual(false);
wrapper.find('.header-container').hostNodes().simulate('click');
expect(wrapper.find(ColumnElement).first().props().column.name).toBe('id');
wrapper.find('.header-container').simulate('mouseEnter');
wrapper.find('.sort-cols').hostNodes().simulate('click');
expect(
wrapper.find(IconTooltip).at(1).hasClass('fa-sort-numeric-asc'),
).toEqual(true);
expect(
wrapper.find(IconTooltip).at(1).hasClass('fa-sort-alpha-asc'),
).toEqual(false);
expect(wrapper.find(ColumnElement).first().props().column.name).toBe(
'active',
);
});
it('removes the table', () => {
const wrapper = mount(
<Provider store={store}>
<TableElement {...mockedProps} />
</Provider>,
{
wrappingComponent: ThemeProvider,
wrappingComponentProps: {
theme: supersetTheme,
},
},
);
wrapper.find('.table-remove').hostNodes().simulate('click');
expect(store.getActions()).toHaveLength(1);
expect(store.getActions()[0].type).toEqual('REMOVE_DATA_PREVIEW');
});
});

View File

@ -0,0 +1,177 @@
/**
* 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 React from 'react';
import fetchMock from 'fetch-mock';
import * as featureFlags from 'src/featureFlags';
import { FeatureFlag } from '@superset-ui/core';
import TableElement, { Column } from 'src/SqlLab/components/TableElement';
import { table, initialState } from 'src/SqlLab/fixtures';
import { render, waitFor, fireEvent } from 'spec/helpers/testing-library';
jest.mock('src/components/Loading', () => () => (
<div data-test="mock-loading" />
));
jest.mock('src/components/IconTooltip', () => ({
IconTooltip: ({
onClick,
tooltip,
}: {
onClick: () => void;
tooltip: string;
}) => (
<button type="button" data-test="mock-icon-tooltip" onClick={onClick}>
{tooltip}
</button>
),
}));
jest.mock(
'src/SqlLab/components/ColumnElement',
() =>
({ column }: { column: Column }) =>
<div data-test="mock-column-element">{column.name}</div>,
);
const getTableMetadataEndpoint = 'glob:**/api/v1/database/*/table/*/*/';
const getExtraTableMetadataEndpoint =
'glob:**/api/v1/database/*/table_extra/*/*/';
const updateTableSchemaEndpoint = 'glob:*/tableschemaview/*/expanded';
beforeEach(() => {
fetchMock.get(getTableMetadataEndpoint, table);
fetchMock.get(getExtraTableMetadataEndpoint, {});
fetchMock.post(updateTableSchemaEndpoint, {});
});
afterEach(() => {
fetchMock.reset();
});
const mockedProps = {
table: {
...table,
initialized: true,
},
};
test('renders', () => {
expect(React.isValidElement(<TableElement table={table} />)).toBe(true);
});
test('renders with props', () => {
expect(React.isValidElement(<TableElement {...mockedProps} />)).toBe(true);
});
test('has 4 IconTooltip elements', async () => {
const { getAllByTestId } = render(<TableElement {...mockedProps} />, {
useRedux: true,
initialState,
});
await waitFor(() =>
expect(getAllByTestId('mock-icon-tooltip')).toHaveLength(4),
);
});
test('has 14 columns', async () => {
const { getAllByTestId } = render(<TableElement {...mockedProps} />, {
useRedux: true,
initialState,
});
await waitFor(() =>
expect(getAllByTestId('mock-column-element')).toHaveLength(14),
);
});
test('fades table', async () => {
const { getAllByTestId } = render(<TableElement {...mockedProps} />, {
useRedux: true,
initialState,
});
await waitFor(() =>
expect(getAllByTestId('mock-icon-tooltip')).toHaveLength(4),
);
const style = window.getComputedStyle(getAllByTestId('fade')[0]);
expect(style.opacity).toBe('0');
fireEvent.mouseEnter(getAllByTestId('table-element-header-container')[0]);
await waitFor(() =>
expect(window.getComputedStyle(getAllByTestId('fade')[0]).opacity).toBe(
'1',
),
);
});
test('sorts columns', async () => {
const { getAllByTestId, getByText } = render(
<TableElement {...mockedProps} />,
{
useRedux: true,
initialState,
},
);
await waitFor(() =>
expect(getAllByTestId('mock-icon-tooltip')).toHaveLength(4),
);
expect(
getAllByTestId('mock-column-element').map(el => el.textContent),
).toEqual(table.columns.map(col => col.name));
fireEvent.click(getByText('Sort columns alphabetically'));
const sorted = [...table.columns.map(col => col.name)].sort();
expect(
getAllByTestId('mock-column-element').map(el => el.textContent),
).toEqual(sorted);
expect(getAllByTestId('mock-column-element')[0]).toHaveTextContent('active');
});
test('removes the table', async () => {
const updateTableSchemaEndpoint = 'glob:*/tableschemaview/*';
fetchMock.delete(updateTableSchemaEndpoint, {});
const isFeatureEnabledMock = jest
.spyOn(featureFlags, 'isFeatureEnabled')
.mockImplementation(
featureFlag => featureFlag === FeatureFlag.SQLLAB_BACKEND_PERSISTENCE,
);
const { getAllByTestId, getByText } = render(
<TableElement {...mockedProps} />,
{
useRedux: true,
initialState,
},
);
await waitFor(() =>
expect(getAllByTestId('mock-icon-tooltip')).toHaveLength(4),
);
expect(fetchMock.calls(updateTableSchemaEndpoint)).toHaveLength(0);
fireEvent.click(getByText('Remove table preview'));
await waitFor(() =>
expect(fetchMock.calls(updateTableSchemaEndpoint)).toHaveLength(1),
);
isFeatureEnabledMock.mockClear();
});
test('fetches table metadata when expanded', async () => {
render(<TableElement {...mockedProps} />, {
useRedux: true,
initialState,
});
expect(fetchMock.calls(getTableMetadataEndpoint)).toHaveLength(0);
expect(fetchMock.calls(getExtraTableMetadataEndpoint)).toHaveLength(0);
await waitFor(() =>
expect(fetchMock.calls(getTableMetadataEndpoint)).toHaveLength(1),
);
expect(fetchMock.calls(updateTableSchemaEndpoint)).toHaveLength(0);
expect(fetchMock.calls(getExtraTableMetadataEndpoint)).toHaveLength(1);
});

View File

@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import React, { useState, useRef } from 'react';
import React, { useState, useRef, useEffect } from 'react';
import { useDispatch } from 'react-redux';
import Collapse from 'src/components/Collapse';
import Card from 'src/components/Card';
@ -24,16 +24,26 @@ import ButtonGroup from 'src/components/ButtonGroup';
import { css, t, styled } from '@superset-ui/core';
import { debounce } from 'lodash';
import { removeDataPreview, removeTables } from 'src/SqlLab/actions/sqlLab';
import {
removeDataPreview,
removeTables,
addDangerToast,
syncTable,
} from 'src/SqlLab/actions/sqlLab';
import {
useTableExtendedMetadataQuery,
useTableMetadataQuery,
} from 'src/hooks/apiResources';
import { Tooltip } from 'src/components/Tooltip';
import CopyToClipboard from 'src/components/CopyToClipboard';
import { IconTooltip } from 'src/components/IconTooltip';
import ModalTrigger from 'src/components/ModalTrigger';
import Loading from 'src/components/Loading';
import useEffectEvent from 'src/hooks/useEffectEvent';
import ColumnElement, { ColumnKeyTypeType } from '../ColumnElement';
import ShowSQL from '../ShowSQL';
interface Column {
export interface Column {
name: string;
keys?: { type: ColumnKeyTypeType }[];
type: string;
@ -41,18 +51,12 @@ interface Column {
export interface Table {
id: string;
dbId: number;
schema: string;
name: string;
partitions?: {
partitionQuery: string;
latest: object[];
};
metadata?: Record<string, string>;
indexes?: object[];
selectStar?: string;
view?: string;
isMetadataLoading: boolean;
isExtraMetadataLoading: boolean;
columns: Column[];
dataPreviewQueryId?: string | null;
expanded?: boolean;
initialized?: boolean;
}
export interface TableElementProps {
@ -106,7 +110,61 @@ const StyledCollapsePanel = styled(Collapse.Panel)`
`;
const TableElement = ({ table, ...props }: TableElementProps) => {
const { dbId, schema, name, expanded } = table;
const dispatch = useDispatch();
const {
data: tableMetadata,
isSuccess: isMetadataSuccess,
isLoading: isMetadataLoading,
isError: hasMetadataError,
} = useTableMetadataQuery(
{
dbId,
schema,
table: name,
},
{ skip: !expanded },
);
const {
data: tableExtendedMetadata,
isSuccess: isExtraMetadataSuccess,
isLoading: isExtraMetadataLoading,
isError: hasExtendedMetadataError,
} = useTableExtendedMetadataQuery(
{
dbId,
schema,
table: name,
},
{ skip: !expanded },
);
useEffect(() => {
if (hasMetadataError || hasExtendedMetadataError) {
dispatch(
addDangerToast(t('An error occurred while fetching table metadata')),
);
}
}, [hasMetadataError, hasExtendedMetadataError, dispatch]);
const tableData = {
...tableMetadata,
...tableExtendedMetadata,
};
// TODO: migrate syncTable logic by SIP-93
const syncTableMetadata = useEffectEvent(() => {
const { initialized } = table;
if (!initialized) {
dispatch(syncTable(table, tableData));
}
});
useEffect(() => {
if (isMetadataSuccess && isExtraMetadataSuccess) {
syncTableMetadata();
}
}, [isMetadataSuccess, isExtraMetadataSuccess, syncTableMetadata]);
const [sortColumns, setSortColumns] = useState(false);
const [hovered, setHovered] = useState(false);
@ -128,11 +186,11 @@ const TableElement = ({ table, ...props }: TableElementProps) => {
const renderWell = () => {
let partitions;
let metadata;
if (table.partitions) {
if (tableData.partitions) {
let partitionQuery;
let partitionClipBoard;
if (table.partitions.partitionQuery) {
({ partitionQuery } = table.partitions);
if (tableData.partitions.partitionQuery) {
({ partitionQuery } = tableData.partitions);
const tt = t('Copy partition query to clipboard');
partitionClipBoard = (
<CopyToClipboard
@ -143,7 +201,7 @@ const TableElement = ({ table, ...props }: TableElementProps) => {
/>
);
}
const latest = Object.entries(table.partitions?.latest || [])
const latest = Object.entries(tableData.partitions?.latest || [])
.map(([key, value]) => `${key}=${value}`)
.join('/');
@ -157,8 +215,8 @@ const TableElement = ({ table, ...props }: TableElementProps) => {
);
}
if (table.metadata) {
metadata = Object.entries(table.metadata).map(([key, value]) => (
if (tableData.metadata) {
metadata = Object.entries(tableData.metadata).map(([key, value]) => (
<div>
<small>
<strong>{key}:</strong> {value}
@ -183,17 +241,17 @@ const TableElement = ({ table, ...props }: TableElementProps) => {
const renderControls = () => {
let keyLink;
const KEYS_FOR_TABLE_TEXT = t('Keys for table');
if (table?.indexes?.length) {
if (tableData?.indexes?.length) {
keyLink = (
<ModalTrigger
modalTitle={`${KEYS_FOR_TABLE_TEXT} ${table.name}`}
modalBody={table.indexes.map((ix, i) => (
modalTitle={`${KEYS_FOR_TABLE_TEXT} ${name}`}
modalBody={tableData.indexes.map((ix, i) => (
<pre key={i}>{JSON.stringify(ix, null, ' ')}</pre>
))}
triggerNode={
<IconTooltip
className="fa fa-key pull-left m-l-2"
tooltip={t('View keys & indexes (%s)', table.indexes.length)}
tooltip={t('View keys & indexes (%s)', tableData.indexes.length)}
/>
}
/>
@ -214,7 +272,7 @@ const TableElement = ({ table, ...props }: TableElementProps) => {
: t('Sort columns alphabetically')
}
/>
{table.selectStar && (
{tableData.selectStar && (
<CopyToClipboard
copyNode={
<IconTooltip
@ -224,13 +282,13 @@ const TableElement = ({ table, ...props }: TableElementProps) => {
<i aria-hidden className="fa fa-clipboard pull-left m-l-2" />
</IconTooltip>
}
text={table.selectStar}
text={tableData.selectStar}
shouldShowText={false}
/>
)}
{table.view && (
{tableData.view && (
<ShowSQL
sql={table.view}
sql={tableData.view}
tooltipText={t('Show CREATE VIEW statement')}
title={t('CREATE VIEW statement')}
/>
@ -253,6 +311,7 @@ const TableElement = ({ table, ...props }: TableElementProps) => {
return (
<div
data-test="table-element-header-container"
className="clearfix header-container"
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
@ -260,7 +319,7 @@ const TableElement = ({ table, ...props }: TableElementProps) => {
<Tooltip
id="copy-to-clipboard-tooltip"
style={{ cursor: 'pointer' }}
title={table.name}
title={name}
trigger={trigger}
>
<StyledSpan
@ -268,12 +327,12 @@ const TableElement = ({ table, ...props }: TableElementProps) => {
ref={tableNameRef}
className="table-name"
>
<strong>{table.name}</strong>
<strong>{name}</strong>
</StyledSpan>
</Tooltip>
<div className="pull-right header-right-side">
{table.isMetadataLoading || table.isExtraMetadataLoading ? (
{isMetadataLoading || isExtraMetadataLoading ? (
<Loading position="inline" />
) : (
<Fade
@ -291,8 +350,8 @@ const TableElement = ({ table, ...props }: TableElementProps) => {
const renderBody = () => {
let cols;
if (table.columns) {
cols = table.columns.slice();
if (tableData.columns) {
cols = tableData.columns.slice();
if (sortColumns) {
cols.sort((a: Column, b: Column) => {
const colA = a.name.toUpperCase();

View File

@ -116,16 +116,7 @@ export default function getInitialState({
activeTab.table_schemas
.filter(tableSchema => tableSchema.description !== null)
.forEach(tableSchema => {
const {
columns,
selectStar,
primaryKey,
foreignKeys,
indexes,
dataPreviewQueryId,
partitions,
metadata,
} = tableSchema.description;
const { dataPreviewQueryId, ...persistData } = tableSchema.description;
const table = {
dbId: tableSchema.database_id,
queryEditorId: tableSchema.tab_state_id.toString(),
@ -133,16 +124,9 @@ export default function getInitialState({
name: tableSchema.table,
expanded: tableSchema.expanded,
id: tableSchema.id,
isMetadataLoading: false,
isExtraMetadataLoading: false,
dataPreviewQueryId,
columns,
selectStar,
primaryKey,
foreignKeys,
indexes,
partitions,
metadata,
persistData,
initialized: true,
};
tables = {
...tables,
@ -184,16 +168,21 @@ export default function getInitialState({
},
};
});
tables = sqlLab.tables.reduce(
(merged, table) => ({
const expandedTables = new Set();
tables = sqlLab.tables.reduce((merged, table) => {
const expanded = !expandedTables.has(table.queryEditorId);
if (expanded) {
expandedTables.add(table.queryEditorId);
}
return {
...merged,
[table.id]: {
...tables[table.id],
...table,
expanded,
},
}),
tables,
);
};
}, tables);
Object.values(sqlLab.queries).forEach(query => {
queries[query.id] = { ...query, inLocalStorage: true };
});

View File

@ -16,6 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import pick from 'lodash/pick';
import {
BYTES_PER_CHAR,
KB_STORAGE,
@ -50,6 +51,21 @@ function shouldEmptyQueryResults(query) {
);
}
export function emptyTablePersistData(tables) {
return tables
.map(table =>
pick(table, [
'id',
'name',
'dbId',
'schema',
'dataPreviewQueryId',
'queryEditorId',
]),
)
.filter(({ queryEditorId }) => Boolean(queryEditorId));
}
export function emptyQueryResults(queries) {
return Object.keys(queries).reduce((accu, key) => {
const { results } = queries[key];

View File

@ -65,7 +65,13 @@ export const supersetClientQuery: BaseQueryFn<
export const api = createApi({
reducerPath: 'queryApi',
tagTypes: ['Schemas', 'Tables', 'DatabaseFunctions', 'QueryValidations'],
tagTypes: [
'Schemas',
'Tables',
'DatabaseFunctions',
'QueryValidations',
'TableMetadatas',
],
endpoints: () => ({}),
baseQuery: supersetClientQuery,
});

View File

@ -18,7 +18,7 @@
*/
import { useCallback, useMemo, useEffect, useRef } from 'react';
import useEffectEvent from 'src/hooks/useEffectEvent';
import { api } from './queryApi';
import { api, JsonResponse } from './queryApi';
import { useSchemas } from './schemas';
@ -56,6 +56,39 @@ export type FetchTablesQueryParams = {
onError?: (error: Response) => void;
};
export type FetchTableMetadataQueryParams = {
dbId: string | number;
schema: string;
table: string;
};
type ColumnKeyTypeType = 'pk' | 'fk' | 'index';
interface Column {
name: string;
keys?: { type: ColumnKeyTypeType }[];
type: string;
}
export type TableMetaData = {
name: string;
partitions?: {
partitionQuery: string;
latest: object[];
};
metadata?: Record<string, string>;
indexes?: object[];
selectStar?: string;
view?: string;
columns: Column[];
};
type TableMetadataReponse = {
json: TableMetaData;
response: Response;
};
export type TableExtendedMetadata = Record<string, string>;
type Params = Omit<FetchTablesQueryParams, 'forceRefresh'>;
const tableApi = api.injectEndpoints({
@ -79,10 +112,34 @@ const tableApi = api.injectEndpoints({
schema,
}),
}),
tableMetadata: builder.query<TableMetaData, FetchTableMetadataQueryParams>({
query: ({ dbId, schema, table }) => ({
endpoint: `/api/v1/database/${dbId}/table/${encodeURIComponent(
table,
)}/${encodeURIComponent(schema)}/`,
transformResponse: ({ json }: TableMetadataReponse) => json,
}),
}),
tableExtendedMetadata: builder.query<
TableExtendedMetadata,
FetchTableMetadataQueryParams
>({
query: ({ dbId, schema, table }) => ({
endpoint: `/api/v1/database/${dbId}/table_extra/${encodeURIComponent(
table,
)}/${encodeURIComponent(schema)}/`,
transformResponse: ({ json }: JsonResponse) => json,
}),
}),
}),
});
export const { useLazyTablesQuery, useTablesQuery } = tableApi;
export const {
useLazyTablesQuery,
useTablesQuery,
useTableMetadataQuery,
useTableExtendedMetadataQuery,
} = tableApi;
export function useTables(options: Params) {
const isMountedRef = useRef(false);