diff --git a/packages/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/autocomplete.command.where.test.ts b/packages/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/autocomplete.command.where.test.ts new file mode 100644 index 0000000000000..3345f7646e2ff --- /dev/null +++ b/packages/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/autocomplete.command.where.test.ts @@ -0,0 +1,334 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { ESQL_COMMON_NUMERIC_TYPES } from '../../shared/esql_types'; +import { pipeCompleteItem } from '../complete_items'; +import { getDateLiterals } from '../factories'; +import { log10ParameterTypes, powParameterTypes } from './constants'; +import { + attachTriggerCommand, + fields, + getFieldNamesByType, + getFunctionSignaturesByReturnType, + setup, +} from './helpers'; + +describe('WHERE ', () => { + const allEvalFns = getFunctionSignaturesByReturnType('where', 'any', { + scalar: true, + }); + test('beginning an expression', async () => { + const { assertSuggestions } = await setup(); + + await assertSuggestions('from a | where /', [ + ...getFieldNamesByType('any') + .map((field) => `${field} `) + .map(attachTriggerCommand), + ...allEvalFns, + ]); + await assertSuggestions( + 'from a | eval var0 = 1 | where /', + [ + ...getFieldNamesByType('any') + .map((name) => `${name} `) + .map(attachTriggerCommand), + attachTriggerCommand('var0 '), + ...allEvalFns, + ], + { + callbacks: { + getColumnsFor: () => Promise.resolve([...fields, { name: 'var0', type: 'integer' }]), + }, + } + ); + }); + + describe('within the expression', () => { + test('after a field name', async () => { + const { assertSuggestions } = await setup(); + + await assertSuggestions('from a | where keywordField /', [ + // all functions compatible with a keywordField type + ...getFunctionSignaturesByReturnType( + 'where', + 'boolean', + { + builtin: true, + }, + undefined, + ['and', 'or', 'not'] + ), + ]); + }); + + test('suggests dates after a comparison with a date', async () => { + const { assertSuggestions } = await setup(); + + const expectedComparisonWithDateSuggestions = [ + ...getDateLiterals(), + ...getFieldNamesByType(['date']), + // all functions compatible with a keywordField type + ...getFunctionSignaturesByReturnType('where', ['date'], { scalar: true }), + ]; + await assertSuggestions( + 'from a | where dateField == /', + expectedComparisonWithDateSuggestions + ); + + await assertSuggestions( + 'from a | where dateField < /', + expectedComparisonWithDateSuggestions + ); + + await assertSuggestions( + 'from a | where dateField >= /', + expectedComparisonWithDateSuggestions + ); + }); + + test('after a comparison with a string field', async () => { + const { assertSuggestions } = await setup(); + + const expectedComparisonWithTextFieldSuggestions = [ + ...getFieldNamesByType(['text', 'keyword', 'ip', 'version']), + ...getFunctionSignaturesByReturnType('where', ['text', 'keyword', 'ip', 'version'], { + scalar: true, + }), + ]; + + await assertSuggestions( + 'from a | where textField >= /', + expectedComparisonWithTextFieldSuggestions + ); + await assertSuggestions( + 'from a | where textField >= textField/', + expectedComparisonWithTextFieldSuggestions + ); + }); + + test('after a logical operator', async () => { + const { assertSuggestions } = await setup(); + + for (const op of ['and', 'or']) { + await assertSuggestions(`from a | where keywordField >= keywordField ${op} /`, [ + ...getFieldNamesByType('any'), + ...getFunctionSignaturesByReturnType('where', 'any', { scalar: true }), + ]); + await assertSuggestions(`from a | where keywordField >= keywordField ${op} doubleField /`, [ + ...getFunctionSignaturesByReturnType('where', 'boolean', { builtin: true }, ['double']), + ]); + await assertSuggestions( + `from a | where keywordField >= keywordField ${op} doubleField == /`, + [ + ...getFieldNamesByType(ESQL_COMMON_NUMERIC_TYPES), + ...getFunctionSignaturesByReturnType('where', ESQL_COMMON_NUMERIC_TYPES, { + scalar: true, + }), + ] + ); + } + }); + + test('suggests operators after a field name', async () => { + const { assertSuggestions } = await setup(); + + await assertSuggestions('from a | stats a=avg(doubleField) | where a /', [ + ...getFunctionSignaturesByReturnType('where', 'any', { builtin: true, skipAssign: true }, [ + 'double', + ]), + ]); + }); + + test('accounts for fields lost in previous commands', async () => { + const { assertSuggestions } = await setup(); + + // Mind this test: suggestion is aware of previous commands when checking for fields + // in this case the doubleField has been wiped by the STATS command and suggest cannot find it's type + await assertSuggestions('from a | stats a=avg(doubleField) | where doubleField /', [], { + callbacks: { getColumnsFor: () => Promise.resolve([{ name: 'a', type: 'double' }]) }, + }); + }); + + test('suggests function arguments', async () => { + const { assertSuggestions } = await setup(); + + // The editor automatically inject the final bracket, so it is not useful to test with just open bracket + await assertSuggestions( + 'from a | where log10(/)', + [ + ...getFieldNamesByType(log10ParameterTypes), + ...getFunctionSignaturesByReturnType( + 'where', + log10ParameterTypes, + { scalar: true }, + undefined, + ['log10'] + ), + ], + { triggerCharacter: '(' } + ); + await assertSuggestions( + 'from a | WHERE pow(doubleField, /)', + [ + ...getFieldNamesByType(powParameterTypes), + ...getFunctionSignaturesByReturnType( + 'where', + powParameterTypes, + { scalar: true }, + undefined, + ['pow'] + ), + ], + { triggerCharacter: ',' } + ); + }); + + test('suggests boolean and numeric operators after a numeric function result', async () => { + const { assertSuggestions } = await setup(); + + await assertSuggestions('from a | where log10(doubleField) /', [ + ...getFunctionSignaturesByReturnType('where', 'double', { builtin: true }, ['double']), + ...getFunctionSignaturesByReturnType('where', 'boolean', { builtin: true }, ['double']), + ]); + }); + + test('suggestions after NOT', async () => { + const { assertSuggestions } = await setup(); + await assertSuggestions('from index | WHERE keywordField not /', [ + 'LIKE $0', + 'RLIKE $0', + 'IN $0', + ]); + await assertSuggestions('from index | WHERE keywordField NOT /', [ + 'LIKE $0', + 'RLIKE $0', + 'IN $0', + ]); + await assertSuggestions('from index | WHERE not /', [ + ...getFieldNamesByType('boolean').map((name) => attachTriggerCommand(`${name} `)), + ...getFunctionSignaturesByReturnType('where', 'boolean', { scalar: true }), + ]); + await assertSuggestions('FROM index | WHERE NOT ENDS_WITH(keywordField, "foo") /', [ + ...getFunctionSignaturesByReturnType('where', 'boolean', { builtin: true }, ['boolean']), + pipeCompleteItem, + ]); + await assertSuggestions('from index | WHERE keywordField IS NOT/', [ + '!= $0', + '== $0', + 'AND $0', + 'IN $0', + 'IS NOT NULL', + 'IS NULL', + 'NOT', + 'OR $0', + '| ', + ]); + + await assertSuggestions('from index | WHERE keywordField IS NOT /', [ + '!= $0', + '== $0', + 'AND $0', + 'IN $0', + 'IS NOT NULL', + 'IS NULL', + 'NOT', + 'OR $0', + '| ', + ]); + }); + + test('suggestions after IN', async () => { + const { assertSuggestions } = await setup(); + + await assertSuggestions('from index | WHERE doubleField in /', ['( $0 )']); + await assertSuggestions('from index | WHERE doubleField not in /', ['( $0 )']); + await assertSuggestions( + 'from index | WHERE doubleField not in (/)', + [ + ...getFieldNamesByType('double').filter((name) => name !== 'doubleField'), + ...getFunctionSignaturesByReturnType('where', 'double', { scalar: true }), + ], + { triggerCharacter: '(' } + ); + await assertSuggestions('from index | WHERE doubleField in ( `any#Char$Field`, /)', [ + ...getFieldNamesByType('double').filter( + (name) => name !== '`any#Char$Field`' && name !== 'doubleField' + ), + ...getFunctionSignaturesByReturnType('where', 'double', { scalar: true }), + ]); + await assertSuggestions('from index | WHERE doubleField not in ( `any#Char$Field`, /)', [ + ...getFieldNamesByType('double').filter( + (name) => name !== '`any#Char$Field`' && name !== 'doubleField' + ), + ...getFunctionSignaturesByReturnType('where', 'double', { scalar: true }), + ]); + }); + + test('suggestions after IS (NOT) NULL', async () => { + const { assertSuggestions } = await setup(); + + await assertSuggestions('FROM index | WHERE tags.keyword IS NULL /', [ + 'AND $0', + 'OR $0', + '| ', + ]); + + await assertSuggestions('FROM index | WHERE tags.keyword IS NOT NULL /', [ + 'AND $0', + 'OR $0', + '| ', + ]); + }); + + test('suggestions after an arithmetic expression', async () => { + const { assertSuggestions } = await setup(); + + await assertSuggestions('FROM index | WHERE doubleField + doubleField /', [ + ...getFunctionSignaturesByReturnType('where', 'any', { builtin: true, skipAssign: true }, [ + 'double', + ]), + ]); + }); + + test('pipe suggestion after complete expression', async () => { + const { suggest } = await setup(); + expect(await suggest('from index | WHERE doubleField != doubleField /')).toContainEqual( + expect.objectContaining({ + label: '|', + }) + ); + }); + + test('attaches ranges', async () => { + const { suggest } = await setup(); + + const suggestions = await suggest('FROM index | WHERE doubleField IS N/'); + + expect(suggestions).toContainEqual( + expect.objectContaining({ + text: 'IS NOT NULL', + rangeToReplace: { + start: 32, + end: 36, + }, + }) + ); + + expect(suggestions).toContainEqual( + expect.objectContaining({ + text: 'IS NULL', + rangeToReplace: { + start: 32, + end: 36, + }, + }) + ); + }); + }); +}); diff --git a/packages/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/autocomplete.suggest.eval.test.ts b/packages/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/autocomplete.suggest.eval.test.ts index 81fd8f7f43902..5c67bfedbae75 100644 --- a/packages/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/autocomplete.suggest.eval.test.ts +++ b/packages/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/autocomplete.suggest.eval.test.ts @@ -535,17 +535,6 @@ describe('autocomplete.suggest', () => { { triggerCharacter: ' ' } ); await assertSuggestions('from a | eval a = 1 year /', [',', '| ', 'IS NOT NULL', 'IS NULL']); - await assertSuggestions('from a | eval a = 1 day + 2 /', [',', '| ']); - await assertSuggestions( - 'from a | eval 1 day + 2 /', - [ - ...dateSuggestions, - ...getFunctionSignaturesByReturnType('eval', 'any', { builtin: true, skipAssign: true }, [ - 'integer', - ]), - ], - { triggerCharacter: ' ' } - ); await assertSuggestions( 'from a | eval var0=date_trunc(/)', [ diff --git a/packages/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/hidden_functions_and_commands.test.ts b/packages/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/hidden_functions_and_commands.test.ts index cc4562f999fe3..02cc79c326792 100644 --- a/packages/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/hidden_functions_and_commands.test.ts +++ b/packages/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/hidden_functions_and_commands.test.ts @@ -50,6 +50,7 @@ describe('hidden functions', () => { expect(suggestedFunctions).toContain('VISIBLE_FUNCTION($0)'); expect(suggestedFunctions).not.toContain('HIDDEN_FUNCTION($0)'); }); + it('does not suggest hidden agg functions', async () => { setTestFunctions([ { diff --git a/packages/kbn-esql-validation-autocomplete/src/autocomplete/autocomplete.test.ts b/packages/kbn-esql-validation-autocomplete/src/autocomplete/autocomplete.test.ts index 9e7fa4566d753..5f3a2e45f9e1f 100644 --- a/packages/kbn-esql-validation-autocomplete/src/autocomplete/autocomplete.test.ts +++ b/packages/kbn-esql-validation-autocomplete/src/autocomplete/autocomplete.test.ts @@ -11,12 +11,7 @@ import { suggest } from './autocomplete'; import { scalarFunctionDefinitions } from '../definitions/generated/scalar_functions'; import { timeUnitsToSuggest } from '../definitions/literals'; import { commandDefinitions as unmodifiedCommandDefinitions } from '../definitions/commands'; -import { - getDateLiterals, - getSafeInsertText, - TIME_SYSTEM_PARAMS, - TRIGGER_SUGGESTION_COMMAND, -} from './factories'; +import { getSafeInsertText, TIME_SYSTEM_PARAMS, TRIGGER_SUGGESTION_COMMAND } from './factories'; import { camelCase } from 'lodash'; import { getAstAndSyntaxErrors } from '@kbn/esql-ast'; import { @@ -34,8 +29,7 @@ import { fields, } from './__tests__/helpers'; import { METADATA_FIELDS } from '../shared/constants'; -import { ESQL_COMMON_NUMERIC_TYPES, ESQL_STRING_TYPES } from '../shared/esql_types'; -import { log10ParameterTypes, powParameterTypes } from './__tests__/constants'; +import { ESQL_STRING_TYPES } from '../shared/esql_types'; import { getRecommendedQueries } from './recommended_queries/templates'; import { getDateHistogramCompletionItem } from './commands/stats/util'; @@ -130,149 +124,6 @@ describe('autocomplete', () => { } }); - describe('where', () => { - const allEvalFns = getFunctionSignaturesByReturnType('where', 'any', { - scalar: true, - }); - testSuggestions('from a | where /', [ - ...getFieldNamesByType('any').map((field) => `${field} `), - ...allEvalFns, - ]); - testSuggestions('from a | eval var0 = 1 | where /', [ - ...getFieldNamesByType('any').map((name) => `${name} `), - 'var0', - ...allEvalFns, - ]); - testSuggestions('from a | where keywordField /', [ - // all functions compatible with a keywordField type - ...getFunctionSignaturesByReturnType( - 'where', - 'boolean', - { - builtin: true, - }, - undefined, - ['and', 'or', 'not'] - ), - ]); - - const expectedComparisonWithDateSuggestions = [ - ...getDateLiterals(), - ...getFieldNamesByType(['date']), - // all functions compatible with a keywordField type - ...getFunctionSignaturesByReturnType('where', ['date'], { scalar: true }), - ]; - testSuggestions('from a | where dateField == /', expectedComparisonWithDateSuggestions); - - testSuggestions('from a | where dateField < /', expectedComparisonWithDateSuggestions); - - testSuggestions('from a | where dateField >= /', expectedComparisonWithDateSuggestions); - - const expectedComparisonWithTextFieldSuggestions = [ - ...getFieldNamesByType(['text', 'keyword', 'ip', 'version']), - ...getFunctionSignaturesByReturnType('where', ['text', 'keyword', 'ip', 'version'], { - scalar: true, - }), - ]; - testSuggestions('from a | where textField >= /', expectedComparisonWithTextFieldSuggestions); - testSuggestions( - 'from a | where textField >= textField/', - expectedComparisonWithTextFieldSuggestions - ); - for (const op of ['and', 'or']) { - testSuggestions(`from a | where keywordField >= keywordField ${op} /`, [ - ...getFieldNamesByType('any'), - ...getFunctionSignaturesByReturnType('where', 'any', { scalar: true }), - ]); - testSuggestions(`from a | where keywordField >= keywordField ${op} doubleField /`, [ - ...getFunctionSignaturesByReturnType('where', 'boolean', { builtin: true }, ['double']), - ]); - testSuggestions(`from a | where keywordField >= keywordField ${op} doubleField == /`, [ - ...getFieldNamesByType(ESQL_COMMON_NUMERIC_TYPES), - ...getFunctionSignaturesByReturnType('where', ESQL_COMMON_NUMERIC_TYPES, { - scalar: true, - }), - ]); - } - testSuggestions('from a | stats a=avg(doubleField) | where a /', [ - ...getFunctionSignaturesByReturnType('where', 'any', { builtin: true, skipAssign: true }, [ - 'double', - ]), - ]); - // Mind this test: suggestion is aware of previous commands when checking for fields - // in this case the doubleField has been wiped by the STATS command and suggest cannot find it's type - // @TODO: verify this is the correct behaviour in this case or if we want a "generic" suggestion anyway - testSuggestions( - 'from a | stats a=avg(doubleField) | where doubleField /', - [], - undefined, - // make the fields suggest aware of the previous STATS, leave the other callbacks untouched - [[{ name: 'a', type: 'double' }], undefined, undefined] - ); - // The editor automatically inject the final bracket, so it is not useful to test with just open bracket - testSuggestions( - 'from a | where log10(/)', - [ - ...getFieldNamesByType(log10ParameterTypes), - ...getFunctionSignaturesByReturnType( - 'where', - log10ParameterTypes, - { scalar: true }, - undefined, - ['log10'] - ), - ], - '(' - ); - testSuggestions('from a | where log10(doubleField) /', [ - ...getFunctionSignaturesByReturnType('where', 'double', { builtin: true }, ['double']), - ...getFunctionSignaturesByReturnType('where', 'boolean', { builtin: true }, ['double']), - ]); - testSuggestions( - 'from a | WHERE pow(doubleField, /)', - [ - ...getFieldNamesByType(powParameterTypes), - ...getFunctionSignaturesByReturnType( - 'where', - powParameterTypes, - { scalar: true }, - undefined, - ['pow'] - ), - ], - ',' - ); - - testSuggestions('from index | WHERE keywordField not /', ['LIKE $0', 'RLIKE $0', 'IN $0']); - testSuggestions('from index | WHERE keywordField NOT /', ['LIKE $0', 'RLIKE $0', 'IN $0']); - testSuggestions('from index | WHERE not /', [ - ...getFieldNamesByType('boolean'), - ...getFunctionSignaturesByReturnType('eval', 'boolean', { scalar: true }), - ]); - testSuggestions('from index | WHERE doubleField in /', ['( $0 )']); - testSuggestions('from index | WHERE doubleField not in /', ['( $0 )']); - testSuggestions( - 'from index | WHERE doubleField not in (/)', - [ - ...getFieldNamesByType('double').filter((name) => name !== 'doubleField'), - ...getFunctionSignaturesByReturnType('where', 'double', { scalar: true }), - ], - '(' - ); - testSuggestions('from index | WHERE doubleField in ( `any#Char$Field`, /)', [ - ...getFieldNamesByType('double').filter( - (name) => name !== '`any#Char$Field`' && name !== 'doubleField' - ), - ...getFunctionSignaturesByReturnType('where', 'double', { scalar: true }), - ]); - testSuggestions('from index | WHERE doubleField not in ( `any#Char$Field`, /)', [ - ...getFieldNamesByType('double').filter( - (name) => name !== '`any#Char$Field`' && name !== 'doubleField' - ), - ...getFunctionSignaturesByReturnType('where', 'double', { scalar: true }), - ]); - }); - describe('grok', () => { const constantPattern = '"%{WORD:firstWord}"'; const subExpressions = [ @@ -766,6 +617,21 @@ describe('autocomplete', () => { ['and', 'or', 'not'] ) ); + + // WHERE function + testSuggestions( + 'FROM index1 | WHERE ABS(integerField) i/', + getFunctionSignaturesByReturnType( + 'where', + 'any', + { + builtin: true, + skipAssign: true, + }, + ['integer'], + ['and', 'or', 'not'] + ) + ); }); describe('advancing the cursor and opening the suggestion menu automatically ✨', () => { @@ -1295,27 +1161,35 @@ describe('autocomplete', () => { describe('Replacement ranges are attached when needed', () => { testSuggestions('FROM a | WHERE doubleField IS NOT N/', [ - { text: 'IS NOT NULL', rangeToReplace: { start: 28, end: 35 } }, - { text: 'IS NULL', rangeToReplace: { start: 36, end: 36 } }, + { text: 'IS NOT NULL', rangeToReplace: { start: 28, end: 36 } }, + { text: 'IS NULL', rangeToReplace: { start: 37, end: 37 } }, '!= $0', '== $0', 'IN $0', 'AND $0', 'NOT', 'OR $0', + // pipe doesn't make sense here, but Monaco will filter it out. + // see https://github.com/elastic/kibana/issues/199401 for an explanation + // of why this happens + '| ', ]); testSuggestions('FROM a | WHERE doubleField IS N/', [ - { text: 'IS NOT NULL', rangeToReplace: { start: 28, end: 31 } }, - { text: 'IS NULL', rangeToReplace: { start: 28, end: 31 } }, - { text: '!= $0', rangeToReplace: { start: 32, end: 32 } }, + { text: 'IS NOT NULL', rangeToReplace: { start: 28, end: 32 } }, + { text: 'IS NULL', rangeToReplace: { start: 28, end: 32 } }, + { text: '!= $0', rangeToReplace: { start: 33, end: 33 } }, '== $0', 'IN $0', 'AND $0', 'NOT', 'OR $0', + // pipe doesn't make sense here, but Monaco will filter it out. + // see https://github.com/elastic/kibana/issues/199401 for an explanation + // of why this happens + '| ', ]); testSuggestions('FROM a | EVAL doubleField IS NOT N/', [ - { text: 'IS NOT NULL', rangeToReplace: { start: 27, end: 34 } }, + { text: 'IS NOT NULL', rangeToReplace: { start: 27, end: 35 } }, 'IS NULL', '!= $0', '== $0', @@ -1324,6 +1198,7 @@ describe('autocomplete', () => { 'NOT', 'OR $0', ]); + describe('dot-separated field names', () => { testSuggestions( 'FROM a | KEEP field.nam/', diff --git a/packages/kbn-esql-validation-autocomplete/src/autocomplete/autocomplete.ts b/packages/kbn-esql-validation-autocomplete/src/autocomplete/autocomplete.ts index ecb46682b041e..bae10b4c321f4 100644 --- a/packages/kbn-esql-validation-autocomplete/src/autocomplete/autocomplete.ts +++ b/packages/kbn-esql-validation-autocomplete/src/autocomplete/autocomplete.ts @@ -24,7 +24,6 @@ import { getCommandOption, getFunctionDefinition, getLastNonWhitespaceChar, - isArrayType, isAssignment, isAssignmentComplete, isColumnItem, @@ -48,6 +47,7 @@ import { sourceExists, findFinalWord, getAllCommands, + getExpressionType, } from '../shared/helpers'; import { collectVariables, excludeVariablesFromCurrentCommand } from '../shared/variables'; import type { ESQLPolicy, ESQLRealField, ESQLVariable, ReferenceMaps } from '../validation/types'; @@ -56,10 +56,7 @@ import { colonCompleteItem, commaCompleteItem, getAssignmentDefinitionCompletitionItem, - getBuiltinCompatibleFunctionDefinition, getCommandAutocompleteDefinitions, - getNextTokenForNot, - listCompleteItem, pipeCompleteItem, semiColonCompleteItem, } from './complete_items'; @@ -80,6 +77,8 @@ import { getDateLiterals, buildFieldsDefinitionsWithMetadata, TRIGGER_SUGGESTION_COMMAND, + getOperatorSuggestions, + getSuggestionsAfterNot, } from './factories'; import { EDITOR_MARKER, METADATA_FIELDS } from '../shared/constants'; import { getAstContext, removeMarkerArgFromArgsList } from '../shared/context'; @@ -92,10 +91,8 @@ import { import { ESQLCallbacks, ESQLSourceResult } from '../shared/types'; import { getFunctionsToIgnoreForStats, - getOverlapRange, getQueryForFields, getSourcesFromCommands, - getSupportedTypesForBinaryOperators, isAggFunctionUsedAlready, removeQuoteForSuggestedSources, getValidSignaturesAndTypesToSuggestNext, @@ -103,8 +100,10 @@ import { getFieldsOrFunctionsSuggestions, pushItUpInTheList, extractTypeFromASTArg, + getSuggestionsToRightOfOperatorExpression, + checkFunctionInvocationComplete, } from './helper'; -import { FunctionParameter, isParameterType, isReturnType } from '../definitions/types'; +import { FunctionParameter, isParameterType } from '../definitions/types'; import { metadataOption } from '../definitions/options'; import { comparisonFunctions } from '../definitions/builtin'; import { getRecommendedQueriesSuggestions } from './recommended_queries/suggestions'; @@ -321,16 +320,12 @@ function findNewVariable(variables: Map) { function workoutBuiltinOptions( nodeArg: ESQLAstItem, references: Pick -): { skipAssign: boolean; commandsToInclude?: string[] } { - const commandsToInclude = - (isSingleItem(nodeArg) && nodeArg.text?.toLowerCase().trim().endsWith('null')) ?? false - ? ['and', 'or'] - : undefined; - +): { ignored?: string[] } { // skip assign operator if it's a function or an existing field to avoid promoting shadowing return { - skipAssign: Boolean(!isColumnItem(nodeArg) || getColumnForASTNode(nodeArg, references)), - commandsToInclude, + ignored: Boolean(!isColumnItem(nodeArg) || getColumnForASTNode(nodeArg, references)) + ? ['='] + : undefined, }; } @@ -340,42 +335,19 @@ function areCurrentArgsValid( references: Pick ) { // unfortunately here we need to bake some command-specific logic - if (command.name === 'stats') { - if (node) { - // consider the following expressions not complete yet - // ... | stats a - // ... | stats a = - if (isColumnItem(node) || (isAssignment(node) && !isAssignmentComplete(node))) { - return false; - } - } - } if (command.name === 'eval') { if (node) { if (isFunctionItem(node)) { if (isAssignment(node)) { return isAssignmentComplete(node); } else { - return isFunctionArgComplete(node, references).complete; + return checkFunctionInvocationComplete(node, (expression) => + getExpressionType(expression, references.fields, references.variables) + ).complete; } } } } - if (command.name === 'where') { - if (node) { - if ( - isColumnItem(node) || - (isFunctionItem(node) && !isFunctionArgComplete(node, references).complete) - ) { - return false; - } else { - return ( - extractTypeFromASTArg(node, references) === - getCommandDefinition(command.name).signature.params[0].type - ); - } - } - } if (command.name === 'rename') { if (node) { if (isColumnItem(node)) { @@ -386,45 +358,6 @@ function areCurrentArgsValid( return true; } -// @TODO: refactor this to be shared with validation -function isFunctionArgComplete( - arg: ESQLFunction, - references: Pick -) { - const fnDefinition = getFunctionDefinition(arg.name); - if (!fnDefinition) { - return { complete: false }; - } - const cleanedArgs = removeMarkerArgFromArgsList(arg)!.args; - const argLengthCheck = fnDefinition.signatures.some((def) => { - if (def.minParams && cleanedArgs.length >= def.minParams) { - return true; - } - if (cleanedArgs.length === def.params.length) { - return true; - } - return cleanedArgs.length >= def.params.filter(({ optional }) => !optional).length; - }); - if (!argLengthCheck) { - return { complete: false, reason: 'fewArgs' }; - } - if (fnDefinition.name === 'in' && Array.isArray(arg.args[1]) && !arg.args[1].length) { - return { complete: false, reason: 'fewArgs' }; - } - const hasCorrectTypes = fnDefinition.signatures.some((def) => { - return arg.args.every((a, index) => { - return ( - (fnDefinition.name.endsWith('null') && def.params[index].type === 'any') || - def.params[index].type === extractTypeFromASTArg(a, references) - ); - }); - }); - if (!hasCorrectTypes) { - return { complete: false, reason: 'wrongTypes' }; - } - return { complete: true }; -} - function extractArgMeta( commandOrOption: ESQLCommand | ESQLCommandOption, node: ESQLSingleAstItem | undefined @@ -478,6 +411,8 @@ async function getSuggestionsWithinCommandExpression( getColumnsByType, (col: string) => Boolean(getColumnByName(col, references)), () => findNewVariable(anyVariables), + (expression: ESQLAstItem | undefined) => + getExpressionType(expression, references.fields, references.variables), getPreferences ); } else { @@ -629,7 +564,7 @@ async function getExpressionSuggestionsByType( // ... | ROW field NOT // ... | EVAL field NOT // there's not way to know the type of the field here, so suggest anything - suggestions.push(...getNextTokenForNot(command.name, option?.name, 'any')); + suggestions.push(...getSuggestionsAfterNot()); } else { // i.e. // ... | ROW @@ -717,13 +652,11 @@ async function getExpressionSuggestionsByType( const nodeArgType = extractTypeFromASTArg(nodeArg, references); if (isParameterType(nodeArgType)) { suggestions.push( - ...getBuiltinCompatibleFunctionDefinition( - command.name, - undefined, - nodeArgType, - undefined, - workoutBuiltinOptions(nodeArg, references) - ) + ...getOperatorSuggestions({ + command: command.name, + leftParamType: nodeArgType, + ignored: workoutBuiltinOptions(nodeArg, references).ignored, + }) ); } else { suggestions.push(getAssignmentDefinitionCompletitionItem()); @@ -754,9 +687,7 @@ async function getExpressionSuggestionsByType( )) ); if (['show', 'meta'].includes(command.name)) { - suggestions.push( - ...getBuiltinCompatibleFunctionDefinition(command.name, undefined, 'any') - ); + suggestions.push(...getOperatorSuggestions({ command: command.name })); } } } @@ -770,13 +701,11 @@ async function getExpressionSuggestionsByType( const [rightArg] = nodeArg.args[1] as [ESQLSingleAstItem]; const nodeArgType = extractTypeFromASTArg(rightArg, references); suggestions.push( - ...getBuiltinCompatibleFunctionDefinition( - command.name, - undefined, - isParameterType(nodeArgType) ? nodeArgType : 'any', - undefined, - workoutBuiltinOptions(rightArg, references) - ) + ...getOperatorSuggestions({ + command: command.name, + leftParamType: isParameterType(nodeArgType) ? nodeArgType : 'any', + ignored: workoutBuiltinOptions(nodeArg, references).ignored, + }) ); if (isNumericType(nodeArgType) && isLiteralItem(rightArg)) { // ... EVAL var = 1 @@ -808,18 +737,16 @@ async function getExpressionSuggestionsByType( )) ); } else { - const nodeArgType = extractTypeFromASTArg(nodeArg, references); suggestions.push( - ...(await getBuiltinFunctionNextArgument( - innerText, - command, - option, - argDef, - nodeArg, - (nodeArgType as string) || 'any', - references, - getFieldsByType - )) + ...(await getSuggestionsToRightOfOperatorExpression({ + queryText: innerText, + commandName: command.name, + optionName: option?.name, + rootOperator: nodeArg, + getExpressionType: (expression) => + getExpressionType(expression, references.fields, references.variables), + getColumnsByType: getFieldsByType, + })) ); if (nodeArg.args.some(isTimeIntervalItem)) { const lastFnArg = nodeArg.args[nodeArg.args.length - 1]; @@ -859,7 +786,7 @@ async function getExpressionSuggestionsByType( // i.e. // ... | WHERE field NOT // there's not way to know the type of the field here, so suggest anything - suggestions.push(...getNextTokenForNot(command.name, option?.name, 'any')); + suggestions.push(...getSuggestionsAfterNot()); } else { // ... | // In this case start suggesting something not strictly based on type @@ -906,28 +833,25 @@ async function getExpressionSuggestionsByType( ); } else { suggestions.push( - ...(await getBuiltinFunctionNextArgument( - innerText, - command, - option, - argDef, - nodeArg, - nodeArgType as string, - references, - getFieldsByType - )) + ...(await getSuggestionsToRightOfOperatorExpression({ + queryText: innerText, + commandName: command.name, + optionName: option?.name, + rootOperator: nodeArg, + getExpressionType: (expression) => + getExpressionType(expression, references.fields, references.variables), + getColumnsByType: getFieldsByType, + })) ); } } else if (isParameterType(nodeArgType)) { // i.e. ... | field suggestions.push( - ...getBuiltinCompatibleFunctionDefinition( - command.name, - undefined, - nodeArgType, - undefined, - workoutBuiltinOptions(nodeArg, references) - ) + ...getOperatorSuggestions({ + command: command.name, + leftParamType: nodeArgType, + ignored: workoutBuiltinOptions(nodeArg, references).ignored, + }) ); } } @@ -1080,110 +1004,6 @@ async function getExpressionSuggestionsByType( return uniqBy(suggestions, (suggestion) => suggestion.text); } -async function getBuiltinFunctionNextArgument( - queryText: string, - command: ESQLCommand, - option: ESQLCommandOption | undefined, - argDef: { type: string }, - nodeArg: ESQLFunction, - nodeArgType: string, - references: Pick, - getFieldsByType: GetColumnsByTypeFn -) { - const suggestions = []; - const isFnComplete = isFunctionArgComplete(nodeArg, references); - - if (isFnComplete.complete) { - // i.e. ... | field > 0 - // i.e. ... | field + otherN - suggestions.push( - ...getBuiltinCompatibleFunctionDefinition( - command.name, - option?.name, - isParameterType(nodeArgType) ? nodeArgType : 'any', - undefined, - workoutBuiltinOptions(nodeArg, references) - ) - ); - } else { - // i.e. ... | field >= - // i.e. ... | field + - // i.e. ... | field and - - // Because it's an incomplete function, need to extract the type of the current argument - // and suggest the next argument based on types - - // pick the last arg and check its type to verify whether is incomplete for the given function - const cleanedArgs = removeMarkerArgFromArgsList(nodeArg)!.args; - const nestedType = extractTypeFromASTArg(nodeArg.args[cleanedArgs.length - 1], references); - - if (isFnComplete.reason === 'fewArgs') { - const fnDef = getFunctionDefinition(nodeArg.name); - if ( - fnDef?.signatures.every(({ params }) => - params.some(({ type }) => isArrayType(type as string)) - ) - ) { - suggestions.push(listCompleteItem); - } else { - const finalType = nestedType || nodeArgType || 'any'; - const supportedTypes = getSupportedTypesForBinaryOperators(fnDef, finalType as string); - - suggestions.push( - ...(await getFieldsOrFunctionsSuggestions( - // this is a special case with AND/OR - // expression AND/OR - // technically another boolean value should be suggested, but it is a better experience - // to actually suggest a wider set of fields/functions - finalType === 'boolean' && getFunctionDefinition(nodeArg.name)?.type === 'builtin' - ? ['any'] - : (supportedTypes as string[]), - command.name, - option?.name, - getFieldsByType, - { - functions: true, - fields: true, - variables: references.variables, - } - )) - ); - } - } - if (isFnComplete.reason === 'wrongTypes') { - if (nestedType) { - // suggest something to complete the builtin function - if ( - nestedType !== argDef.type && - isParameterType(nestedType) && - isReturnType(argDef.type) - ) { - suggestions.push( - ...getBuiltinCompatibleFunctionDefinition( - command.name, - undefined, - nestedType, - [argDef.type], - workoutBuiltinOptions(nodeArg, references) - ) - ); - } - } - } - } - return suggestions.map((s) => { - const overlap = getOverlapRange(queryText, s.text); - const offset = overlap.start === overlap.end ? 1 : 0; - return { - ...s, - rangeToReplace: { - start: overlap.start + offset, - end: overlap.end + offset, - }, - }; - }); -} - const addCommaIf = (condition: boolean, text: string) => (condition ? `${text},` : text); async function getFunctionArgsSuggestions( @@ -1615,10 +1435,7 @@ async function getOptionArgsSuggestions( // ... | ENRICH ... WITH a // effectively only assign will apper suggestions.push( - ...pushItUpInTheList( - getBuiltinCompatibleFunctionDefinition(command.name, undefined, 'any'), - true - ) + ...pushItUpInTheList(getOperatorSuggestions({ command: command.name }), true) ); } diff --git a/packages/kbn-esql-validation-autocomplete/src/autocomplete/commands/stats/index.ts b/packages/kbn-esql-validation-autocomplete/src/autocomplete/commands/stats/index.ts index 46a37d36eacc9..ac70ac1a1a5ca 100644 --- a/packages/kbn-esql-validation-autocomplete/src/autocomplete/commands/stats/index.ts +++ b/packages/kbn-esql-validation-autocomplete/src/autocomplete/commands/stats/index.ts @@ -7,7 +7,8 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import type { ESQLCommand } from '@kbn/esql-ast'; +import type { ESQLAstItem, ESQLCommand } from '@kbn/esql-ast'; +import { SupportedDataType } from '../../../definitions/types'; import type { GetColumnsByTypeFn, SuggestionRawDefinition } from '../../types'; import { TRIGGER_SUGGESTION_COMMAND, @@ -24,6 +25,7 @@ export async function suggest( getColumnsByType: GetColumnsByTypeFn, _columnExists: (column: string) => boolean, getSuggestedVariableName: () => string, + _getExpressionType: (expression: ESQLAstItem | undefined) => SupportedDataType | 'unknown', getPreferences?: () => Promise<{ histogramBarTarget: number } | undefined> ): Promise { const pos = getPosition(innerText, command); diff --git a/packages/kbn-esql-validation-autocomplete/src/autocomplete/commands/where/index.ts b/packages/kbn-esql-validation-autocomplete/src/autocomplete/commands/where/index.ts new file mode 100644 index 0000000000000..dc2ab341e961e --- /dev/null +++ b/packages/kbn-esql-validation-autocomplete/src/autocomplete/commands/where/index.ts @@ -0,0 +1,183 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { + Walker, + type ESQLAstItem, + type ESQLCommand, + type ESQLSingleAstItem, + type ESQLFunction, +} from '@kbn/esql-ast'; +import { logicalOperators } from '../../../definitions/builtin'; +import { isParameterType, type SupportedDataType } from '../../../definitions/types'; +import { isFunctionItem } from '../../../shared/helpers'; +import type { GetColumnsByTypeFn, SuggestionRawDefinition } from '../../types'; +import { + getFunctionSuggestions, + getOperatorSuggestion, + getOperatorSuggestions, + getSuggestionsAfterNot, +} from '../../factories'; +import { getOverlapRange, getSuggestionsToRightOfOperatorExpression } from '../../helper'; +import { getPosition } from './util'; +import { pipeCompleteItem } from '../../complete_items'; + +export async function suggest( + innerText: string, + command: ESQLCommand<'where'>, + getColumnsByType: GetColumnsByTypeFn, + _columnExists: (column: string) => boolean, + _getSuggestedVariableName: () => string, + getExpressionType: (expression: ESQLAstItem | undefined) => SupportedDataType | 'unknown', + _getPreferences?: () => Promise<{ histogramBarTarget: number } | undefined> +): Promise { + const suggestions: SuggestionRawDefinition[] = []; + + /** + * The logic for WHERE suggestions is basically the logic for expression suggestions. + * I assume we will eventually extract much of this to be a shared function among WHERE and EVAL + * and anywhere else the user can enter a generic expression. + */ + const expressionRoot = command.args[0] as ESQLSingleAstItem | undefined; + + switch (getPosition(innerText, command)) { + /** + * After a column name + */ + case 'after_column': + const columnType = getExpressionType(expressionRoot); + + if (!isParameterType(columnType)) { + break; + } + + suggestions.push( + ...getOperatorSuggestions({ + command: 'where', + leftParamType: columnType, + // no assignments allowed in WHERE + ignored: ['='], + }) + ); + break; + + /** + * After a complete (non-operator) function call + */ + case 'after_function': + const returnType = getExpressionType(expressionRoot); + + if (!isParameterType(returnType)) { + break; + } + + suggestions.push( + ...getOperatorSuggestions({ + command: 'where', + leftParamType: returnType, + ignored: ['='], + }) + ); + + break; + + /** + * After a NOT keyword + * + * the NOT function is a special operator that can be used in different ways, + * and not all these are mapped within the AST data structure: in particular + * NOT + * is an incomplete statement and it results in a missing AST node, so we need to detect + * from the query string itself + * + * (this comment was copied but seems to still apply) + */ + case 'after_not': + if (expressionRoot && isFunctionItem(expressionRoot) && expressionRoot.name === 'not') { + suggestions.push( + ...getFunctionSuggestions({ command: 'where', returnTypes: ['boolean'] }), + ...(await getColumnsByType('boolean', [], { advanceCursor: true, openSuggestions: true })) + ); + } else { + suggestions.push(...getSuggestionsAfterNot()); + } + + break; + + /** + * After an operator (e.g. AND, OR, IS NULL, +, etc.) + */ + case 'after_operator': + if (!expressionRoot) { + break; + } + + if (!isFunctionItem(expressionRoot) || expressionRoot.subtype === 'variadic-call') { + // this is already guaranteed in the getPosition function, but TypeScript doesn't know + break; + } + + let rightmostOperator = expressionRoot; + // get rightmost function + const walker = new Walker({ + visitFunction: (fn: ESQLFunction) => { + if (fn.location.min > rightmostOperator.location.min && fn.subtype !== 'variadic-call') + rightmostOperator = fn; + }, + }); + walker.walkFunction(expressionRoot); + + // See https://github.com/elastic/kibana/issues/199401 for an explanation of + // why this check has to be so convoluted + if (rightmostOperator.text.toLowerCase().trim().endsWith('null')) { + suggestions.push(...logicalOperators.map(getOperatorSuggestion)); + break; + } + + suggestions.push( + ...(await getSuggestionsToRightOfOperatorExpression({ + queryText: innerText, + commandName: 'where', + rootOperator: rightmostOperator, + preferredExpressionType: 'boolean', + getExpressionType, + getColumnsByType, + })) + ); + + break; + + case 'empty_expression': + const columnSuggestions = await getColumnsByType('any', [], { + advanceCursor: true, + openSuggestions: true, + }); + suggestions.push(...columnSuggestions, ...getFunctionSuggestions({ command: 'where' })); + + break; + } + + // Is this a complete expression of the right type? + // If so, we can call it done and suggest a pipe + if (getExpressionType(expressionRoot) === 'boolean') { + suggestions.push(pipeCompleteItem); + } + + return suggestions.map((s) => { + const overlap = getOverlapRange(innerText, s.text); + const offset = overlap.start === overlap.end ? 1 : 0; + return { + ...s, + rangeToReplace: { + start: overlap.start + offset, + end: overlap.end + offset, + }, + }; + }); +} diff --git a/packages/kbn-esql-validation-autocomplete/src/autocomplete/commands/where/util.ts b/packages/kbn-esql-validation-autocomplete/src/autocomplete/commands/where/util.ts new file mode 100644 index 0000000000000..c969e7e37461f --- /dev/null +++ b/packages/kbn-esql-validation-autocomplete/src/autocomplete/commands/where/util.ts @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { ESQLCommand, ESQLSingleAstItem } from '@kbn/esql-ast'; +import { isColumnItem, isFunctionItem } from '../../../shared/helpers'; + +export type CaretPosition = + | 'after_column' + | 'after_function' + | 'after_not' + | 'after_operator' + | 'empty_expression'; + +export const getPosition = (innerText: string, command: ESQLCommand): CaretPosition => { + const expressionRoot = command.args[0] as ESQLSingleAstItem | undefined; + + const endsWithNot = / not$/i.test(innerText.trimEnd()); + if ( + endsWithNot && + !( + expressionRoot && + isFunctionItem(expressionRoot) && + // See https://github.com/elastic/kibana/issues/199401 + // for more information on this check... + ['is null', 'is not null'].includes(expressionRoot.name) + ) + ) { + return 'after_not'; + } + + if (expressionRoot) { + if (isColumnItem(expressionRoot)) { + return 'after_column'; + } + + if (isFunctionItem(expressionRoot) && expressionRoot.subtype === 'variadic-call') { + return 'after_function'; + } + + if (isFunctionItem(expressionRoot) && expressionRoot.subtype !== 'variadic-call') { + return 'after_operator'; + } + } + + return 'empty_expression'; +}; diff --git a/packages/kbn-esql-validation-autocomplete/src/autocomplete/complete_items.ts b/packages/kbn-esql-validation-autocomplete/src/autocomplete/complete_items.ts index b115e30c47efe..0c448d4814f96 100644 --- a/packages/kbn-esql-validation-autocomplete/src/autocomplete/complete_items.ts +++ b/packages/kbn-esql-validation-autocomplete/src/autocomplete/complete_items.ts @@ -11,81 +11,17 @@ import { i18n } from '@kbn/i18n'; import type { ItemKind, SuggestionRawDefinition } from './types'; import { builtinFunctions } from '../definitions/builtin'; import { - getSuggestionBuiltinDefinition, + getOperatorSuggestion, getSuggestionCommandDefinition, TRIGGER_SUGGESTION_COMMAND, - buildConstantsDefinitions, } from './factories'; -import { CommandDefinition, FunctionParameterType, FunctionReturnType } from '../definitions/types'; -import { getTestFunctions } from '../shared/test_functions'; +import { CommandDefinition } from '../definitions/types'; export function getAssignmentDefinitionCompletitionItem() { const assignFn = builtinFunctions.find(({ name }) => name === '=')!; - return getSuggestionBuiltinDefinition(assignFn); + return getOperatorSuggestion(assignFn); } -export const getNextTokenForNot = ( - command: string, - option: string | undefined, - argType: string -): SuggestionRawDefinition[] => { - const compatibleFunctions = builtinFunctions.filter( - ({ name, supportedCommands, supportedOptions, ignoreAsSuggestion }) => - !ignoreAsSuggestion && - !/not_/.test(name) && - (option ? supportedOptions?.includes(option) : supportedCommands.includes(command)) - ); - if (argType === 'string' || argType === 'any') { - // suggest IS, LIKE, RLIKE and TRUE/FALSE - return compatibleFunctions - .filter(({ name }) => name === 'like' || name === 'rlike' || name === 'in') - .map(getSuggestionBuiltinDefinition); - } - if (argType === 'boolean') { - // suggest IS, NOT and TRUE/FALSE - return [ - ...compatibleFunctions - .filter(({ name }) => name === 'in') - .map(getSuggestionBuiltinDefinition), - ...buildConstantsDefinitions(['true', 'false']), - ]; - } - return []; -}; - -export const getBuiltinCompatibleFunctionDefinition = ( - command: string, - option: string | undefined, - argType: FunctionParameterType, - returnTypes?: FunctionReturnType[], - { skipAssign, commandsToInclude }: { skipAssign?: boolean; commandsToInclude?: string[] } = {} -): SuggestionRawDefinition[] => { - const compatibleFunctions = [...builtinFunctions, ...getTestFunctions()].filter( - ({ name, supportedCommands, supportedOptions, signatures, ignoreAsSuggestion }) => - (command === 'where' && commandsToInclude ? commandsToInclude.indexOf(name) > -1 : true) && - !ignoreAsSuggestion && - (!skipAssign || name !== '=') && - (option ? supportedOptions?.includes(option) : supportedCommands.includes(command)) && - signatures.some( - ({ params }) => - !params.length || params.some((pArg) => pArg.type === argType || pArg.type === 'any') - ) - ); - if (!returnTypes) { - return compatibleFunctions.map(getSuggestionBuiltinDefinition); - } - return compatibleFunctions - .filter((mathDefinition) => - mathDefinition.signatures.some( - (signature) => - returnTypes[0] === 'unknown' || - returnTypes[0] === 'any' || - returnTypes.includes(signature.returnType) - ) - ) - .map(getSuggestionBuiltinDefinition); -}; - export const getCommandAutocompleteDefinitions = ( commands: Array> ): SuggestionRawDefinition[] => diff --git a/packages/kbn-esql-validation-autocomplete/src/autocomplete/factories.ts b/packages/kbn-esql-validation-autocomplete/src/autocomplete/factories.ts index 9b7e2b0bf71a5..88560f6d2f4c5 100644 --- a/packages/kbn-esql-validation-autocomplete/src/autocomplete/factories.ts +++ b/packages/kbn-esql-validation-autocomplete/src/autocomplete/factories.ts @@ -20,6 +20,7 @@ import { CommandDefinition, CommandOptionsDefinition, CommandModeDefinition, + FunctionParameterType, } from '../definitions/types'; import { shouldBeQuotedSource, getCommandDefinition, shouldBeQuotedText } from '../shared/helpers'; import { buildDocumentation, buildFunctionDocumentation } from './documentation_util'; @@ -27,6 +28,7 @@ import { DOUBLE_BACKTICK, SINGLE_TICK_REGEX } from '../shared/constants'; import { ESQLRealField } from '../validation/types'; import { isNumericType } from '../shared/esql_types'; import { getTestFunctions } from '../shared/test_functions'; +import { builtinFunctions } from '../definitions/builtin'; const allFunctions = memoize( () => @@ -75,7 +77,7 @@ export function getFunctionSuggestion(fn: FunctionDefinition): SuggestionRawDefi }; } -export function getSuggestionBuiltinDefinition(fn: FunctionDefinition): SuggestionRawDefinition { +export function getOperatorSuggestion(fn: FunctionDefinition): SuggestionRawDefinition { const hasArgs = fn.signatures.some(({ params }) => params.length > 1); return { label: fn.name.toUpperCase(), @@ -91,21 +93,22 @@ export function getSuggestionBuiltinDefinition(fn: FunctionDefinition): Suggesti }; } -/** - * Builds suggestions for functions based on the provided predicates. - * - * @param predicates a set of conditions that must be met for a function to be included in the suggestions - * @returns - */ -export const getFunctionSuggestions = (predicates?: { +interface FunctionFilterPredicates { command?: string; option?: string | undefined; returnTypes?: string[]; ignored?: string[]; -}): SuggestionRawDefinition[] => { - const functions = allFunctions(); - const { command, option, returnTypes, ignored = [] } = predicates ?? {}; - const filteredFunctions: FunctionDefinition[] = functions.filter( +} + +export const filterFunctionDefinitions = ( + functions: FunctionDefinition[], + predicates: FunctionFilterPredicates | undefined +): FunctionDefinition[] => { + if (!predicates) { + return functions; + } + const { command, option, returnTypes, ignored = [] } = predicates; + return functions.filter( ({ name, supportedCommands, supportedOptions, ignoreAsSuggestion, signatures }) => { if (ignoreAsSuggestion) { return false; @@ -130,8 +133,53 @@ export const getFunctionSuggestions = (predicates?: { return true; } ); +}; + +/** + * Builds suggestions for functions based on the provided predicates. + * + * @param predicates a set of conditions that must be met for a function to be included in the suggestions + * @returns + */ +export const getFunctionSuggestions = ( + predicates?: FunctionFilterPredicates +): SuggestionRawDefinition[] => { + return filterFunctionDefinitions(allFunctions(), predicates).map(getFunctionSuggestion); +}; + +/** + * Builds suggestions for operators based on the provided predicates. + * + * @param predicates a set of conditions that must be met for an operator to be included in the suggestions + * @returns + */ +export const getOperatorSuggestions = ( + predicates?: FunctionFilterPredicates & { leftParamType?: FunctionParameterType } +): SuggestionRawDefinition[] => { + const filteredDefinitions = filterFunctionDefinitions( + getTestFunctions().length ? [...builtinFunctions, ...getTestFunctions()] : builtinFunctions, + predicates + ); + + // make sure the operator has at least one signature that matches + // the type of the existing left argument if provided (e.g. "doubleField ") + return ( + predicates?.leftParamType + ? filteredDefinitions.filter(({ signatures }) => + signatures.some( + ({ params }) => + !params.length || + params.some((pArg) => pArg.type === predicates?.leftParamType || pArg.type === 'any') + ) + ) + : filteredDefinitions + ).map(getOperatorSuggestion); +}; - return filteredFunctions.map(getFunctionSuggestion); +export const getSuggestionsAfterNot = (): SuggestionRawDefinition[] => { + return builtinFunctions + .filter(({ name }) => name === 'like' || name === 'rlike' || name === 'in') + .map(getOperatorSuggestion); }; export function getSuggestionCommandDefinition( diff --git a/packages/kbn-esql-validation-autocomplete/src/autocomplete/helper.test.ts b/packages/kbn-esql-validation-autocomplete/src/autocomplete/helper.test.ts new file mode 100644 index 0000000000000..c4133592c425d --- /dev/null +++ b/packages/kbn-esql-validation-autocomplete/src/autocomplete/helper.test.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { getOverlapRange } from './helper'; + +describe('getOverlapRange', () => { + it('should return the overlap range', () => { + expect(getOverlapRange('IS N', 'IS NOT NULL')).toEqual({ start: 1, end: 5 }); + expect(getOverlapRange('I', 'IS NOT NULL')).toEqual({ start: 1, end: 2 }); + }); + + it('full query', () => { + expect(getOverlapRange('FROM index | WHERE field IS N', 'IS NOT NULL')).toEqual({ + start: 26, + end: 30, + }); + }); +}); diff --git a/packages/kbn-esql-validation-autocomplete/src/autocomplete/helper.ts b/packages/kbn-esql-validation-autocomplete/src/autocomplete/helper.ts index 3ccddfc5ff241..67ea324a1a69a 100644 --- a/packages/kbn-esql-validation-autocomplete/src/autocomplete/helper.ts +++ b/packages/kbn-esql-validation-autocomplete/src/autocomplete/helper.ts @@ -15,15 +15,18 @@ import type { ESQLSource, } from '@kbn/esql-ast'; import { uniqBy } from 'lodash'; -import type { - FunctionDefinition, - FunctionReturnType, - SupportedDataType, +import { + isParameterType, + type FunctionDefinition, + type FunctionReturnType, + type SupportedDataType, + isReturnType, } from '../definitions/types'; import { findFinalWord, getColumnForASTNode, getFunctionDefinition, + isArrayType, isAssignment, isColumnItem, isFunctionItem, @@ -39,9 +42,12 @@ import { getFunctionSuggestions, getCompatibleLiterals, getDateLiterals, + getOperatorSuggestions, } from './factories'; import { EDITOR_MARKER } from '../shared/constants'; import { ESQLRealField, ESQLVariable, ReferenceMaps } from '../validation/types'; +import { listCompleteItem } from './complete_items'; +import { removeMarkerArgFromArgsList } from '../shared/context'; function extractFunctionArgs(args: ESQLAstItem[]): ESQLFunction[] { return args.flatMap((arg) => (isAssignment(arg) ? arg.args[1] : arg)).filter(isFunctionItem); @@ -208,9 +214,10 @@ export function getOverlapRange( } } + // add one since Monaco columns are 1-based return { - start: Math.min(query.length - overlapLength + 1, query.length), - end: query.length, + start: query.length - overlapLength + 1, + end: query.length + 1, }; } @@ -445,6 +452,7 @@ export function pushItUpInTheList(suggestions: SuggestionRawDefinition[], should })); } +/** @deprecated — use getExpressionType instead (packages/kbn-esql-validation-autocomplete/src/shared/helpers.ts) */ export function extractTypeFromASTArg( arg: ESQLAstItem, references: Pick @@ -479,3 +487,182 @@ export function extractTypeFromASTArg( } } } + +// @TODO: refactor this to be shared with validation +export function checkFunctionInvocationComplete( + func: ESQLFunction, + getExpressionType: (expression: ESQLAstItem) => SupportedDataType | 'unknown' +): { + complete: boolean; + reason?: 'tooFewArgs' | 'wrongTypes'; +} { + const fnDefinition = getFunctionDefinition(func.name); + if (!fnDefinition) { + return { complete: false }; + } + const cleanedArgs = removeMarkerArgFromArgsList(func)!.args; + const argLengthCheck = fnDefinition.signatures.some((def) => { + if (def.minParams && cleanedArgs.length >= def.minParams) { + return true; + } + if (cleanedArgs.length === def.params.length) { + return true; + } + return cleanedArgs.length >= def.params.filter(({ optional }) => !optional).length; + }); + if (!argLengthCheck) { + return { complete: false, reason: 'tooFewArgs' }; + } + if (fnDefinition.name === 'in' && Array.isArray(func.args[1]) && !func.args[1].length) { + return { complete: false, reason: 'tooFewArgs' }; + } + const hasCorrectTypes = fnDefinition.signatures.some((def) => { + return func.args.every((a, index) => { + return ( + (fnDefinition.name.endsWith('null') && def.params[index].type === 'any') || + def.params[index].type === getExpressionType(a) + ); + }); + }); + if (!hasCorrectTypes) { + return { complete: false, reason: 'wrongTypes' }; + } + return { complete: true }; +} + +/** + * This function is used to + * - suggest the next argument for an incomplete or incorrect binary operator expression (e.g. field > ) + * - suggest an operator to the right of a complete binary operator expression (e.g. field > 0 ) + * - suggest an operator to the right of a complete unary operator (e.g. field IS NOT NULL ) + * + * TODO — is this function doing too much? + */ +export async function getSuggestionsToRightOfOperatorExpression({ + queryText, + commandName, + optionName, + rootOperator: operator, + preferredExpressionType, + getExpressionType, + getColumnsByType, +}: { + queryText: string; + commandName: string; + optionName?: string; + rootOperator: ESQLFunction; + preferredExpressionType?: SupportedDataType; + getExpressionType: (expression: ESQLAstItem) => SupportedDataType | 'unknown'; + getColumnsByType: GetColumnsByTypeFn; +}) { + const suggestions = []; + const isFnComplete = checkFunctionInvocationComplete(operator, getExpressionType); + if (isFnComplete.complete) { + // i.e. ... | field > 0 + // i.e. ... | field + otherN + const operatorReturnType = getExpressionType(operator); + suggestions.push( + ...getOperatorSuggestions({ + command: commandName, + option: optionName, + // here we use the operator return type because we're suggesting operators that could + // accept the result of the existing operator as a left operand + leftParamType: + operatorReturnType === 'unknown' || operatorReturnType === 'unsupported' + ? 'any' + : operatorReturnType, + ignored: ['='], + }) + ); + } else { + // i.e. ... | field >= + // i.e. ... | field + + // i.e. ... | field and + + // Because it's an incomplete function, need to extract the type of the current argument + // and suggest the next argument based on types + + // pick the last arg and check its type to verify whether is incomplete for the given function + const cleanedArgs = removeMarkerArgFromArgsList(operator)!.args; + const leftArgType = getExpressionType(operator.args[cleanedArgs.length - 1]); + + if (isFnComplete.reason === 'tooFewArgs') { + const fnDef = getFunctionDefinition(operator.name); + if ( + fnDef?.signatures.every(({ params }) => + params.some(({ type }) => isArrayType(type as string)) + ) + ) { + suggestions.push(listCompleteItem); + } else { + const finalType = leftArgType || leftArgType || 'any'; + const supportedTypes = getSupportedTypesForBinaryOperators(fnDef, finalType as string); + + // this is a special case with AND/OR + // expression AND/OR + // technically another boolean value should be suggested, but it is a better experience + // to actually suggest a wider set of fields/functions + const typeToUse = + finalType === 'boolean' && getFunctionDefinition(operator.name)?.type === 'builtin' + ? ['any'] + : (supportedTypes as string[]); + + // TODO replace with fields callback + function suggestions + suggestions.push( + ...(await getFieldsOrFunctionsSuggestions( + typeToUse, + commandName, + optionName, + getColumnsByType, + { + functions: true, + fields: true, + } + )) + ); + } + } + + /** + * If the caller has supplied a preferred expression type, we can suggest operators that + * would move the user toward that expression type. + * + * e.g. if we have a preferred type of boolean and we have `timestamp > "2002" AND doubleField` + * this is an incorrect signature for AND because the left side is boolean and the right side is double + * + * Knowing that we prefer boolean expressions, we suggest operators that would accept doubleField as a left operand + * and also return a boolean value. + * + * I believe this is only used in WHERE and probably bears some rethinking. + */ + if (isFnComplete.reason === 'wrongTypes') { + if (leftArgType && preferredExpressionType) { + // suggest something to complete the operator + if ( + leftArgType !== preferredExpressionType && + isParameterType(leftArgType) && + isReturnType(preferredExpressionType) + ) { + suggestions.push( + ...getOperatorSuggestions({ + command: commandName, + leftParamType: leftArgType, + returnTypes: [preferredExpressionType], + }) + ); + } + } + } + } + return suggestions.map((s) => { + const overlap = getOverlapRange(queryText, s.text); + const offset = overlap.start === overlap.end ? 1 : 0; + return { + ...s, + rangeToReplace: { + start: overlap.start + offset, + end: overlap.end + offset, + }, + }; + }); +} diff --git a/packages/kbn-esql-validation-autocomplete/src/definitions/builtin.ts b/packages/kbn-esql-validation-autocomplete/src/definitions/builtin.ts index e71ed32e4c79d..3f5040efbcb10 100644 --- a/packages/kbn-esql-validation-autocomplete/src/definitions/builtin.ts +++ b/packages/kbn-esql-validation-autocomplete/src/definitions/builtin.ts @@ -528,7 +528,7 @@ const inFunctions: FunctionDefinition[] = [ ], })); -const logicFunctions: FunctionDefinition[] = [ +export const logicalOperators: FunctionDefinition[] = [ { name: 'and', description: i18n.translate('kbn-esql-validation-autocomplete.esql.definition.andDoc', { @@ -649,7 +649,7 @@ export const builtinFunctions: FunctionDefinition[] = [ ...comparisonFunctions, ...likeFunctions, ...inFunctions, - ...logicFunctions, + ...logicalOperators, ...nullFunctions, ...otherDefinitions, ]; diff --git a/packages/kbn-esql-validation-autocomplete/src/definitions/commands.ts b/packages/kbn-esql-validation-autocomplete/src/definitions/commands.ts index dc64c664a14fd..950dac5e2d50b 100644 --- a/packages/kbn-esql-validation-autocomplete/src/definitions/commands.ts +++ b/packages/kbn-esql-validation-autocomplete/src/definitions/commands.ts @@ -37,6 +37,7 @@ import { suggest as suggestForSort } from '../autocomplete/commands/sort'; import { suggest as suggestForKeep } from '../autocomplete/commands/keep'; import { suggest as suggestForDrop } from '../autocomplete/commands/drop'; import { suggest as suggestForStats } from '../autocomplete/commands/stats'; +import { suggest as suggestForWhere } from '../autocomplete/commands/where'; const statsValidator = (command: ESQLCommand) => { const messages: ESQLMessage[] = []; @@ -411,6 +412,7 @@ export const commandDefinitions: Array> = [ }, options: [], modes: [], + suggest: suggestForWhere, }, { name: 'dissect', diff --git a/packages/kbn-esql-validation-autocomplete/src/definitions/types.ts b/packages/kbn-esql-validation-autocomplete/src/definitions/types.ts index ff461683d8e76..a86811f535f8b 100644 --- a/packages/kbn-esql-validation-autocomplete/src/definitions/types.ts +++ b/packages/kbn-esql-validation-autocomplete/src/definitions/types.ts @@ -7,7 +7,13 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import type { ESQLCommand, ESQLCommandOption, ESQLFunction, ESQLMessage } from '@kbn/esql-ast'; +import type { + ESQLAstItem, + ESQLCommand, + ESQLCommandOption, + ESQLFunction, + ESQLMessage, +} from '@kbn/esql-ast'; import { GetColumnsByTypeFn, SuggestionRawDefinition } from '../autocomplete/types'; /** @@ -173,6 +179,7 @@ export interface CommandBaseDefinition { getColumnsByType: GetColumnsByTypeFn, columnExists: (column: string) => boolean, getSuggestedVariableName: () => string, + getExpressionType: (expression: ESQLAstItem | undefined) => SupportedDataType | 'unknown', getPreferences?: () => Promise<{ histogramBarTarget: number } | undefined> ) => Promise; /** @deprecated this property will disappear in the future */ diff --git a/packages/kbn-esql-validation-autocomplete/src/shared/helpers.test.ts b/packages/kbn-esql-validation-autocomplete/src/shared/helpers.test.ts index 97f35e1c66722..f880143108ce6 100644 --- a/packages/kbn-esql-validation-autocomplete/src/shared/helpers.test.ts +++ b/packages/kbn-esql-validation-autocomplete/src/shared/helpers.test.ts @@ -57,6 +57,10 @@ describe('getExpressionType', () => { return root.commands[1].args[0]; }; + test('empty expression', () => { + expect(getExpressionType(getASTForExpression(''))).toBe('unknown'); + }); + describe('literal expressions', () => { const cases: Array<{ expression: string; expectedType: SupportedDataType }> = [ { @@ -289,6 +293,19 @@ describe('getExpressionType', () => { it('supports COUNT(*)', () => { expect(getExpressionType(getASTForExpression('COUNT(*)'))).toBe('long'); }); + + it('accounts for the "any" parameter type', () => { + setTestFunctions([ + { + type: 'eval', + name: 'test', + description: 'Test function', + supportedCommands: ['eval'], + signatures: [{ params: [{ name: 'arg', type: 'any' }], returnType: 'keyword' }], + }, + ]); + expect(getExpressionType(getASTForExpression('test(1)'))).toBe('keyword'); + }); }); describe('lists', () => { diff --git a/packages/kbn-esql-validation-autocomplete/src/shared/helpers.ts b/packages/kbn-esql-validation-autocomplete/src/shared/helpers.ts index e86cb4f6ae8f2..2c864a487026c 100644 --- a/packages/kbn-esql-validation-autocomplete/src/shared/helpers.ts +++ b/packages/kbn-esql-validation-autocomplete/src/shared/helpers.ts @@ -630,6 +630,10 @@ export function findPreviousWord(text: string) { return words[words.length - 2]; } +export function endsInWhitespace(text: string) { + return /\s$/.test(text); +} + /** * Returns the word at the end of the text if there is one. * @param text @@ -805,10 +809,14 @@ export function getParamAtPosition( * Determines the type of the expression */ export function getExpressionType( - root: ESQLAstItem, + root: ESQLAstItem | undefined, fields?: Map, variables?: Map ): SupportedDataType | 'unknown' { + if (!root) { + return 'unknown'; + } + if (!isSingleItem(root)) { if (root.length === 0) { return 'unknown'; @@ -905,7 +913,8 @@ export function getExpressionType( const param = getParamAtPosition(signature, i); return ( param && - (param.type === argType || + (param.type === 'any' || + param.type === argType || (argType === 'keyword' && ['date', 'date_period'].includes(param.type))) ); });