[ES|QL] remove inaccurate values suggestions (#189228)

## Summary

Today, we get a list of field names and functions where values should be
suggested instead. Since we can't yet suggest values in most cases, this
PR prevents any suggestions from being shown in a values context.

### Values suggestions hidden

**Before**


https://github.com/user-attachments/assets/ddf02092-4c61-4a80-8666-c9b4fca735be

**After**


https://github.com/user-attachments/assets/f1c50a8b-6bd1-4e44-b56f-68f8510e53f6

### But not for index names

However, index names are still suggested within quotes


https://github.com/user-attachments/assets/a416220c-370b-4bb3-a1bc-c2d4bada18ca

But not if a space is entered


https://github.com/user-attachments/assets/83d3d1e4-b11b-4bdb-b250-c0f1575fe82f

However, there were a few cases with quoted index names which I just
couldn't find a good way to cover. They are recorded here
https://github.com/elastic/kibana/pull/189228/files#diff-4a3b7269c26dc777be5c0b2a9a1d8f4b1897b7921f6d3e7a176defdf67e83fc6R910-R912

### Checklist

Delete any items that are not applicable to this PR.

- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios

---------

Co-authored-by: Stratoula Kalafateli <efstratia.kalafateli@elastic.co>
This commit is contained in:
Drew Tate 2024-07-29 08:33:42 -06:00 committed by GitHub
parent 35f63763bc
commit 70cad5eb75
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 99 additions and 40 deletions

View file

@ -898,6 +898,29 @@ describe('autocomplete', () => {
});
});
describe('values suggestions', () => {
testSuggestions('FROM "a"', ['a', 'b'], undefined, 7, [
,
[
{ name: 'a', hidden: false },
{ name: 'b', hidden: false },
],
]);
testSuggestions('FROM " "', [], ' ');
// TODO — re-enable these tests when we can support this case
testSuggestions.skip('FROM " a"', [], undefined, 9);
testSuggestions.skip('FROM "foo b"', [], undefined, 11);
testSuggestions('FROM a | WHERE tags == " "', [], ' ');
testSuggestions('FROM a | WHERE tags == """ """', [], ' ');
testSuggestions('FROM a | WHERE tags == "a"', [], undefined, 25);
testSuggestions('FROM a | EVAL tags == " "', [], ' ');
testSuggestions('FROM a | EVAL tags == "a"', [], undefined, 24);
testSuggestions('FROM a | STATS tags == " "', [], ' ');
testSuggestions('FROM a | STATS tags == "a"', [], undefined, 25);
testSuggestions('FROM a | GROK "a" "%{WORD:firstWord}"', [], undefined, 16);
testSuggestions('FROM a | DISSECT "a" "%{WORD:firstWord}"', [], undefined, 19);
});
describe('callbacks', () => {
it('should send the fields query without the last command', async () => {
const callbackMocks = createCustomCallbackMocks(undefined, undefined, undefined);

View file

@ -146,19 +146,67 @@ function getFinalSuggestions({ comma }: { comma?: boolean } = { comma: true }) {
* @param text
* @returns
*/
function countBracketsUnclosed(bracketType: '(' | '[', text: string) {
function countBracketsUnclosed(bracketType: '(' | '[' | '"' | '"""', text: string) {
const stack = [];
const closingBrackets = { '(': ')', '[': ']' };
for (const char of text) {
if (char === bracketType) {
stack.push(bracketType);
} else if (char === closingBrackets[bracketType]) {
const closingBrackets = { '(': ')', '[': ']', '"': '"', '"""': '"""' };
for (let i = 0; i < text.length; i++) {
const substr = text.substring(i, i + bracketType.length);
if (substr === closingBrackets[bracketType] && stack.length) {
stack.pop();
} else if (substr === bracketType) {
stack.push(bracketType);
}
}
return stack.length;
}
/**
* This function attempts to correct the syntax of a partial query to make it valid.
*
* This is important because a syntactically-invalid query will not generate a good AST.
*
* @param _query
* @param context
* @returns
*/
function correctQuerySyntax(_query: string, context: EditorContext) {
let query = _query;
// check if all brackets are closed, otherwise close them
const unclosedRoundBrackets = countBracketsUnclosed('(', query);
const unclosedSquaredBrackets = countBracketsUnclosed('[', query);
const unclosedQuotes = countBracketsUnclosed('"', query);
const unclosedTripleQuotes = countBracketsUnclosed('"""', query);
// if it's a comma by the user or a forced trigger by a function argument suggestion
// add a marker to make the expression still valid
const charThatNeedMarkers = [',', ':'];
if (
(context.triggerCharacter && charThatNeedMarkers.includes(context.triggerCharacter)) ||
// monaco.editor.CompletionTriggerKind['Invoke'] === 0
(context.triggerKind === 0 && unclosedRoundBrackets === 0) ||
(context.triggerCharacter === ' ' &&
(isMathFunction(query, query.length) || isComma(query.trimEnd()[query.trimEnd().length - 1])))
) {
query += EDITOR_MARKER;
}
// if there are unclosed brackets, close them
if (unclosedRoundBrackets || unclosedSquaredBrackets || unclosedQuotes) {
for (const [char, count] of [
['"""', unclosedTripleQuotes],
['"', unclosedQuotes],
[')', unclosedRoundBrackets],
[']', unclosedSquaredBrackets],
]) {
if (count) {
// inject the closing brackets
query += Array(count).fill(char).join('');
}
}
}
return query;
}
export async function suggest(
fullText: string,
offset: number,
@ -168,43 +216,16 @@ export async function suggest(
): Promise<SuggestionRawDefinition[]> {
const innerText = fullText.substring(0, offset);
let finalText = innerText;
const correctedQuery = correctQuerySyntax(innerText, context);
// check if all brackets are closed, otherwise close them
const unclosedRoundBrackets = countBracketsUnclosed('(', finalText);
const unclosedSquaredBrackets = countBracketsUnclosed('[', finalText);
const unclosedBrackets = unclosedRoundBrackets + unclosedSquaredBrackets;
// if it's a comma by the user or a forced trigger by a function argument suggestion
// add a marker to make the expression still valid
const charThatNeedMarkers = [',', ':'];
if (
(context.triggerCharacter && charThatNeedMarkers.includes(context.triggerCharacter)) ||
// monaco.editor.CompletionTriggerKind['Invoke'] === 0
(context.triggerKind === 0 && unclosedRoundBrackets === 0) ||
(context.triggerCharacter === ' ' &&
(isMathFunction(innerText, offset) ||
isComma(innerText.trimEnd()[innerText.trimEnd().length - 1])))
) {
finalText = `${innerText.substring(0, offset)}${EDITOR_MARKER}${innerText.substring(offset)}`;
}
// if there are unclosed brackets, close them
if (unclosedBrackets) {
for (const [char, count] of [
[')', unclosedRoundBrackets],
[']', unclosedSquaredBrackets],
]) {
if (count) {
// inject the closing brackets
finalText += Array(count).fill(char).join('');
}
}
}
const { ast } = await astProvider(finalText);
const { ast } = await astProvider(correctedQuery);
const astContext = getAstContext(innerText, ast, offset);
// build the correct query to fetch the list of fields
const queryForFields = getQueryForFields(buildQueryUntilPreviousCommand(ast, finalText), ast);
const queryForFields = getQueryForFields(
buildQueryUntilPreviousCommand(ast, correctedQuery),
ast
);
const { getFieldsByType, getFieldsMap } = getFieldsByTypeRetriever(
queryForFields,
resourceRetriever
@ -511,6 +532,12 @@ async function getExpressionSuggestionsByType(
const commandDef = getCommandDefinition(command.name);
const { argIndex, prevIndex, lastArg, nodeArg } = extractArgMeta(command, node);
// TODO - this is a workaround because it was too difficult to handle this case in a generic way :(
if (commandDef.name === 'from' && node && isSourceItem(node) && /\s/.test(node.name)) {
// FROM " <suggest>"
return [];
}
// A new expression is considered either
// * just after a command name => i.e. ... | STATS <here>
// * or after a comma => i.e. STATS fieldA, <here>

View file

@ -62,6 +62,11 @@ export interface SuggestionRawDefinition {
export interface EditorContext {
/** The actual char that triggered the suggestion (1 single char) */
triggerCharacter?: string;
/** The type of trigger id. triggerKind = 0 is a programmatic trigger, while any other non-zero value is currently ignored. */
/**
* monaco.editor.CompletionTriggerKind
*
* 0 is "Invoke" (user starts typing a word)
* 1 is "Trigger character" (user types a trigger character)
*/
triggerKind: number;
}

View file

@ -152,6 +152,10 @@ function isBuiltinFunction(node: ESQLFunction) {
export function getAstContext(queryString: string, ast: ESQLAst, offset: number) {
const { command, option, setting, node } = findAstPosition(ast, offset);
if (node) {
if (node.type === 'literal' && node.literalType === 'string') {
// command ... "<here>"
return { type: 'value' as const, command, node, option, setting };
}
if (node.type === 'function') {
if (['in', 'not_in'].includes(node.name) && Array.isArray(node.args[1])) {
// command ... a in ( <here> )