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/07/26 18:24:34 UTC

[superset] branch master updated: fix(sqllab): Replace autocomplete logic by a hook (#24677)

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 77505173ae fix(sqllab): Replace autocomplete logic by a hook (#24677)
77505173ae is described below

commit 77505173ae6f202b29cb782cb0ea989932fa9713
Author: JUST.in DO IT <ju...@airbnb.com>
AuthorDate: Wed Jul 26 11:24:28 2023 -0700

    fix(sqllab): Replace autocomplete logic by a hook (#24677)
---
 superset-frontend/spec/helpers/testing-library.tsx |   2 +-
 superset-frontend/src/SqlLab/App.jsx               |   6 +-
 .../SqlLab/components/AceEditorWrapper/index.tsx   | 174 ++-----------
 .../AceEditorWrapper/useKeywords.test.ts           | 269 +++++++++++++++++++++
 .../components/AceEditorWrapper/useKeywords.ts     | 208 ++++++++++++++++
 .../SqlLab/components/SqlEditor/SqlEditor.test.jsx |   3 +
 .../src/components/AsyncAceEditor/index.tsx        |  55 +++--
 .../src/hooks/apiResources/schemas.ts              |   9 +-
 superset-frontend/src/hooks/apiResources/tables.ts |   1 +
 9 files changed, 541 insertions(+), 186 deletions(-)

diff --git a/superset-frontend/spec/helpers/testing-library.tsx b/superset-frontend/spec/helpers/testing-library.tsx
index eb3e1401ed..9a8649a862 100644
--- a/superset-frontend/spec/helpers/testing-library.tsx
+++ b/superset-frontend/spec/helpers/testing-library.tsx
@@ -39,7 +39,7 @@ type Options = Omit<RenderOptions, 'queries'> & {
   store?: Store;
 };
 
-const createStore = (initialState: object = {}, reducers: object = {}) =>
+export const createStore = (initialState: object = {}, reducers: object = {}) =>
   configureStore({
     preloadedState: initialState,
     reducer: {
diff --git a/superset-frontend/src/SqlLab/App.jsx b/superset-frontend/src/SqlLab/App.jsx
index dbbab0d184..deaf177265 100644
--- a/superset-frontend/src/SqlLab/App.jsx
+++ b/superset-frontend/src/SqlLab/App.jsx
@@ -26,7 +26,7 @@ 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 { tableApiUtil } from 'src/hooks/apiResources/tables';
 import getInitialState from './reducers/getInitialState';
 import { reducers } from './reducers/index';
 import App from './components/App';
@@ -127,14 +127,14 @@ initialState.sqlLab.tables.forEach(
   ({ name: table, schema, dbId, persistData }) => {
     if (dbId && schema && table && persistData?.columns) {
       store.dispatch(
-        api.util.upsertQueryData(
+        tableApiUtil.upsertQueryData(
           'tableMetadata',
           { dbId, schema, table },
           persistData,
         ),
       );
       store.dispatch(
-        api.util.upsertQueryData(
+        tableApiUtil.upsertQueryData(
           'tableExtendedMetadata',
           { dbId, schema, table },
           {},
diff --git a/superset-frontend/src/SqlLab/components/AceEditorWrapper/index.tsx b/superset-frontend/src/SqlLab/components/AceEditorWrapper/index.tsx
index d14c848f59..0adf3f8e1f 100644
--- a/superset-frontend/src/SqlLab/components/AceEditorWrapper/index.tsx
+++ b/superset-frontend/src/SqlLab/components/AceEditorWrapper/index.tsx
@@ -16,38 +16,15 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import React, { useState, useEffect, useRef, useMemo } from 'react';
-import { shallowEqual, useDispatch, useSelector } from 'react-redux';
-import { css, styled, usePrevious, t } from '@superset-ui/core';
+import React, { useState, useEffect, useRef } from 'react';
+import { useDispatch } from 'react-redux';
+import { css, styled, usePrevious } from '@superset-ui/core';
 
-import { areArraysShallowEqual } from 'src/reduxUtils';
-import sqlKeywords from 'src/SqlLab/utils/sqlKeywords';
-import {
-  queryEditorSetSelectedText,
-  addTable,
-  addDangerToast,
-} from 'src/SqlLab/actions/sqlLab';
-import {
-  SCHEMA_AUTOCOMPLETE_SCORE,
-  TABLE_AUTOCOMPLETE_SCORE,
-  COLUMN_AUTOCOMPLETE_SCORE,
-  SQL_FUNCTIONS_AUTOCOMPLETE_SCORE,
-} from 'src/SqlLab/constants';
-import {
-  Editor,
-  AceCompleterKeyword,
-  FullSQLEditor as AceEditor,
-} from 'src/components/AsyncAceEditor';
+import { queryEditorSetSelectedText } from 'src/SqlLab/actions/sqlLab';
+import { FullSQLEditor as AceEditor } from 'src/components/AsyncAceEditor';
 import useQueryEditor from 'src/SqlLab/hooks/useQueryEditor';
-import {
-  useSchemas,
-  useTables,
-  tableEndpoints,
-  skipToken,
-} from 'src/hooks/apiResources';
-import { useDatabaseFunctionsQuery } from 'src/hooks/apiResources/databaseFunctions';
-import { RootState } from 'src/views/store';
 import { useAnnotations } from './useAnnotations';
+import { useKeywords } from './useKeywords';
 
 type HotKey = {
   key: string;
@@ -101,68 +78,10 @@ const AceEditorWrapper = ({
     'schema',
     'templateParams',
   ]);
-  const { data: schemaOptions } = useSchemas({
-    ...(autocomplete && { dbId: queryEditor.dbId }),
-  });
-  const { data: tableData } = useTables({
-    ...(autocomplete && {
-      dbId: queryEditor.dbId,
-      schema: queryEditor.schema,
-    }),
-  });
-
-  const { data: functionNames, isError } = useDatabaseFunctionsQuery(
-    { dbId: queryEditor.dbId },
-    { skip: !autocomplete || !queryEditor.dbId },
-  );
-
-  useEffect(() => {
-    if (isError) {
-      dispatch(
-        addDangerToast(t('An error occurred while fetching function names.')),
-      );
-    }
-  }, [dispatch, isError]);
 
   const currentSql = queryEditor.sql ?? '';
 
-  // Loading schema, table and column names as auto-completable words
-  const { schemas, schemaWords } = useMemo(
-    () => ({
-      schemas: schemaOptions ?? [],
-      schemaWords: (schemaOptions ?? []).map(s => ({
-        name: s.label,
-        value: s.value,
-        score: SCHEMA_AUTOCOMPLETE_SCORE,
-        meta: 'schema',
-      })),
-    }),
-    [schemaOptions],
-  );
-  const tables = tableData?.options ?? [];
-
-  const columns = useSelector<RootState, string[]>(state => {
-    const columns = new Set<string>();
-    tables.forEach(({ value }) => {
-      tableEndpoints.tableMetadata
-        .select(
-          queryEditor.dbId && queryEditor.schema
-            ? {
-                dbId: queryEditor.dbId,
-                schema: queryEditor.schema,
-                table: value,
-              }
-            : skipToken,
-        )(state)
-        .data?.columns?.forEach(({ name }) => {
-          columns.add(name);
-        });
-    });
-    return [...columns];
-  }, shallowEqual);
-
   const [sql, setSql] = useState(currentSql);
-  const [words, setWords] = useState<AceCompleterKeyword[]>([]);
 
   // The editor changeSelection is called multiple times in a row,
   // faster than React reconciliation process, so the selected text
@@ -173,24 +92,10 @@ const AceEditorWrapper = ({
   useEffect(() => {
     // Making sure no text is selected from previous mount
     dispatch(queryEditorSetSelectedText(queryEditor, null));
-    setAutoCompleter();
   }, []);
 
-  const prevTables = usePrevious(tables) ?? [];
-  const prevSchemas = usePrevious(schemas) ?? [];
-  const prevColumns = usePrevious(columns) ?? [];
   const prevSql = usePrevious(currentSql);
 
-  useEffect(() => {
-    if (
-      !areArraysShallowEqual(tables, prevTables) ||
-      !areArraysShallowEqual(schemas, prevSchemas) ||
-      !areArraysShallowEqual(columns, prevColumns)
-    ) {
-      setAutoCompleter();
-    }
-  }, [tables, schemas, columns]);
-
   useEffect(() => {
     if (currentSql !== prevSql) {
       setSql(currentSql);
@@ -243,62 +148,6 @@ const AceEditorWrapper = ({
     onChange(text);
   };
 
-  function setAutoCompleter() {
-    const tableWords = tables.map(t => {
-      const tableName = t.value;
-
-      return {
-        name: t.label,
-        value: tableName,
-        score: TABLE_AUTOCOMPLETE_SCORE,
-        meta: 'table',
-      };
-    });
-
-    const columnWords = columns.map(col => ({
-      name: col,
-      value: col,
-      score: COLUMN_AUTOCOMPLETE_SCORE,
-      meta: 'column',
-    }));
-
-    const functionWords = (functionNames ?? []).map(func => ({
-      name: func,
-      value: func,
-      score: SQL_FUNCTIONS_AUTOCOMPLETE_SCORE,
-      meta: 'function',
-    }));
-
-    const completer = {
-      insertMatch: (editor: Editor, data: any) => {
-        if (data.meta === 'table') {
-          dispatch(addTable(queryEditor, data.value, queryEditor.schema));
-        }
-
-        let { caption } = data;
-        if (data.meta === 'table' && caption.includes(' ')) {
-          caption = `"${caption}"`;
-        }
-
-        // executing https://github.com/thlorenz/brace/blob/3a00c5d59777f9d826841178e1eb36694177f5e6/ext/language_tools.js#L1448
-        editor.completer.insertMatch(
-          `${caption}${['function', 'schema'].includes(data.meta) ? '' : ' '}`,
-        );
-      },
-    };
-
-    const words = schemaWords
-      .concat(tableWords)
-      .concat(columnWords)
-      .concat(functionWords)
-      .concat(sqlKeywords)
-      .map(word => ({
-        ...word,
-        completer,
-      }));
-
-    setWords(words);
-  }
   const { data: annotations } = useAnnotations({
     dbId: queryEditor.dbId,
     schema: queryEditor.schema,
@@ -306,9 +155,18 @@ const AceEditorWrapper = ({
     templateParams: queryEditor.templateParams,
   });
 
+  const keywords = useKeywords(
+    {
+      queryEditorId,
+      dbId: queryEditor.dbId,
+      schema: queryEditor.schema,
+    },
+    !autocomplete,
+  );
+
   return (
     <StyledAceEditor
-      keywords={words}
+      keywords={keywords}
       onLoad={onEditorLoad}
       onBlur={onBlurSql}
       height={height}
diff --git a/superset-frontend/src/SqlLab/components/AceEditorWrapper/useKeywords.test.ts b/superset-frontend/src/SqlLab/components/AceEditorWrapper/useKeywords.test.ts
new file mode 100644
index 0000000000..6a7c79b85b
--- /dev/null
+++ b/superset-frontend/src/SqlLab/components/AceEditorWrapper/useKeywords.test.ts
@@ -0,0 +1,269 @@
+/**
+ * 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 fetchMock from 'fetch-mock';
+import { act, renderHook } from '@testing-library/react-hooks';
+import {
+  createWrapper,
+  defaultStore as store,
+  createStore,
+} from 'spec/helpers/testing-library';
+import { api } from 'src/hooks/apiResources/queryApi';
+import { schemaApiUtil } from 'src/hooks/apiResources/schemas';
+import { tableApiUtil } from 'src/hooks/apiResources/tables';
+import { addTable } from 'src/SqlLab/actions/sqlLab';
+import { initialState } from 'src/SqlLab/fixtures';
+import { reducers } from 'src/SqlLab/reducers';
+import {
+  SCHEMA_AUTOCOMPLETE_SCORE,
+  TABLE_AUTOCOMPLETE_SCORE,
+  COLUMN_AUTOCOMPLETE_SCORE,
+  SQL_FUNCTIONS_AUTOCOMPLETE_SCORE,
+} from 'src/SqlLab/constants';
+import { useKeywords } from './useKeywords';
+
+const fakeSchemaApiResult = ['schema1', 'schema2'];
+const fakeTableApiResult = {
+  count: 2,
+  result: [
+    {
+      id: 1,
+      value: 'fake api result1',
+      label: 'fake api label1',
+      type: 'table',
+    },
+    {
+      id: 2,
+      value: 'fake api result2',
+      label: 'fake api label2',
+      type: 'table',
+    },
+  ],
+};
+const fakeFunctionNamesApiResult = {
+  function_names: ['abs', 'avg', 'sum'],
+};
+
+const expectDbId = 1;
+const expectSchema = 'schema1';
+
+beforeEach(() => {
+  act(() => {
+    store.dispatch(
+      schemaApiUtil.upsertQueryData(
+        'schemas',
+        {
+          dbId: expectDbId,
+          forceRefresh: false,
+        },
+        fakeSchemaApiResult.map(value => ({
+          value,
+          label: value,
+          title: value,
+        })),
+      ),
+    );
+    store.dispatch(
+      tableApiUtil.upsertQueryData(
+        'tables',
+        { dbId: expectDbId, schema: expectSchema },
+        {
+          options: fakeTableApiResult.result,
+          hasMore: false,
+        },
+      ),
+    );
+  });
+});
+
+afterEach(() => {
+  fetchMock.reset();
+  act(() => {
+    store.dispatch(api.util.resetApiState());
+  });
+});
+
+test('returns keywords including fetched function_names data', async () => {
+  const dbFunctionNamesApiRoute = `glob:*/api/v1/database/${expectDbId}/function_names/`;
+  fetchMock.get(dbFunctionNamesApiRoute, fakeFunctionNamesApiResult);
+
+  const { result, waitFor } = renderHook(
+    () =>
+      useKeywords({
+        queryEditorId: 'testqueryid',
+        dbId: expectDbId,
+        schema: expectSchema,
+      }),
+    {
+      wrapper: createWrapper({
+        useRedux: true,
+        store,
+      }),
+    },
+  );
+
+  await waitFor(() =>
+    expect(fetchMock.calls(dbFunctionNamesApiRoute).length).toBe(1),
+  );
+  fakeSchemaApiResult.forEach(schema => {
+    expect(result.current).toContainEqual(
+      expect.objectContaining({
+        name: schema,
+        score: SCHEMA_AUTOCOMPLETE_SCORE,
+        meta: 'schema',
+      }),
+    );
+  });
+  fakeTableApiResult.result.forEach(({ value }) => {
+    expect(result.current).toContainEqual(
+      expect.objectContaining({
+        value,
+        score: TABLE_AUTOCOMPLETE_SCORE,
+        meta: 'table',
+      }),
+    );
+  });
+  fakeFunctionNamesApiResult.function_names.forEach(func => {
+    expect(result.current).toContainEqual(
+      expect.objectContaining({
+        name: func,
+        value: func,
+        meta: 'function',
+        score: SQL_FUNCTIONS_AUTOCOMPLETE_SCORE,
+      }),
+    );
+  });
+});
+
+test('skip fetching if autocomplete skipped', () => {
+  const { result } = renderHook(
+    () =>
+      useKeywords(
+        {
+          queryEditorId: 'testqueryid',
+          dbId: expectDbId,
+          schema: expectSchema,
+        },
+        true,
+      ),
+    {
+      wrapper: createWrapper({
+        useRedux: true,
+        store,
+      }),
+    },
+  );
+  expect(result.current).toEqual([]);
+  expect(fetchMock.calls()).toEqual([]);
+});
+
+test('returns column keywords among selected tables', async () => {
+  const expectTable = 'table1';
+  const expectColumn = 'column1';
+  const expectQueryEditorId = 'testqueryid';
+
+  const unexpectedColumn = 'column2';
+  const unexpectedTable = 'table2';
+
+  const dbFunctionNamesApiRoute = `glob:*/api/v1/database/${expectDbId}/function_names/`;
+  const storeWithSqlLab = createStore(initialState, reducers);
+  fetchMock.get(dbFunctionNamesApiRoute, fakeFunctionNamesApiResult);
+
+  act(() => {
+    storeWithSqlLab.dispatch(
+      tableApiUtil.upsertQueryData(
+        'tableMetadata',
+        { dbId: expectDbId, schema: expectSchema, table: expectTable },
+        {
+          name: expectTable,
+          columns: [
+            {
+              name: expectColumn,
+              type: 'VARCHAR',
+            },
+          ],
+        },
+      ),
+    );
+
+    storeWithSqlLab.dispatch(
+      tableApiUtil.upsertQueryData(
+        'tableMetadata',
+        { dbId: expectDbId, schema: expectSchema, table: unexpectedTable },
+        {
+          name: unexpectedTable,
+          columns: [
+            {
+              name: unexpectedColumn,
+              type: 'VARCHAR',
+            },
+          ],
+        },
+      ),
+    );
+    storeWithSqlLab.dispatch(
+      addTable({ id: expectQueryEditorId }, expectTable, expectSchema),
+    );
+  });
+
+  const { result, waitFor } = renderHook(
+    () =>
+      useKeywords({
+        queryEditorId: expectQueryEditorId,
+        dbId: expectDbId,
+        schema: expectSchema,
+      }),
+    {
+      wrapper: createWrapper({
+        useRedux: true,
+        store: storeWithSqlLab,
+      }),
+    },
+  );
+
+  await waitFor(() =>
+    expect(result.current).toContainEqual(
+      expect.objectContaining({
+        name: expectColumn,
+        value: expectColumn,
+        score: COLUMN_AUTOCOMPLETE_SCORE,
+        meta: 'column',
+      }),
+    ),
+  );
+
+  expect(result.current).not.toContainEqual(
+    expect.objectContaining({
+      name: unexpectedColumn,
+    }),
+  );
+
+  act(() => {
+    storeWithSqlLab.dispatch(
+      addTable({ id: expectQueryEditorId }, unexpectedTable, expectSchema),
+    );
+  });
+
+  await waitFor(() =>
+    expect(result.current).toContainEqual(
+      expect.objectContaining({
+        name: unexpectedColumn,
+      }),
+    ),
+  );
+});
diff --git a/superset-frontend/src/SqlLab/components/AceEditorWrapper/useKeywords.ts b/superset-frontend/src/SqlLab/components/AceEditorWrapper/useKeywords.ts
new file mode 100644
index 0000000000..e7582158f4
--- /dev/null
+++ b/superset-frontend/src/SqlLab/components/AceEditorWrapper/useKeywords.ts
@@ -0,0 +1,208 @@
+/**
+ * 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 { useEffect, useMemo, useRef } from 'react';
+import { useSelector, useDispatch, shallowEqual, useStore } from 'react-redux';
+import { t } from '@superset-ui/core';
+
+import { Editor } from 'src/components/AsyncAceEditor';
+import sqlKeywords from 'src/SqlLab/utils/sqlKeywords';
+import { addTable, addDangerToast } from 'src/SqlLab/actions/sqlLab';
+import {
+  SCHEMA_AUTOCOMPLETE_SCORE,
+  TABLE_AUTOCOMPLETE_SCORE,
+  COLUMN_AUTOCOMPLETE_SCORE,
+  SQL_FUNCTIONS_AUTOCOMPLETE_SCORE,
+} from 'src/SqlLab/constants';
+import {
+  schemaEndpoints,
+  tableEndpoints,
+  skipToken,
+} from 'src/hooks/apiResources';
+import { api } from 'src/hooks/apiResources/queryApi';
+import { useDatabaseFunctionsQuery } from 'src/hooks/apiResources/databaseFunctions';
+import useEffectEvent from 'src/hooks/useEffectEvent';
+import { SqlLabRootState } from 'src/SqlLab/types';
+
+type Params = {
+  queryEditorId: string | number;
+  dbId?: string | number;
+  schema?: string;
+};
+
+const EMPTY_LIST = [] as typeof sqlKeywords;
+
+const { useQueryState: useSchemasQueryState } = schemaEndpoints.schemas;
+const { useQueryState: useTablesQueryState } = tableEndpoints.tables;
+
+export function useKeywords(
+  { queryEditorId, dbId, schema }: Params,
+  skip = false,
+) {
+  const dispatch = useDispatch();
+  const hasFetchedKeywords = useRef(false);
+  // skipFetch is used to prevent re-evaluating memoized keywords
+  // due to updated api results by skip flag
+  const skipFetch = hasFetchedKeywords && skip;
+  const { data: schemaOptions } = useSchemasQueryState(
+    {
+      dbId,
+      forceRefresh: false,
+    },
+    { skip: skipFetch || !dbId },
+  );
+  const { data: tableData } = useTablesQueryState(
+    {
+      dbId,
+      schema,
+      forceRefresh: false,
+    },
+    { skip: skipFetch || !dbId || !schema },
+  );
+
+  const { data: functionNames, isError } = useDatabaseFunctionsQuery(
+    { dbId },
+    { skip: skipFetch || !dbId },
+  );
+
+  useEffect(() => {
+    if (isError) {
+      dispatch(
+        addDangerToast(t('An error occurred while fetching function names.')),
+      );
+    }
+  }, [dispatch, isError]);
+
+  const tablesForColumnMetadata = useSelector<SqlLabRootState, string[]>(
+    ({ sqlLab }) =>
+      skip
+        ? []
+        : (sqlLab?.tables ?? [])
+            .filter(table => table.queryEditorId === queryEditorId)
+            .map(table => table.name),
+    shallowEqual,
+  );
+
+  const store = useStore();
+  const apiState = store.getState()[api.reducerPath];
+
+  const allColumns = useMemo(() => {
+    const columns = new Set<string>();
+    tablesForColumnMetadata.forEach(table => {
+      tableEndpoints.tableMetadata
+        .select(
+          dbId && schema
+            ? {
+                dbId,
+                schema,
+                table,
+              }
+            : skipToken,
+        )({
+          [api.reducerPath]: apiState,
+        })
+        .data?.columns?.forEach(({ name }) => {
+          columns.add(name);
+        });
+    });
+    return [...columns];
+  }, [dbId, schema, apiState, tablesForColumnMetadata]);
+
+  const insertMatch = useEffectEvent((editor: Editor, data: any) => {
+    if (data.meta === 'table') {
+      dispatch(addTable({ id: queryEditorId, dbId }, data.value, schema));
+    }
+
+    let { caption } = data;
+    if (data.meta === 'table' && caption.includes(' ')) {
+      caption = `"${caption}"`;
+    }
+
+    // executing https://github.com/thlorenz/brace/blob/3a00c5d59777f9d826841178e1eb36694177f5e6/ext/language_tools.js#L1448
+    editor.completer.insertMatch(
+      `${caption}${['function', 'schema'].includes(data.meta) ? '' : ' '}`,
+    );
+  });
+
+  const schemaKeywords = useMemo(
+    () =>
+      (schemaOptions ?? []).map(s => ({
+        name: s.label,
+        value: s.value,
+        score: SCHEMA_AUTOCOMPLETE_SCORE,
+        meta: 'schema',
+        completer: {
+          insertMatch,
+        },
+      })),
+    [schemaOptions, insertMatch],
+  );
+
+  const tableKeywords = useMemo(
+    () =>
+      (tableData?.options ?? []).map(({ value, label }) => ({
+        name: label,
+        value,
+        score: TABLE_AUTOCOMPLETE_SCORE,
+        meta: 'table',
+        completer: {
+          insertMatch,
+        },
+      })),
+    [tableData?.options, insertMatch],
+  );
+
+  const columnKeywords = useMemo(
+    () =>
+      allColumns.map(col => ({
+        name: col,
+        value: col,
+        score: COLUMN_AUTOCOMPLETE_SCORE,
+        meta: 'column',
+      })),
+    [allColumns],
+  );
+
+  const functionKeywords = useMemo(
+    () =>
+      (functionNames ?? []).map(func => ({
+        name: func,
+        value: func,
+        score: SQL_FUNCTIONS_AUTOCOMPLETE_SCORE,
+        meta: 'function',
+        completer: {
+          insertMatch,
+        },
+      })),
+    [functionNames, insertMatch],
+  );
+
+  const keywords = useMemo(
+    () =>
+      columnKeywords
+        .concat(schemaKeywords)
+        .concat(tableKeywords)
+        .concat(functionKeywords)
+        .concat(sqlKeywords),
+    [schemaKeywords, tableKeywords, columnKeywords, functionKeywords],
+  );
+
+  hasFetchedKeywords.current = !skip;
+
+  return skip ? EMPTY_LIST : keywords;
+}
diff --git a/superset-frontend/src/SqlLab/components/SqlEditor/SqlEditor.test.jsx b/superset-frontend/src/SqlLab/components/SqlEditor/SqlEditor.test.jsx
index 464dbf9e8c..ed3f3c9de2 100644
--- a/superset-frontend/src/SqlLab/components/SqlEditor/SqlEditor.test.jsx
+++ b/superset-frontend/src/SqlLab/components/SqlEditor/SqlEditor.test.jsx
@@ -47,6 +47,9 @@ jest.mock('src/components/AsyncAceEditor', () => ({
 }));
 jest.mock('src/SqlLab/components/SqlEditorLeftBar', () => jest.fn());
 
+fetchMock.get('glob:*/api/v1/database/*/function_names/', {
+  function_names: [],
+});
 fetchMock.get('glob:*/api/v1/database/*', { result: [] });
 fetchMock.get('glob:*/api/v1/database/*/tables/*', { options: [] });
 fetchMock.post('glob:*/sqllab/execute/*', { result: [] });
diff --git a/superset-frontend/src/components/AsyncAceEditor/index.tsx b/superset-frontend/src/components/AsyncAceEditor/index.tsx
index 297ff7b552..e4fa51f56b 100644
--- a/superset-frontend/src/components/AsyncAceEditor/index.tsx
+++ b/superset-frontend/src/components/AsyncAceEditor/index.tsx
@@ -16,7 +16,7 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import React, { forwardRef } from 'react';
+import React, { forwardRef, useEffect } from 'react';
 import {
   Editor as OrigEditor,
   IEditSession,
@@ -28,6 +28,7 @@ import { acequire } from 'ace-builds/src-noconflict/ace';
 import AsyncEsmComponent, {
   PlaceholderProps,
 } from 'src/components/AsyncEsmComponent';
+import useEffectEvent from 'src/hooks/useEffectEvent';
 
 export interface AceCompleterKeywordData {
   name: string;
@@ -127,27 +128,37 @@ export default function AsyncAceEditor(
         },
         ref,
       ) {
-        if (keywords) {
-          const langTools = acequire('ace/ext/language_tools');
-          const completer = {
-            getCompletions: (
-              editor: AceEditor,
-              session: IEditSession,
-              pos: Position,
-              prefix: string,
-              callback: (error: null, wordList: object[]) => void,
-            ) => {
-              // If the prefix starts with a number, don't try to autocomplete
-              if (!Number.isNaN(parseInt(prefix, 10))) {
-                return;
-              }
-              if ((session.getMode() as TextMode).$id === `ace/mode/${mode}`) {
-                callback(null, keywords);
-              }
-            },
-          };
-          langTools.setCompleters([completer]);
-        }
+        const langTools = acequire('ace/ext/language_tools');
+        const setCompleters = useEffectEvent(
+          (keywords: AceCompleterKeyword[]) => {
+            const completer = {
+              getCompletions: (
+                editor: AceEditor,
+                session: IEditSession,
+                pos: Position,
+                prefix: string,
+                callback: (error: null, wordList: object[]) => void,
+              ) => {
+                // If the prefix starts with a number, don't try to autocomplete
+                if (!Number.isNaN(parseInt(prefix, 10))) {
+                  return;
+                }
+                if (
+                  (session.getMode() as TextMode).$id === `ace/mode/${mode}`
+                ) {
+                  callback(null, keywords);
+                }
+              },
+            };
+            langTools.setCompleters([completer]);
+          },
+        );
+        useEffect(() => {
+          if (keywords) {
+            setCompleters(keywords);
+          }
+        }, [keywords, setCompleters]);
+
         return (
           <ReactAceEditor
             ref={ref}
diff --git a/superset-frontend/src/hooks/apiResources/schemas.ts b/superset-frontend/src/hooks/apiResources/schemas.ts
index 22fd2cf38b..bbce48d5ad 100644
--- a/superset-frontend/src/hooks/apiResources/schemas.ts
+++ b/superset-frontend/src/hooks/apiResources/schemas.ts
@@ -59,9 +59,14 @@ const schemaApi = api.injectEndpoints({
   }),
 });
 
-export const { useLazySchemasQuery, useSchemasQuery } = schemaApi;
+export const {
+  useLazySchemasQuery,
+  useSchemasQuery,
+  endpoints: schemaEndpoints,
+  util: schemaApiUtil,
+} = schemaApi;
 
-const EMPTY_SCHEMAS = [] as SchemaOption[];
+export const EMPTY_SCHEMAS = [] as SchemaOption[];
 
 export function useSchemas(options: Params) {
   const isMountedRef = useRef(false);
diff --git a/superset-frontend/src/hooks/apiResources/tables.ts b/superset-frontend/src/hooks/apiResources/tables.ts
index 93bc68c985..66eedc00b5 100644
--- a/superset-frontend/src/hooks/apiResources/tables.ts
+++ b/superset-frontend/src/hooks/apiResources/tables.ts
@@ -140,6 +140,7 @@ export const {
   useTableMetadataQuery,
   useTableExtendedMetadataQuery,
   endpoints: tableEndpoints,
+  util: tableApiUtil,
 } = tableApi;
 
 export function useTables(options: Params) {