You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ju...@apache.org on 2023/06/22 22:37:14 UTC

[superset] branch master updated: chore(sqllab): Remove table metadata from state (#24371)

This is an automated email from the ASF dual-hosted git repository.

justinpark pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git


The following commit(s) were added to refs/heads/master by this push:
     new 51a34d7d58 chore(sqllab): Remove table metadata from state (#24371)
51a34d7d58 is described below

commit 51a34d7d58a8c110d403e22db30a799a68a81ed5
Author: JUST.in DO IT <ju...@airbnb.com>
AuthorDate: Thu Jun 22 15:37:03 2023 -0700

    chore(sqllab): Remove table metadata from state (#24371)
---
 superset-frontend/src/SqlLab/App.jsx               |  25 +++
 superset-frontend/src/SqlLab/actions/sqlLab.js     | 183 +++++++++------------
 .../src/SqlLab/actions/sqlLab.test.js              | 118 ++++++-------
 .../AceEditorWrapper/AceEditorWrapper.test.tsx     |   1 -
 .../SqlLab/components/AceEditorWrapper/index.tsx   |   6 +-
 .../src/SqlLab/components/SqlEditor/index.jsx      |   1 -
 .../SqlLab/components/SqlEditorLeftBar/index.tsx   |   6 +-
 .../components/TableElement/TableElement.test.jsx  | 161 ------------------
 .../components/TableElement/TableElement.test.tsx  | 177 ++++++++++++++++++++
 .../src/SqlLab/components/TableElement/index.tsx   | 125 ++++++++++----
 .../src/SqlLab/reducers/getInitialState.js         |  37 ++---
 .../SqlLab/utils/reduxStateToLocalStorageHelper.js |  16 ++
 .../src/hooks/apiResources/queryApi.ts             |   8 +-
 superset-frontend/src/hooks/apiResources/tables.ts |  61 ++++++-
 14 files changed, 515 insertions(+), 410 deletions(-)

diff --git a/superset-frontend/src/SqlLab/App.jsx b/superset-frontend/src/SqlLab/App.jsx
index be0a982a49..dbbab0d184 100644
--- a/superset-frontend/src/SqlLab/App.jsx
+++ b/superset-frontend/src/SqlLab/App.jsx
@@ -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
diff --git a/superset-frontend/src/SqlLab/actions/sqlLab.js b/superset-frontend/src/SqlLab/actions/sqlLab.js
index 8b908d2666..addad284e7 100644
--- a/superset-frontend/src/SqlLab/actions/sqlLab.js
+++ b/superset-frontend/src/SqlLab/actions/sqlLab.js
@@ -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() } });
-
-      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,
-            ),
+export function runTablePreviewQuery(newTable) {
+  return function (dispatch, getState) {
+    const {
+      sqlLab: { databases },
+    } = getState();
+    const database = databases[newTable.dbId];
+    const { dbId } = newTable;
+
+    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.',
             ),
           ),
-        );
-    });
+        ),
+      );
   };
 }
 
diff --git a/superset-frontend/src/SqlLab/actions/sqlLab.test.js b/superset-frontend/src/SqlLab/actions/sqlLab.test.js
index 01886d6f77..2f26ec16d5 100644
--- a/superset-frontend/src/SqlLab/actions/sqlLab.test.js
+++ b/superset-frontend/src/SqlLab/actions/sqlLab.test.js
@@ -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);
         });
diff --git a/superset-frontend/src/SqlLab/components/AceEditorWrapper/AceEditorWrapper.test.tsx b/superset-frontend/src/SqlLab/components/AceEditorWrapper/AceEditorWrapper.test.tsx
index 7638003e90..c69b92346d 100644
--- a/superset-frontend/src/SqlLab/components/AceEditorWrapper/AceEditorWrapper.test.tsx
+++ b/superset-frontend/src/SqlLab/components/AceEditorWrapper/AceEditorWrapper.test.tsx
@@ -51,7 +51,6 @@ const setup = (queryEditor: QueryEditor, store?: Store) =>
       queryEditorId={queryEditor.id}
       height="100px"
       hotkeys={[]}
-      database={{}}
       onChange={jest.fn()}
       onBlur={jest.fn()}
       autocomplete
