fix(sqllab): Replace autocomplete logic by a hook (#24677)

This commit is contained in:
JUST.in DO IT
2023-07-26 11:24:28 -07:00
committed by GitHub
parent 83ad291053
commit 77505173ae
9 changed files with 541 additions and 186 deletions

View File

@@ -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: {

View File

@@ -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 },
{},

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -140,6 +140,7 @@ export const {
useTableMetadataQuery,
useTableExtendedMetadataQuery,
endpoints: tableEndpoints,
util: tableApiUtil,
} = tableApi;
export function useTables(options: Params) {