blob: 5f7d92e943a80f0cbf40aa9a005a6c1191b3c579 [file] [log] [blame]
/**
* 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, { useState, useEffect, useRef, useMemo } from 'react';
import { useDispatch } from 'react-redux';
import { css, styled, usePrevious, t } 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 useQueryEditor from 'src/SqlLab/hooks/useQueryEditor';
import { useSchemas, useTables } from 'src/hooks/apiResources';
import { useDatabaseFunctionsQuery } from 'src/hooks/apiResources/databaseFunctions';
import { useAnnotations } from './useAnnotations';
type HotKey = {
key: string;
descr: string;
name: string;
func: () => void;
};
type AceEditorWrapperProps = {
autocomplete: boolean;
onBlur: (sql: string) => void;
onChange: (sql: string) => void;
queryEditorId: string;
database: any;
extendedTables?: Array<{ name: string; columns: any[] }>;
height: string;
hotkeys: HotKey[];
};
const StyledAceEditor = styled(AceEditor)`
${({ theme }) => css`
&& {
// double class is better than !important
border: 1px solid ${theme.colors.grayscale.light2};
font-feature-settings: 'liga' off, 'calt' off;
&.ace_autocomplete {
// Use !important because Ace Editor applies extra CSS at the last second
// when opening the autocomplete.
width: ${theme.gridUnit * 130}px !important;
}
.ace_scroller {
background-color: ${theme.colors.grayscale.light4};
}
}
`}
`;
const AceEditorWrapper = ({
autocomplete,
onBlur = () => {},
onChange = () => {},
queryEditorId,
database,
extendedTables = [],
height,
hotkeys,
}: AceEditorWrapperProps) => {
const dispatch = useDispatch();
const queryEditor = useQueryEditor(queryEditorId, [
'id',
'dbId',
'sql',
'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 [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
// needs to be stored out of the state to ensure changes to it
// get saved immediately
const currentSelectionCache = useRef('');
useEffect(() => {
// Making sure no text is selected from previous mount
dispatch(queryEditorSetSelectedText(queryEditor, null));
setAutoCompleter();
}, []);
const prevTables = usePrevious(tables) ?? [];
const prevSchemas = usePrevious(schemas) ?? [];
const prevExtendedTables = usePrevious(extendedTables) ?? [];
const prevSql = usePrevious(currentSql);
useEffect(() => {
if (
!areArraysShallowEqual(tables, prevTables) ||
!areArraysShallowEqual(schemas, prevSchemas) ||
!areArraysShallowEqual(extendedTables, prevExtendedTables)
) {
setAutoCompleter();
}
}, [tables, schemas, extendedTables]);
useEffect(() => {
if (currentSql !== prevSql) {
setSql(currentSql);
}
}, [currentSql]);
const onBlurSql = () => {
onBlur(sql);
};
const onAltEnter = () => {
onBlur(sql);
};
const onEditorLoad = (editor: any) => {
editor.commands.addCommand({
name: 'runQuery',
bindKey: { win: 'Alt-enter', mac: 'Alt-enter' },
exec: () => {
onAltEnter();
},
});
hotkeys.forEach(keyConfig => {
editor.commands.addCommand({
name: keyConfig.name,
bindKey: { win: keyConfig.key, mac: keyConfig.key },
exec: keyConfig.func,
});
});
editor.$blockScrolling = Infinity; // eslint-disable-line no-param-reassign
editor.selection.on('changeSelection', () => {
const selectedText = editor.getSelectedText();
// Backspace trigger 1 character selection, ignoring
if (
selectedText !== currentSelectionCache.current &&
selectedText.length !== 1
) {
dispatch(queryEditorSetSelectedText(queryEditor, selectedText));
}
currentSelectionCache.current = selectedText;
});
};
const onChangeText = (text: string) => {
setSql(text);
onChange(text);
};
function setAutoCompleter() {
const columns = {};
const tableWords = tables.map(t => {
const tableName = t.value;
const extendedTable = extendedTables.find(et => et.name === tableName);
const cols = extendedTable?.columns || [];
cols.forEach(col => {
columns[col.name] = null; // using an object as a unique set
});
return {
name: t.label,
value: tableName,
score: TABLE_AUTOCOMPLETE_SCORE,
meta: 'table',
};
});
const columnWords = Object.keys(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, database, 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,
sql: currentSql,
templateParams: queryEditor.templateParams,
});
return (
<StyledAceEditor
keywords={words}
onLoad={onEditorLoad}
onBlur={onBlurSql}
height={height}
onChange={onChangeText}
width="100%"
editorProps={{ $blockScrolling: true }}
enableLiveAutocompletion={autocomplete}
value={sql}
annotations={annotations}
/>
);
};
export default AceEditorWrapper;