diff --git a/superset-frontend/src/SqlLab/components/AceEditorWrapper/index.tsx b/superset-frontend/src/SqlLab/components/AceEditorWrapper/index.tsx
index 5f7d92e943..1db1cc5d9b 100644
--- a/superset-frontend/src/SqlLab/components/AceEditorWrapper/index.tsx
+++ b/superset-frontend/src/SqlLab/components/AceEditorWrapper/index.tsx
@@ -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;
diff --git a/superset-frontend/src/SqlLab/components/SqlEditor/index.jsx b/superset-frontend/src/SqlLab/components/SqlEditor/index.jsx
index 6399baa1cd..b07c31fd75 100644
--- a/superset-frontend/src/SqlLab/components/SqlEditor/index.jsx
+++ b/superset-frontend/src/SqlLab/components/SqlEditor/index.jsx
@@ -666,7 +666,6 @@ const SqlEditor = ({
             onBlur={setQueryEditorAndSaveSql}
             onChange={onSqlChanged}
             queryEditorId={queryEditor.id}
-            database={database}
             extendedTables={tables}
             height={`${aceEditorHeight}px`}
             hotkeys={hotkeys}
diff --git a/superset-frontend/src/SqlLab/components/SqlEditorLeftBar/index.tsx b/superset-frontend/src/SqlLab/components/SqlEditorLeftBar/index.tsx
index 453d80f112..e89bdc57b7 100644
--- a/superset-frontend/src/SqlLab/components/SqlEditorLeftBar/index.tsx
+++ b/superset-frontend/src/SqlLab/components/SqlEditorLeftBar/index.tsx
@@ -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));
   };
diff --git a/superset-frontend/src/SqlLab/components/TableElement/TableElement.test.jsx b/superset-frontend/src/SqlLab/components/TableElement/TableElement.test.jsx
deleted file mode 100644
index 60efa3a596..0000000000
--- a/superset-frontend/src/SqlLab/components/TableElement/TableElement.test.jsx
+++ /dev/null
@@ -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');
-  });
-});
diff --git a/superset-frontend/src/SqlLab/components/TableElement/TableElement.test.tsx b/superset-frontend/src/SqlLab/components/TableElement/TableElement.test.tsx
new file mode 100644
index 0000000000..89c98cfd33
--- /dev/null
+++ b/superset-frontend/src/SqlLab/components/TableElement/TableElement.test.tsx
@@ -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);
+});
diff --git a/superset-frontend/src/SqlLab/components/TableElement/index.tsx b/superset-frontend/src/SqlLab/components/TableElement/index.tsx
index 0f1140f9e8..147422b3c0 100644
--- a/superset-frontend/src/SqlLab/components/TableElement/index.tsx
+++ b/superset-frontend/src/SqlLab/components/TableElement/index.tsx
@@ -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();
diff --git a/superset-frontend/src/SqlLab/reducers/getInitialState.js b/superset-frontend/src/SqlLab/reducers/getInitialState.js
index 03d6ec5524..edb778fcfa 100644
--- a/superset-frontend/src/SqlLab/reducers/getInitialState.js
+++ b/superset-frontend/src/SqlLab/reducers/getInitialState.js
@@ -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 };
       });
diff --git a/superset-frontend/src/SqlLab/utils/reduxStateToLocalStorageHelper.js b/superset-frontend/src/SqlLab/utils/reduxStateToLocalStorageHelper.js
index d03354f166..2fb1da1783 100644
--- a/superset-frontend/src/SqlLab/utils/reduxStateToLocalStorageHelper.js
+++ b/superset-frontend/src/SqlLab/utils/reduxStateToLocalStorageHelper.js
@@ -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];
diff --git a/superset-frontend/src/hooks/apiResources/queryApi.ts b/superset-frontend/src/hooks/apiResources/queryApi.ts
index 159e6095f1..2881fc2252 100644
--- a/superset-frontend/src/hooks/apiResources/queryApi.ts
+++ b/superset-frontend/src/hooks/apiResources/queryApi.ts
@@ -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,
 });
diff --git a/superset-frontend/src/hooks/apiResources/tables.ts b/superset-frontend/src/hooks/apiResources/tables.ts
index 5d28cba488..2ac1fe566a 100644
--- a/superset-frontend/src/hooks/apiResources/tables.ts
+++ b/superset-frontend/src/hooks/apiResources/tables.ts
@@ -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);