diff --git a/packages/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/autocomplete.command.stats.test.ts b/packages/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/autocomplete.command.stats.test.ts index 13d471d53d7ec..1f6bd93359311 100644 --- a/packages/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/autocomplete.command.stats.test.ts +++ b/packages/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/autocomplete.command.stats.test.ts @@ -7,6 +7,8 @@ */ import { ESQL_COMMON_NUMERIC_TYPES, ESQL_NUMBER_TYPES } from '../../shared/esql_types'; +import { ADD_DATE_HISTOGRAM_SNIPPET } from '../factories'; +import { roundParameterTypes } from './constants'; import { setup, getFunctionSignaturesByReturnType, getFieldNamesByType } from './helpers'; const allAggFunctions = getFunctionSignaturesByReturnType('stats', 'any', { @@ -82,7 +84,6 @@ describe('autocomplete.suggest', () => { scalar: true, }).map((s) => ({ ...s, text: `${s.text},` })), ]); - const roundParameterTypes = ['double', 'integer', 'long', 'unsigned_long'] as const; await assertSuggestions('from a | stats round(/', [ ...getFunctionSignaturesByReturnType('stats', roundParameterTypes, { agg: true, @@ -213,6 +214,7 @@ describe('autocomplete.suggest', () => { const { assertSuggestions } = await setup(); const expected = [ 'var0 = ', + ADD_DATE_HISTOGRAM_SNIPPET, ...getFieldNamesByType('any').map((field) => `${field} `), ...allEvaFunctions, ...allGroupingFunctions, @@ -235,6 +237,7 @@ describe('autocomplete.suggest', () => { const fields = getFieldNamesByType('any').map((field) => `${field} `); await assertSuggestions('from a | stats a=c by d, /', [ 'var0 = ', + ADD_DATE_HISTOGRAM_SNIPPET, ...fields, ...allEvaFunctions, ...allGroupingFunctions, @@ -246,6 +249,7 @@ describe('autocomplete.suggest', () => { ]); await assertSuggestions('from a | stats avg(b) by c, /', [ 'var0 = ', + ADD_DATE_HISTOGRAM_SNIPPET, ...fields, ...getFunctionSignaturesByReturnType('eval', 'any', { scalar: true }), ...allGroupingFunctions, @@ -267,11 +271,13 @@ describe('autocomplete.suggest', () => { ...allGroupingFunctions, ]); await assertSuggestions('from a | stats avg(b) by var0 = /', [ + ADD_DATE_HISTOGRAM_SNIPPET, ...getFieldNamesByType('any').map((field) => `${field} `), ...allEvaFunctions, ...allGroupingFunctions, ]); await assertSuggestions('from a | stats avg(b) by c, var0 = /', [ + ADD_DATE_HISTOGRAM_SNIPPET, ...getFieldNamesByType('any').map((field) => `${field} `), ...allEvaFunctions, ...allGroupingFunctions, 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 new file mode 100644 index 0000000000000..a73109a9ad3dc --- /dev/null +++ b/packages/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/autocomplete.suggest.eval.test.ts @@ -0,0 +1,553 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +import { + setup, + getFunctionSignaturesByReturnType, + getFieldNamesByType, + createCustomCallbackMocks, + getLiteralsByType, + PartialSuggestionWithText, + getDateLiteralsByFieldType, +} from './helpers'; +import { ESQL_COMMON_NUMERIC_TYPES } from '../../shared/esql_types'; +import { evalFunctionDefinitions } from '../../definitions/functions'; +import { timeUnitsToSuggest } from '../../definitions/literals'; +import { + getCompatibleTypesToSuggestNext, + getValidFunctionSignaturesForPreviousArgs, + strictlyGetParamAtPosition, +} from '../helper'; +import { uniq } from 'lodash'; +import { + FunctionParameter, + FunctionReturnType, + SupportedDataType, + isFieldType, + isReturnType, + isSupportedDataType, +} from '../../definitions/types'; +import { fieldNameFromType } from '../../validation/validation.test'; +import { ESQLAstItem } from '@kbn/esql-ast'; +import { roundParameterTypes } from './constants'; + +const getTypesFromParamDefs = (paramDefs: FunctionParameter[]): SupportedDataType[] => + Array.from(new Set(paramDefs.map((p) => p.type))).filter( + isSupportedDataType + ) as SupportedDataType[]; + +describe('autocomplete.suggest', () => { + describe('eval', () => { + test('suggestions', async () => { + const { assertSuggestions } = await setup(); + await assertSuggestions('from a | eval /', [ + 'var0 = ', + ...getFieldNamesByType('any'), + ...getFunctionSignaturesByReturnType('eval', 'any', { scalar: true }), + ]); + await assertSuggestions('from a | eval doubleField /', [ + ...getFunctionSignaturesByReturnType('eval', 'any', { builtin: true, skipAssign: true }, [ + 'double', + ]), + ',', + '| ', + ]); + await assertSuggestions('from index | EVAL keywordField not /', [ + 'LIKE $0', + 'RLIKE $0', + 'IN $0', + ]); + await assertSuggestions('from index | EVAL keywordField NOT /', [ + 'LIKE $0', + 'RLIKE $0', + 'IN $0', + ]); + await assertSuggestions('from index | EVAL doubleField in /', ['( $0 )']); + await assertSuggestions( + 'from index | EVAL doubleField in (/)', + [ + ...getFieldNamesByType('double').filter((name) => name !== 'doubleField'), + ...getFunctionSignaturesByReturnType('eval', 'double', { scalar: true }), + ], + { triggerCharacter: '(' } + ); + await assertSuggestions('from index | EVAL doubleField not in /', ['( $0 )']); + await assertSuggestions('from index | EVAL not /', [ + ...getFieldNamesByType('boolean'), + ...getFunctionSignaturesByReturnType('eval', 'boolean', { scalar: true }), + ]); + await assertSuggestions( + 'from a | eval a=/', + [...getFunctionSignaturesByReturnType('eval', 'any', { scalar: true })], + { triggerCharacter: '=' } + ); + await assertSuggestions( + 'from a | eval a=abs(doubleField), b= /', + [...getFunctionSignaturesByReturnType('eval', 'any', { scalar: true })], + { triggerCharacter: '=' } + ); + await assertSuggestions('from a | eval a=doubleField, /', [ + 'var0 = ', + ...getFieldNamesByType('any'), + 'a', + ...getFunctionSignaturesByReturnType('eval', 'any', { scalar: true }), + ]); + await assertSuggestions( + 'from a | eval a=round(/)', + [ + ...getFieldNamesByType(roundParameterTypes), + ...getFunctionSignaturesByReturnType( + 'eval', + roundParameterTypes, + { scalar: true }, + undefined, + ['round'] + ), + ], + { triggerCharacter: '(' } + ); + await assertSuggestions( + 'from a | eval a=raund(/)', // note the typo in round + [], + { triggerCharacter: '(' } + ); + await assertSuggestions( + 'from a | eval a=raund(/', // note the typo in round + [] + ); + await assertSuggestions( + 'from a | eval raund(/', // note the typo in round + [] + ); + await assertSuggestions( + 'from a | eval raund(5, /', // note the typo in round + [], + { triggerCharacter: '(' } + ); + await assertSuggestions( + 'from a | eval var0 = raund(5, /', // note the typo in round + [], + { triggerCharacter: '(' } + ); + await assertSuggestions('from a | eval a=round(doubleField) /', [ + ',', + '| ', + ...getFunctionSignaturesByReturnType('eval', 'any', { builtin: true, skipAssign: true }, [ + 'double', + ]), + ]); + await assertSuggestions( + 'from a | eval a=round(doubleField, /', + [ + ...getFieldNamesByType('integer'), + ...getFunctionSignaturesByReturnType('eval', 'integer', { scalar: true }, undefined, [ + 'round', + ]), + ], + { triggerCharacter: '(' } + ); + await assertSuggestions( + 'from a | eval round(doubleField, /', + [ + ...getFieldNamesByType('integer'), + ...getFunctionSignaturesByReturnType('eval', 'integer', { scalar: true }, undefined, [ + 'round', + ]), + ], + { triggerCharacter: '(' } + ); + await assertSuggestions('from a | eval a=round(doubleField),/', [ + 'var0 = ', + ...getFieldNamesByType('any'), + 'a', + ...getFunctionSignaturesByReturnType('eval', 'any', { scalar: true }), + ]); + await assertSuggestions('from a | eval a=round(doubleField) + /', [ + ...getFieldNamesByType(ESQL_COMMON_NUMERIC_TYPES), + ...getFunctionSignaturesByReturnType('eval', ESQL_COMMON_NUMERIC_TYPES, { + scalar: true, + }), + ]); + await assertSuggestions('from a | eval a=round(doubleField)+ /', [ + ...getFieldNamesByType(ESQL_COMMON_NUMERIC_TYPES), + ...getFunctionSignaturesByReturnType('eval', ESQL_COMMON_NUMERIC_TYPES, { + scalar: true, + }), + ]); + await assertSuggestions('from a | eval a=doubleField+ /', [ + ...getFieldNamesByType(ESQL_COMMON_NUMERIC_TYPES), + ...getFunctionSignaturesByReturnType('eval', ESQL_COMMON_NUMERIC_TYPES, { + scalar: true, + }), + ]); + await assertSuggestions('from a | eval a=`any#Char$Field`+ /', [ + ...getFieldNamesByType(ESQL_COMMON_NUMERIC_TYPES), + ...getFunctionSignaturesByReturnType('eval', ESQL_COMMON_NUMERIC_TYPES, { + scalar: true, + }), + ]); + await assertSuggestions( + 'from a | stats avg(doubleField) by keywordField | eval /', + [ + 'var0 = ', + '`avg(doubleField)`', + ...getFunctionSignaturesByReturnType('eval', 'any', { scalar: true }), + ], + { + triggerCharacter: ' ', + // make aware EVAL of the previous STATS command + callbacks: createCustomCallbackMocks([], undefined, undefined), + } + ); + await assertSuggestions( + 'from a | eval abs(doubleField) + 1 | eval /', + [ + 'var0 = ', + ...getFieldNamesByType('any'), + '`abs(doubleField) + 1`', + ...getFunctionSignaturesByReturnType('eval', 'any', { scalar: true }), + ], + { triggerCharacter: ' ' } + ); + await assertSuggestions( + 'from a | stats avg(doubleField) by keywordField | eval /', + [ + 'var0 = ', + '`avg(doubleField)`', + ...getFunctionSignaturesByReturnType('eval', 'any', { scalar: true }), + ], + { + triggerCharacter: ' ', + callbacks: createCustomCallbackMocks( + [{ name: 'avg_doubleField_', type: 'double' }], + undefined, + undefined + ), + } + // make aware EVAL of the previous STATS command with the buggy field name from expression + ); + await assertSuggestions( + 'from a | stats avg(doubleField), avg(kubernetes.something.something) by keywordField | eval /', + [ + 'var0 = ', + '`avg(doubleField)`', + '`avg(kubernetes.something.something)`', + ...getFunctionSignaturesByReturnType('eval', 'any', { scalar: true }), + ], + { + triggerCharacter: ' ', + // make aware EVAL of the previous STATS command with the buggy field name from expression + callbacks: createCustomCallbackMocks( + [{ name: 'avg_doubleField_', type: 'double' }], + undefined, + undefined + ), + } + ); + + await assertSuggestions( + 'from a | eval a=round(doubleField), b=round(/)', + [ + ...getFieldNamesByType(roundParameterTypes), + ...getFunctionSignaturesByReturnType( + 'eval', + roundParameterTypes, + { scalar: true }, + undefined, + ['round'] + ), + ], + { triggerCharacter: '(' } + ); + // test that comma is correctly added to the suggestions if minParams is not reached yet + await assertSuggestions('from a | eval a=concat( /', [ + ...getFieldNamesByType(['text', 'keyword']).map((v) => `${v}, `), + ...getFunctionSignaturesByReturnType( + 'eval', + ['text', 'keyword'], + { scalar: true }, + undefined, + ['concat'] + ).map((v) => ({ ...v, text: `${v.text},` })), + ]); + await assertSuggestions( + 'from a | eval a=concat(textField, /', + [ + ...getFieldNamesByType(['text', 'keyword']), + ...getFunctionSignaturesByReturnType( + 'eval', + ['text', 'keyword'], + { scalar: true }, + undefined, + ['concat'] + ), + ], + { triggerCharacter: ' ' } + ); + // test that the arg type is correct after minParams + await assertSuggestions('from a | eval a=cidr_match(ipField, textField, /', [], { + triggerCharacter: ' ', + }); + // test that comma is correctly added to the suggestions if minParams is not reached yet + await assertSuggestions('from a | eval a=cidr_match(/', [ + ...getFieldNamesByType('ip').map((v) => `${v}, `), + ...getFunctionSignaturesByReturnType('eval', 'ip', { scalar: true }, undefined, [ + 'cidr_match', + ]).map((v) => ({ ...v, text: `${v.text},` })), + ]); + await assertSuggestions( + 'from a | eval a=cidr_match(ipField, /', + [ + ...getFieldNamesByType(['text', 'keyword']), + ...getFunctionSignaturesByReturnType( + 'eval', + ['text', 'keyword'], + { scalar: true }, + undefined, + ['cidr_match'] + ), + ], + { triggerCharacter: ' ' } + ); + // test deep function nesting suggestions (and check that the same function is not suggested) + // round(round( + // round(round(round( + // etc... + + for (const nesting of [1, 2, 3, 4]) { + await assertSuggestions( + `from a | eval a=${Array(nesting).fill('round(/').join('')}`, + [ + ...getFieldNamesByType(roundParameterTypes), + ...getFunctionSignaturesByReturnType( + 'eval', + roundParameterTypes, + { scalar: true }, + undefined, + ['round'] + ), + ], + { triggerCharacter: '(' } + ); + } + + const absParameterTypes = ['double', 'integer', 'long', 'unsigned_long'] as const; + + // Smoke testing for suggestions in previous position than the end of the statement + await assertSuggestions('from a | eval var0 = abs(doubleField) / | eval abs(var0)', [ + ',', + '| ', + ...getFunctionSignaturesByReturnType('eval', 'any', { builtin: true, skipAssign: true }, [ + 'double', + ]), + ]); + await assertSuggestions('from a | eval var0 = abs(b/) | eval abs(var0)', [ + ...getFieldNamesByType(absParameterTypes), + ...getFunctionSignaturesByReturnType( + 'eval', + absParameterTypes, + { scalar: true }, + undefined, + ['abs'] + ), + ]); + }); + + describe('eval functions', () => { + // // Test suggestions for each possible param, within each signature variation, for each function + for (const fn of evalFunctionDefinitions) { + // skip this fn for the moment as it's quite hard to test + // if (!['bucket', 'date_extract', 'date_diff', 'case'].includes(fn.name)) { + if (!['bucket', 'date_extract', 'date_diff', 'case'].includes(fn.name)) { + test(`${fn.name}`, async () => { + const testedCases = new Set(); + + const { assertSuggestions } = await setup(); + + for (const signature of fn.signatures) { + // @ts-expect-error Partial type + const enrichedArgs: Array< + ESQLAstItem & { + dataType: string; + } + > = signature.params.map(({ type }) => ({ + type: 'column', + dataType: type, + })); + + // Starting at -1 to include empty case e.g. to_string( / ) + for (let i = -1; i < signature.params.length; i++) { + const param = signature.params[i]; + if (param?.type === 'time_duration') { + continue; + } + const testCase = `${fn.name}(${signature.params + .slice(0, i + 1) + .map((p) => + p.type === 'time_literal' + ? '1 year,' + : `${ + typeof p.type === 'string' && isFieldType(p.type) + ? fieldNameFromType(p.type) + : 'field' + }, ` + ) + .join('')} / )`; + + // Avoid duplicate test cases that might start with first params that are exactly the same + if (testedCases.has(testCase)) { + continue; + } + testedCases.add(testCase); + + const validSignatures = getValidFunctionSignaturesForPreviousArgs( + fn, + enrichedArgs, + i + 1 + ); + // Retrieve unique of types that are compatiable for the current arg + const typesToSuggestNext = getCompatibleTypesToSuggestNext(fn, enrichedArgs, i + 1); + + const hasMoreMandatoryArgs = !validSignatures + // Types available to suggest next after this argument is completed + .map((sig) => strictlyGetParamAtPosition(sig, i + 2)) + // when a param is null, it means param is optional + // If there's at least one param that is optional, then + // no need to suggest comma + .some((p) => p === null || p?.optional === true); + + // Wehther to prepend comma to suggestion string + // E.g. if true, "fieldName" -> "fieldName, " + const shouldAddComma = hasMoreMandatoryArgs && fn.type !== 'builtin'; + + const constantOnlyParamDefs = typesToSuggestNext.filter( + (p) => p.constantOnly || /_literal/.test(p.type as string) + ); + + const suggestedConstants = uniq( + typesToSuggestNext + .map((d) => d.literalSuggestions || d.literalOptions) + .filter((d) => d) + .flat() + ); + + const addCommaIfRequired = (s: string | PartialSuggestionWithText) => { + if (!shouldAddComma || s === '' || (typeof s === 'object' && s.text === '')) { + return s; + } + return typeof s === 'string' ? `${s}, ` : { ...s, text: `${s.text},` }; + }; + + const expected = suggestedConstants?.length + ? suggestedConstants.map( + (option) => `"${option}"${hasMoreMandatoryArgs ? ', ' : ''}` + ) + : [ + ...getDateLiteralsByFieldType( + getTypesFromParamDefs(typesToSuggestNext).filter(isFieldType) + ), + ...getFieldNamesByType( + getTypesFromParamDefs(typesToSuggestNext).filter(isFieldType) + ), + ...getFunctionSignaturesByReturnType( + 'eval', + getTypesFromParamDefs(typesToSuggestNext).filter( + isReturnType + ) as FunctionReturnType[], + { scalar: true }, + undefined, + [fn.name] + ), + ...getLiteralsByType(getTypesFromParamDefs(constantOnlyParamDefs)), + ].map(addCommaIfRequired); + + await assertSuggestions(`from a | eval ${testCase}`, expected, { + triggerCharacter: ' ', + }); + + await assertSuggestions(`from a | eval var0 = ${testCase}`, expected, { + triggerCharacter: ' ', + }); + } + } + }); + } + + // The above test fails cause it expects nested functions like + // DATE_EXTRACT(concat("aligned_day_","of_week_in_month"), date) to also be suggested + // which is actually valid according to func signature + // but currently, our autocomplete only suggests the literal suggestions + if (['date_extract', 'date_diff'].includes(fn.name)) { + test(`${fn.name}`, async () => { + const { assertSuggestions } = await setup(); + const firstParam = fn.signatures[0].params[0]; + const suggestedConstants = firstParam?.literalSuggestions || firstParam?.literalOptions; + const requiresMoreArgs = true; + + await assertSuggestions( + `from a | eval ${fn.name}(/`, + suggestedConstants?.length + ? [ + ...suggestedConstants.map( + (option) => `"${option}"${requiresMoreArgs ? ', ' : ''}` + ), + ] + : [] + ); + }); + } + } + }); + + test('date math', async () => { + const { assertSuggestions } = await setup(); + const dateSuggestions = timeUnitsToSuggest.map(({ name }) => name); + + // Eval bucket is not a valid expression + await assertSuggestions('from a | eval var0 = bucket(@timestamp, /', [], { + triggerCharacter: ' ', + }); + + // If a literal number is detected then suggest also date period keywords + await assertSuggestions( + 'from a | eval a = 1 /', + [ + ...dateSuggestions, + ',', + '| ', + ...getFunctionSignaturesByReturnType('eval', 'any', { builtin: true, skipAssign: true }, [ + 'integer', + ]), + ], + { 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(/)', + getLiteralsByType('time_literal').map((t) => `${t}, `), + { triggerCharacter: '(' } + ); + await assertSuggestions( + 'from a | eval var0=date_trunc(2 /)', + [...dateSuggestions.map((t) => `${t}, `), ','], + { triggerCharacter: ' ' } + ); + }); + }); +}); diff --git a/packages/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/constants.ts b/packages/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/constants.ts new file mode 100644 index 0000000000000..10079be511da9 --- /dev/null +++ b/packages/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/constants.ts @@ -0,0 +1,11 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +export const roundParameterTypes = ['double', 'integer', 'long', 'unsigned_long'] as const; +export const powParameterTypes = ['double', 'integer', 'long', 'unsigned_long'] as const; +export const log10ParameterTypes = ['double', 'integer', 'long', 'unsigned_long'] as const; diff --git a/packages/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/helpers.ts b/packages/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/helpers.ts index a05e8059d1308..96cb89c774776 100644 --- a/packages/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/helpers.ts +++ b/packages/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/helpers.ts @@ -319,22 +319,28 @@ export const setup = async (caret = '/') => { expected: Array, opts?: SuggestOptions ) => { - const result = await suggest(query, opts); - const resultTexts = [...result.map((suggestion) => suggestion.text)].sort(); + try { + const result = await suggest(query, opts); + const resultTexts = [...result.map((suggestion) => suggestion.text)].sort(); - const expectedTexts = expected - .map((suggestion) => (typeof suggestion === 'string' ? suggestion : suggestion.text ?? '')) - .sort(); + const expectedTexts = expected + .map((suggestion) => (typeof suggestion === 'string' ? suggestion : suggestion.text ?? '')) + .sort(); - expect(resultTexts).toEqual(expectedTexts); + expect(resultTexts).toEqual(expectedTexts); - const expectedNonStringSuggestions = expected.filter( - (suggestion) => typeof suggestion !== 'string' - ) as PartialSuggestionWithText[]; + const expectedNonStringSuggestions = expected.filter( + (suggestion) => typeof suggestion !== 'string' + ) as PartialSuggestionWithText[]; - for (const expectedSuggestion of expectedNonStringSuggestions) { - const suggestion = result.find((s) => s.text === expectedSuggestion.text); - expect(suggestion).toEqual(expect.objectContaining(expectedSuggestion)); + for (const expectedSuggestion of expectedNonStringSuggestions) { + const suggestion = result.find((s) => s.text === expectedSuggestion.text); + expect(suggestion).toEqual(expect.objectContaining(expectedSuggestion)); + } + } catch (error) { + // eslint-disable-next-line no-console + console.error(`Failed query\n-------------\n${query}`); + throw error; } }; 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 e8b1c42db2aed..9f558c7c17470 100644 --- a/packages/kbn-esql-validation-autocomplete/src/autocomplete/autocomplete.test.ts +++ b/packages/kbn-esql-validation-autocomplete/src/autocomplete/autocomplete.test.ts @@ -11,29 +11,17 @@ import { evalFunctionDefinitions } from '../definitions/functions'; import { timeUnitsToSuggest } from '../definitions/literals'; import { commandDefinitions as unmodifiedCommandDefinitions } from '../definitions/commands'; import { + ADD_DATE_HISTOGRAM_SNIPPET, getSafeInsertText, - getUnitDuration, TIME_SYSTEM_PARAMS, TRIGGER_SUGGESTION_COMMAND, } from './factories'; -import { camelCase, partition } from 'lodash'; +import { camelCase } from 'lodash'; import { getAstAndSyntaxErrors } from '@kbn/esql-ast'; -import { - FunctionParameter, - FunctionReturnType, - isFieldType, - isReturnType, - isSupportedDataType, - SupportedDataType, -} from '../definitions/types'; -import { getParamAtPosition } from './helper'; -import { nonNullable } from '../shared/helpers'; import { policies, getFunctionSignaturesByReturnType, getFieldNamesByType, - getLiteralsByType, - getDateLiteralsByFieldType, createCustomCallbackMocks, createCompletionContext, getPolicyFields, @@ -43,10 +31,7 @@ import { } from './__tests__/helpers'; import { METADATA_FIELDS } from '../shared/constants'; import { ESQL_COMMON_NUMERIC_TYPES, ESQL_STRING_TYPES } from '../shared/esql_types'; - -const roundParameterTypes = ['double', 'integer', 'long', 'unsigned_long'] as const; -const powParameterTypes = ['double', 'integer', 'long', 'unsigned_long'] as const; -const log10ParameterTypes = ['double', 'integer', 'long', 'unsigned_long'] as const; +import { log10ParameterTypes, powParameterTypes } from './__tests__/constants'; const commandDefinitions = unmodifiedCommandDefinitions.filter(({ hidden }) => !hidden); describe('autocomplete', () => { @@ -429,459 +414,7 @@ describe('autocomplete', () => { } }); - describe('eval', () => { - testSuggestions('from a | eval /', [ - 'var0 = ', - ...getFieldNamesByType('any'), - ...getFunctionSignaturesByReturnType('eval', 'any', { scalar: true }), - ]); - testSuggestions('from a | eval doubleField /', [ - ...getFunctionSignaturesByReturnType('eval', 'any', { builtin: true, skipAssign: true }, [ - 'double', - ]), - ',', - '| ', - ]); - testSuggestions('from index | EVAL keywordField not /', ['LIKE $0', 'RLIKE $0', 'IN $0']); - testSuggestions('from index | EVAL keywordField NOT /', ['LIKE $0', 'RLIKE $0', 'IN $0']); - testSuggestions('from index | EVAL doubleField in /', ['( $0 )']); - testSuggestions( - 'from index | EVAL doubleField in (/)', - [ - ...getFieldNamesByType('double').filter((name) => name !== 'doubleField'), - ...getFunctionSignaturesByReturnType('eval', 'double', { scalar: true }), - ], - '(' - ); - testSuggestions('from index | EVAL doubleField not in /', ['( $0 )']); - testSuggestions('from index | EVAL not /', [ - ...getFieldNamesByType('boolean'), - ...getFunctionSignaturesByReturnType('eval', 'boolean', { scalar: true }), - ]); - testSuggestions( - 'from a | eval a=/', - [...getFunctionSignaturesByReturnType('eval', 'any', { scalar: true })], - '=' - ); - testSuggestions( - 'from a | eval a=abs(doubleField), b= /', - [...getFunctionSignaturesByReturnType('eval', 'any', { scalar: true })], - '=' - ); - testSuggestions('from a | eval a=doubleField, /', [ - 'var0 = ', - ...getFieldNamesByType('any'), - 'a', - ...getFunctionSignaturesByReturnType('eval', 'any', { scalar: true }), - ]); - testSuggestions( - 'from a | eval a=round(/)', - [ - ...getFieldNamesByType(roundParameterTypes), - ...getFunctionSignaturesByReturnType( - 'eval', - roundParameterTypes, - { scalar: true }, - undefined, - ['round'] - ), - ], - '(' - ); - testSuggestions( - 'from a | eval a=raund(/)', // note the typo in round - [], - '(' - ); - testSuggestions( - 'from a | eval a=raund(/', // note the typo in round - [] - ); - testSuggestions( - 'from a | eval raund(/', // note the typo in round - [] - ); - testSuggestions( - 'from a | eval raund(5, /', // note the typo in round - [], - ' ' - ); - testSuggestions( - 'from a | eval var0 = raund(5, /', // note the typo in round - [], - ' ' - ); - testSuggestions('from a | eval a=round(doubleField) /', [ - ',', - '| ', - ...getFunctionSignaturesByReturnType('eval', 'any', { builtin: true, skipAssign: true }, [ - 'double', - ]), - ]); - testSuggestions( - 'from a | eval a=round(doubleField, /', - [ - ...getFieldNamesByType('integer'), - ...getFunctionSignaturesByReturnType('eval', 'integer', { scalar: true }, undefined, [ - 'round', - ]), - ], - ' ' - ); - testSuggestions( - 'from a | eval round(doubleField, /', - [ - ...getFieldNamesByType('integer'), - ...getFunctionSignaturesByReturnType('eval', 'integer', { scalar: true }, undefined, [ - 'round', - ]), - ], - ' ' - ); - testSuggestions('from a | eval a=round(doubleField),/', [ - 'var0 = ', - ...getFieldNamesByType('any'), - 'a', - ...getFunctionSignaturesByReturnType('eval', 'any', { scalar: true }), - ]); - testSuggestions('from a | eval a=round(doubleField) + /', [ - ...getFieldNamesByType(ESQL_COMMON_NUMERIC_TYPES), - ...getFunctionSignaturesByReturnType('eval', ESQL_COMMON_NUMERIC_TYPES, { - scalar: true, - }), - ]); - testSuggestions('from a | eval a=round(doubleField)+ /', [ - ...getFieldNamesByType(ESQL_COMMON_NUMERIC_TYPES), - ...getFunctionSignaturesByReturnType('eval', ESQL_COMMON_NUMERIC_TYPES, { - scalar: true, - }), - ]); - testSuggestions('from a | eval a=doubleField+ /', [ - ...getFieldNamesByType(ESQL_COMMON_NUMERIC_TYPES), - ...getFunctionSignaturesByReturnType('eval', ESQL_COMMON_NUMERIC_TYPES, { - scalar: true, - }), - ]); - testSuggestions('from a | eval a=`any#Char$Field`+ /', [ - ...getFieldNamesByType(ESQL_COMMON_NUMERIC_TYPES), - ...getFunctionSignaturesByReturnType('eval', ESQL_COMMON_NUMERIC_TYPES, { - scalar: true, - }), - ]); - testSuggestions( - 'from a | stats avg(doubleField) by keywordField | eval /', - [ - 'var0 = ', - '`avg(doubleField)`', - ...getFunctionSignaturesByReturnType('eval', 'any', { scalar: true }), - ], - ' ', - // make aware EVAL of the previous STATS command - [[], undefined, undefined] - ); - testSuggestions( - 'from a | eval abs(doubleField) + 1 | eval /', - [ - 'var0 = ', - ...getFieldNamesByType('any'), - '`abs(doubleField) + 1`', - ...getFunctionSignaturesByReturnType('eval', 'any', { scalar: true }), - ], - ' ' - ); - testSuggestions( - 'from a | stats avg(doubleField) by keywordField | eval /', - [ - 'var0 = ', - '`avg(doubleField)`', - ...getFunctionSignaturesByReturnType('eval', 'any', { scalar: true }), - ], - ' ', - // make aware EVAL of the previous STATS command with the buggy field name from expression - [[{ name: 'avg_doubleField_', type: 'double' }], undefined, undefined] - ); - testSuggestions( - 'from a | stats avg(doubleField), avg(kubernetes.something.something) by keywordField | eval /', - [ - 'var0 = ', - '`avg(doubleField)`', - '`avg(kubernetes.something.something)`', - ...getFunctionSignaturesByReturnType('eval', 'any', { scalar: true }), - ], - ' ', - // make aware EVAL of the previous STATS command with the buggy field name from expression - [ - [ - { name: 'avg_doubleField_', type: 'double' }, - { name: 'avg_kubernetes.something.something_', type: 'double' }, - ], - undefined, - undefined, - ] - ); - - testSuggestions( - 'from a | eval a=round(doubleField), b=round(/)', - [ - ...getFieldNamesByType(roundParameterTypes), - ...getFunctionSignaturesByReturnType( - 'eval', - roundParameterTypes, - { scalar: true }, - undefined, - ['round'] - ), - ], - '(' - ); - // test that comma is correctly added to the suggestions if minParams is not reached yet - testSuggestions('from a | eval a=concat( /', [ - ...getFieldNamesByType(['text', 'keyword']).map((v) => `${v}, `), - ...getFunctionSignaturesByReturnType( - 'eval', - ['text', 'keyword'], - { scalar: true }, - undefined, - ['concat'] - ).map((v) => ({ ...v, text: `${v.text},` })), - ]); - testSuggestions( - 'from a | eval a=concat(textField, /', - [ - ...getFieldNamesByType(['text', 'keyword']), - ...getFunctionSignaturesByReturnType( - 'eval', - ['text', 'keyword'], - { scalar: true }, - undefined, - ['concat'] - ), - ], - ' ' - ); - // test that the arg type is correct after minParams - testSuggestions( - 'from a | eval a=cidr_match(ipField, textField, /', - [ - ...getFieldNamesByType('text'), - ...getFunctionSignaturesByReturnType('eval', 'text', { scalar: true }, undefined, [ - 'cidr_match', - ]), - ], - ' ' - ); - // test that comma is correctly added to the suggestions if minParams is not reached yet - testSuggestions('from a | eval a=cidr_match(/', [ - ...getFieldNamesByType('ip').map((v) => `${v}, `), - ...getFunctionSignaturesByReturnType('eval', 'ip', { scalar: true }, undefined, [ - 'cidr_match', - ]).map((v) => ({ ...v, text: `${v.text},` })), - ]); - testSuggestions( - 'from a | eval a=cidr_match(ipField, /', - [ - ...getFieldNamesByType(['text', 'keyword']), - ...getFunctionSignaturesByReturnType( - 'eval', - ['text', 'keyword'], - { scalar: true }, - undefined, - ['cidr_match'] - ), - ], - ' ' - ); - // test deep function nesting suggestions (and check that the same function is not suggested) - // round(round( - // round(round(round( - // etc... - - for (const nesting of [1, 2, 3, 4]) { - testSuggestions( - `from a | eval a=${Array(nesting).fill('round(/').join('')}`, - [ - ...getFieldNamesByType(roundParameterTypes), - ...getFunctionSignaturesByReturnType( - 'eval', - roundParameterTypes, - { scalar: true }, - undefined, - ['round'] - ), - ], - '(' - ); - } - - const absParameterTypes = ['double', 'integer', 'long', 'unsigned_long'] as const; - - // Smoke testing for suggestions in previous position than the end of the statement - testSuggestions('from a | eval var0 = abs(doubleField) / | eval abs(var0)', [ - ',', - '| ', - ...getFunctionSignaturesByReturnType('eval', 'any', { builtin: true, skipAssign: true }, [ - 'double', - ]), - ]); - testSuggestions('from a | eval var0 = abs(b/) | eval abs(var0)', [ - ...getFieldNamesByType(absParameterTypes), - ...getFunctionSignaturesByReturnType('eval', absParameterTypes, { scalar: true }, undefined, [ - 'abs', - ]), - ]); - - // Test suggestions for each possible param, within each signature variation, for each function - for (const fn of evalFunctionDefinitions) { - // skip this fn for the moment as it's quite hard to test - if (!['bucket', 'date_extract', 'date_diff', 'case'].includes(fn.name)) { - for (const signature of fn.signatures) { - signature.params.forEach((param, i) => { - if (i < signature.params.length) { - // This ref signature thing is probably wrong in a few cases, but it matches - // the logic in getFunctionArgsSuggestions. They should both be updated - const refSignature = fn.signatures[0]; - const requiresMoreArgs = - i + 1 < (refSignature.minParams ?? 0) || - refSignature.params.filter(({ optional }, j) => !optional && j > i).length > 0; - - const allParamDefs = fn.signatures - .map((s) => getParamAtPosition(s, i)) - .filter(nonNullable); - - // get all possible types for this param - const [constantOnlyParamDefs, acceptsFieldParamDefs] = partition( - allParamDefs, - (p) => p.constantOnly || /_literal/.test(p.type as string) - ); - - const getTypesFromParamDefs = (paramDefs: FunctionParameter[]): SupportedDataType[] => - Array.from(new Set(paramDefs.map((p) => p.type))).filter( - isSupportedDataType - ) as SupportedDataType[]; - - const suggestedConstants = param.literalSuggestions || param.literalOptions; - - const addCommaIfRequired = (s: string | PartialSuggestionWithText) => { - // don't add commas to the empty string or if there are no more required args - if (!requiresMoreArgs || s === '' || (typeof s === 'object' && s.text === '')) { - return s; - } - return typeof s === 'string' ? `${s}, ` : { ...s, text: `${s.text},` }; - }; - - testSuggestions( - `from a | eval ${fn.name}(${Array(i).fill('field').join(', ')}${i ? ',' : ''} /)`, - suggestedConstants?.length - ? suggestedConstants.map((option) => `"${option}"${requiresMoreArgs ? ', ' : ''}`) - : [ - ...getDateLiteralsByFieldType( - getTypesFromParamDefs(acceptsFieldParamDefs).filter(isFieldType) - ), - ...getFieldNamesByType( - getTypesFromParamDefs(acceptsFieldParamDefs).filter(isFieldType) - ), - ...getFunctionSignaturesByReturnType( - 'eval', - getTypesFromParamDefs(acceptsFieldParamDefs).filter( - isReturnType - ) as FunctionReturnType[], - { scalar: true }, - undefined, - [fn.name] - ), - ...getLiteralsByType(getTypesFromParamDefs(constantOnlyParamDefs)), - ].map(addCommaIfRequired), - ' ' - ); - testSuggestions( - `from a | eval var0 = ${fn.name}(${Array(i).fill('field').join(', ')}${ - i ? ',' : '' - } /)`, - suggestedConstants?.length - ? suggestedConstants.map((option) => `"${option}"${requiresMoreArgs ? ', ' : ''}`) - : [ - ...getDateLiteralsByFieldType( - getTypesFromParamDefs(acceptsFieldParamDefs).filter(isFieldType) - ), - ...getFieldNamesByType( - getTypesFromParamDefs(acceptsFieldParamDefs).filter(isFieldType) - ), - ...getFunctionSignaturesByReturnType( - 'eval', - getTypesFromParamDefs(acceptsFieldParamDefs) as FunctionReturnType[], - { scalar: true }, - undefined, - [fn.name] - ), - ...getLiteralsByType(getTypesFromParamDefs(constantOnlyParamDefs)), - ].map(addCommaIfRequired), - ' ' - ); - } - }); - } - } - - // The above test fails cause it expects nested functions like - // DATE_EXTRACT(concat("aligned_day_","of_week_in_month"), date) to also be suggested - // which is actually valid according to func signature - // but currently, our autocomplete only suggests the literal suggestions - if (['date_extract', 'date_diff'].includes(fn.name)) { - const firstParam = fn.signatures[0].params[0]; - const suggestedConstants = firstParam?.literalSuggestions || firstParam?.literalOptions; - const requiresMoreArgs = true; - - testSuggestions( - `from a | eval ${fn.name}(/`, - suggestedConstants?.length - ? [...suggestedConstants.map((option) => `"${option}"${requiresMoreArgs ? ', ' : ''}`)] - : [] - ); - } - } - - testSuggestions('from a | eval var0 = bucket(@timestamp, /', getUnitDuration(1), ' '); - - describe('date math', () => { - const dateSuggestions = timeUnitsToSuggest.map(({ name }) => name); - // If a literal number is detected then suggest also date period keywords - testSuggestions( - 'from a | eval a = 1 /', - [ - ...dateSuggestions, - ',', - '| ', - ...getFunctionSignaturesByReturnType('eval', 'any', { builtin: true, skipAssign: true }, [ - 'integer', - ]), - ], - ' ' - ); - testSuggestions('from a | eval a = 1 year /', [',', '| ', 'IS NOT NULL', 'IS NULL']); - testSuggestions('from a | eval a = 1 day + 2 /', [',', '| ']); - testSuggestions( - 'from a | eval 1 day + 2 /', - [ - ...dateSuggestions, - ...getFunctionSignaturesByReturnType('eval', 'any', { builtin: true, skipAssign: true }, [ - 'integer', - ]), - ], - ' ' - ); - testSuggestions( - 'from a | eval var0=date_trunc(/)', - getLiteralsByType('time_literal').map((t) => `${t}, `), - '(' - ); - testSuggestions( - 'from a | eval var0=date_trunc(2 /)', - [...dateSuggestions.map((t) => `${t}, `), ','], - ' ' - ); - }); - }); - + // @TODO: get updated eval block from main describe('values suggestions', () => { testSuggestions('FROM "a/"', ['a ', 'b '], undefined, [ , @@ -1110,6 +643,7 @@ describe('autocomplete', () => { // STATS argument BY expression testSuggestions('FROM index1 | STATS field BY f/', [ 'var0 = ', + ADD_DATE_HISTOGRAM_SNIPPET, ...getFunctionSignaturesByReturnType('stats', 'any', { grouping: true, scalar: true }), ...getFieldNamesByType('any').map((field) => `${field} `), ]); @@ -1322,6 +856,7 @@ describe('autocomplete', () => { 'by' ); testSuggestions('FROM a | STATS AVG(numberField) BY /', [ + ADD_DATE_HISTOGRAM_SNIPPET, attachTriggerCommand('var0 = '), ...getFieldNamesByType('any') .map((field) => `${field} `) @@ -1331,6 +866,7 @@ describe('autocomplete', () => { // STATS argument BY assignment (checking field suggestions) testSuggestions('FROM a | STATS AVG(numberField) BY var0 = /', [ + ADD_DATE_HISTOGRAM_SNIPPET, ...getFieldNamesByType('any') .map((field) => `${field} `) .map(attachTriggerCommand), diff --git a/packages/kbn-esql-validation-autocomplete/src/autocomplete/autocomplete.ts b/packages/kbn-esql-validation-autocomplete/src/autocomplete/autocomplete.ts index 556e4860738d7..b2808ee2c1156 100644 --- a/packages/kbn-esql-validation-autocomplete/src/autocomplete/autocomplete.ts +++ b/packages/kbn-esql-validation-autocomplete/src/autocomplete/autocomplete.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import uniqBy from 'lodash/uniqBy'; +import { uniq, uniqBy } from 'lodash'; import type { AstProviderFn, ESQLAstItem, @@ -16,8 +16,8 @@ import type { ESQLLiteral, ESQLSingleAstItem, } from '@kbn/esql-ast'; -import { partition } from 'lodash'; -import { ESQL_NUMBER_TYPES, compareTypesWithLiterals, isNumericType } from '../shared/esql_types'; +import { i18n } from '@kbn/i18n'; +import { ESQL_NUMBER_TYPES, isNumericType } from '../shared/esql_types'; import type { EditorContext, SuggestionRawDefinition } from './types'; import { getColumnForASTNode, @@ -78,6 +78,7 @@ import { getDateLiterals, buildFieldsDefinitionsWithMetadata, TRIGGER_SUGGESTION_COMMAND, + ADD_DATE_HISTOGRAM_SNIPPET, } from './factories'; import { EDITOR_MARKER, SINGLE_BACKTICK, METADATA_FIELDS } from '../shared/constants'; import { getAstContext, removeMarkerArgFromArgsList } from '../shared/context'; @@ -91,12 +92,15 @@ import { ESQLCallbacks } from '../shared/types'; import { getFunctionsToIgnoreForStats, getOverlapRange, - getParamAtPosition, getQueryForFields, getSourcesFromCommands, getSupportedTypesForBinaryOperators, isAggFunctionUsedAlready, + getCompatibleTypesToSuggestNext, removeQuoteForSuggestedSources, + getValidFunctionSignaturesForPreviousArgs, + strictlyGetParamAtPosition, + isLiteralDateItem, } from './helper'; import { FunctionParameter, @@ -305,7 +309,9 @@ export async function suggest( astContext, getFieldsByType, getFieldsMap, - getPolicyMetadata + getPolicyMetadata, + fullText, + offset ); } if (astContext.type === 'list') { @@ -430,7 +436,7 @@ function areCurrentArgsValid( return false; } else { return ( - extractFinalTypeFromArg(node, references) === + extractTypeFromASTArg(node, references) === getCommandDefinition(command.name).signature.params[0].type ); } @@ -446,7 +452,7 @@ function areCurrentArgsValid( return true; } -function extractFinalTypeFromArg( +export function extractTypeFromASTArg( arg: ESQLAstItem, references: Pick ): @@ -457,7 +463,7 @@ function extractFinalTypeFromArg( | string // @TODO remove this | undefined { if (Array.isArray(arg)) { - return extractFinalTypeFromArg(arg[0], references); + return extractTypeFromASTArg(arg[0], references); } if (isColumnItem(arg) || isLiteralItem(arg)) { if (isLiteralItem(arg)) { @@ -510,7 +516,7 @@ function isFunctionArgComplete( } const hasCorrectTypes = fnDefinition.signatures.some((def) => { return arg.args.every((a, index) => { - return def.params[index].type === extractFinalTypeFromArg(a, references); + return def.params[index].type === extractTypeFromASTArg(a, references); }); }); if (!hasCorrectTypes) { @@ -746,7 +752,7 @@ async function getExpressionSuggestionsByType( if (isColumnItem(nodeArg)) { // ... | STATS a // ... | EVAL a - const nodeArgType = extractFinalTypeFromArg(nodeArg, references); + const nodeArgType = extractTypeFromASTArg(nodeArg, references); if (isParameterType(nodeArgType)) { suggestions.push( ...getBuiltinCompatibleFunctionDefinition( @@ -800,7 +806,7 @@ async function getExpressionSuggestionsByType( if (!isNewExpression) { if (isAssignment(nodeArg) && isAssignmentComplete(nodeArg)) { const [rightArg] = nodeArg.args[1] as [ESQLSingleAstItem]; - const nodeArgType = extractFinalTypeFromArg(rightArg, references); + const nodeArgType = extractTypeFromASTArg(rightArg, references); suggestions.push( ...getBuiltinCompatibleFunctionDefinition( command.name, @@ -817,7 +823,7 @@ async function getExpressionSuggestionsByType( if (isFunctionItem(rightArg)) { if (rightArg.args.some(isTimeIntervalItem)) { const lastFnArg = rightArg.args[rightArg.args.length - 1]; - const lastFnArgType = extractFinalTypeFromArg(lastFnArg, references); + const lastFnArgType = extractTypeFromASTArg(lastFnArg, references); if (isNumericType(lastFnArgType) && isLiteralItem(lastFnArg)) // ... EVAL var = 1 year + 2 suggestions.push(...getCompatibleLiterals(command.name, ['time_literal_unit'])); @@ -840,7 +846,7 @@ async function getExpressionSuggestionsByType( )) ); } else { - const nodeArgType = extractFinalTypeFromArg(nodeArg, references); + const nodeArgType = extractTypeFromASTArg(nodeArg, references); suggestions.push( ...(await getBuiltinFunctionNextArgument( innerText, @@ -855,7 +861,7 @@ async function getExpressionSuggestionsByType( ); if (nodeArg.args.some(isTimeIntervalItem)) { const lastFnArg = nodeArg.args[nodeArg.args.length - 1]; - const lastFnArgType = extractFinalTypeFromArg(lastFnArg, references); + const lastFnArgType = extractTypeFromASTArg(lastFnArg, references); if (isNumericType(lastFnArgType) && isLiteralItem(lastFnArg)) // ... EVAL var = 1 year + 2 suggestions.push(...getCompatibleLiterals(command.name, ['time_literal_unit'])); @@ -912,7 +918,7 @@ async function getExpressionSuggestionsByType( } } else { // if something is already present, leverage its type to suggest something in context - const nodeArgType = extractFinalTypeFromArg(nodeArg, references); + const nodeArgType = extractTypeFromASTArg(nodeArg, references); // These cases can happen here, so need to identify each and provide the right suggestion // i.e. ... | field // i.e. ... | field + @@ -1083,7 +1089,7 @@ async function getBuiltinFunctionNextArgument( // pick the last arg and check its type to verify whether is incomplete for the given function const cleanedArgs = removeMarkerArgFromArgsList(nodeArg)!.args; - const nestedType = extractFinalTypeFromArg(nodeArg.args[cleanedArgs.length - 1], references); + const nestedType = extractTypeFromASTArg(nodeArg.args[cleanedArgs.length - 1], references); if (isFnComplete.reason === 'fewArgs') { const fnDef = getFunctionDefinition(nodeArg.name); @@ -1254,7 +1260,9 @@ async function getFunctionArgsSuggestions( }, getFieldsByType: GetFieldsByTypeFn, getFieldsMap: GetFieldsMapFn, - getPolicyMetadata: GetPolicyMetadataFn + getPolicyMetadata: GetPolicyMetadataFn, + fullText: string, + offset: number ): Promise { const fnDefinition = getFunctionDefinition(node.name); // early exit on no hit @@ -1262,6 +1270,24 @@ async function getFunctionArgsSuggestions( return []; } const fieldsMap: Map = await getFieldsMap(); + const anyVariables = collectVariables(commands, fieldsMap, innerText); + + const references = { + fields: fieldsMap, + variables: anyVariables, + }; + + const enrichedArgs = node.args.map((nodeArg) => { + let dataType = extractTypeFromASTArg(nodeArg, references); + + // For named system time parameters ?start and ?end, make sure it's compatiable + if (isLiteralDateItem(nodeArg)) { + dataType = 'date'; + } + + return { ...nodeArg, dataType } as ESQLAstItem & { dataType: string }; + }); + const variablesExcludingCurrentCommandOnes = excludeVariablesFromCurrentCommand( commands, command, @@ -1275,44 +1301,39 @@ async function getFunctionArgsSuggestions( argIndex -= 1; } - const arg = node.args[argIndex]; - - // the first signature is used as reference - // TODO - take into consideration all signatures that match the current args - const refSignature = fnDefinition.signatures[0]; - - const hasMoreMandatoryArgs = - (refSignature.params.length >= argIndex && - refSignature.params.filter(({ optional }, index) => !optional && index > argIndex).length > - 0) || - ('minParams' in refSignature && refSignature.minParams - ? refSignature.minParams - 1 > argIndex - : false); - - const shouldAddComma = hasMoreMandatoryArgs && fnDefinition.type !== 'builtin'; + const arg: ESQLAstItem = enrichedArgs[argIndex]; - const suggestedConstants = Array.from( - new Set( - fnDefinition.signatures.reduce((acc, signature) => { - const p = signature.params[argIndex]; - if (!p) { - return acc; - } - - const _suggestions: string[] = p.literalSuggestions - ? p.literalSuggestions - : p.literalOptions - ? p.literalOptions - : []; - - return acc.concat(_suggestions); - }, [] as string[]) - ) + const validSignatures = getValidFunctionSignaturesForPreviousArgs( + fnDefinition, + enrichedArgs, + argIndex ); + // Retrieve unique of types that are compatiable for the current arg + const typesToSuggestNext = getCompatibleTypesToSuggestNext(fnDefinition, enrichedArgs, argIndex); + const hasMoreMandatoryArgs = !validSignatures + // Types available to suggest next after this argument is completed + .map((signature) => strictlyGetParamAtPosition(signature, argIndex + 1)) + // when a param is null, it means param is optional + // If there's at least one param that is optional, then + // no need to suggest comma + .some((p) => p === null || p?.optional === true); + + // Whether to prepend comma to suggestion string + // E.g. if true, "fieldName" -> "fieldName, " + const alreadyHasComma = fullText ? fullText[offset] === ',' : false; + const shouldAddComma = + hasMoreMandatoryArgs && fnDefinition.type !== 'builtin' && !alreadyHasComma; + + const suggestedConstants = uniq( + typesToSuggestNext + .map((d) => d.literalSuggestions || d.literalOptions) + .filter((d) => d) + .flat() + ) as string[]; if (suggestedConstants.length) { return buildValueDefinitions(suggestedConstants, { - addComma: hasMoreMandatoryArgs, + addComma: shouldAddComma, advanceCursorAndOpenSuggestions: hasMoreMandatoryArgs, }); } @@ -1357,35 +1378,6 @@ async function getFunctionArgsSuggestions( : []) ); } - - const existingTypes = node.args - .map((nodeArg) => - extractFinalTypeFromArg(nodeArg, { - fields: fieldsMap, - variables: variablesExcludingCurrentCommandOnes, - }) - ) - .filter(nonNullable); - - const validSignatures = fnDefinition.signatures - // if existing arguments are preset already, use them to filter out incompatible signatures - .filter((signature) => { - if (existingTypes.length) { - return existingTypes.every((type, index) => - compareTypesWithLiterals(signature.params[index].type, type) - ); - } - return true; - }); - - /** - * Get all parameter definitions across all function signatures - * for the current parameter position in the given function definition, - */ - const allParamDefinitionsForThisPosition = validSignatures - .map((signature) => getParamAtPosition(signature, argIndex)) - .filter(nonNullable); - // Separate the param definitions into two groups: // fields should only be suggested if the param isn't constant-only, // and constant suggestions should only be given if it is. @@ -1396,9 +1388,8 @@ async function getFunctionArgsSuggestions( // (e.g. if func1's first parameter is constant-only, any nested functions should // inherit that constraint: func1(func2(shouldBeConstantOnly))) // - const [constantOnlyParamDefs, paramDefsWhichSupportFields] = partition( - allParamDefinitionsForThisPosition, - (paramDef) => paramDef.constantOnly || /_literal$/.test(paramDef.type as string) + const constantOnlyParamDefs = typesToSuggestNext.filter( + (p) => p.constantOnly || /_literal/.test(p.type as string) ); const getTypesFromParamDefs = (paramDefs: FunctionParameter[]) => { @@ -1418,32 +1409,37 @@ async function getFunctionArgsSuggestions( // Fields suggestions.push( ...pushItUpInTheList( - await getFieldsByType(getTypesFromParamDefs(paramDefsWhichSupportFields) as string[], [], { - addComma: shouldAddComma, - advanceCursor: hasMoreMandatoryArgs, - openSuggestions: hasMoreMandatoryArgs, - }), + await getFieldsByType( + // @TODO: have a way to better suggest constant only params + getTypesFromParamDefs(typesToSuggestNext.filter((d) => !d.constantOnly)) as string[], + [], + { + addComma: shouldAddComma, + advanceCursor: shouldAddComma, + openSuggestions: shouldAddComma, + } + ), true ) ); - // Functions suggestions.push( ...getCompatibleFunctionDefinition( command.name, option?.name, - getTypesFromParamDefs(paramDefsWhichSupportFields) as string[], + getTypesFromParamDefs(typesToSuggestNext) as string[], fnToIgnore ).map((suggestion) => ({ ...suggestion, text: addCommaIf(shouldAddComma, suggestion.text), })) ); - // could also be in stats (bucket) but our autocomplete is not great yet if ( - getTypesFromParamDefs(paramDefsWhichSupportFields).includes('date') && - ['where', 'eval'].includes(command.name) + (getTypesFromParamDefs(typesToSuggestNext).includes('date') && + ['where', 'eval'].includes(command.name)) || + (command.name === 'stats' && + typesToSuggestNext.some((t) => t && t.type === 'date' && t.constantOnly === true)) ) suggestions.push( ...getDateLiterals({ @@ -1452,7 +1448,6 @@ async function getFunctionArgsSuggestions( }) ); } - // for eval and row commands try also to complete numeric literals with time intervals where possible if (arg) { if (command.name !== 'stats') { @@ -1504,7 +1499,7 @@ async function getListArgsSuggestions( }); const [firstArg] = node.args; if (isColumnItem(firstArg)) { - const argType = extractFinalTypeFromArg(firstArg, { + const argType = extractTypeFromASTArg(firstArg, { fields: fieldsMap, variables: anyVariables, }); @@ -1704,7 +1699,7 @@ async function getOptionArgsSuggestions( if (command.name === 'stats') { const argDef = optionDef?.signature.params[argIndex]; - const nodeArgType = extractFinalTypeFromArg(nodeArg, references); + const nodeArgType = extractTypeFromASTArg(nodeArg, references); // These cases can happen here, so need to identify each and provide the right suggestion // i.e. ... | STATS ... BY field + // i.e. ... | STATS ... BY field >= @@ -1770,6 +1765,29 @@ async function getOptionArgsSuggestions( ); if (option.name === 'by') { + // Add quick snippet for for stats ... by bucket(<>) + if (command.name === 'stats') { + suggestions.push({ + label: i18n.translate( + 'kbn-esql-validation-autocomplete.esql.autocomplete.addDateHistogram', + { + defaultMessage: 'Add date histogram', + } + ), + text: ADD_DATE_HISTOGRAM_SNIPPET, + asSnippet: true, + kind: 'Function', + detail: i18n.translate( + 'kbn-esql-validation-autocomplete.esql.autocomplete.addDateHistogramDetail', + { + defaultMessage: 'Add date histogram using bucket()', + } + ), + sortText: '1A', + command: TRIGGER_SUGGESTION_COMMAND, + } as SuggestionRawDefinition); + } + suggestions.push( ...(await getFieldsOrFunctionsSuggestions( types[0] === 'column' ? ['any'] : types, diff --git a/packages/kbn-esql-validation-autocomplete/src/autocomplete/factories.ts b/packages/kbn-esql-validation-autocomplete/src/autocomplete/factories.ts index 4ac3b3b24d1f6..ac405084bb253 100644 --- a/packages/kbn-esql-validation-autocomplete/src/autocomplete/factories.ts +++ b/packages/kbn-esql-validation-autocomplete/src/autocomplete/factories.ts @@ -30,6 +30,7 @@ const allFunctions = statsAggregationFunctionDefinitions .concat(groupingFunctionDefinitions); export const TIME_SYSTEM_PARAMS = ['?t_start', '?t_end']; +export const ADD_DATE_HISTOGRAM_SNIPPET = 'BUCKET($0, 10, ?t_start, ?t_end)'; export const TRIGGER_SUGGESTION_COMMAND = { title: 'Trigger Suggestion Dialog', diff --git a/packages/kbn-esql-validation-autocomplete/src/autocomplete/helper.ts b/packages/kbn-esql-validation-autocomplete/src/autocomplete/helper.ts index 7d4c8c9cc111e..ab85dcc353d9f 100644 --- a/packages/kbn-esql-validation-autocomplete/src/autocomplete/helper.ts +++ b/packages/kbn-esql-validation-autocomplete/src/autocomplete/helper.ts @@ -7,9 +7,17 @@ */ import type { ESQLAstItem, ESQLCommand, ESQLFunction, ESQLSource } from '@kbn/esql-ast'; +import { uniqBy } from 'lodash'; import type { FunctionDefinition } from '../definitions/types'; -import { getFunctionDefinition, isAssignment, isFunctionItem } from '../shared/helpers'; +import { + getFunctionDefinition, + isAssignment, + isFunctionItem, + isLiteralItem, +} from '../shared/helpers'; import type { SuggestionRawDefinition } from './types'; +import { compareTypesWithLiterals } from '../shared/esql_types'; +import { TIME_SYSTEM_PARAMS } from './factories'; function extractFunctionArgs(args: ESQLAstItem[]): ESQLFunction[] { return args.flatMap((arg) => (isAssignment(arg) ? arg.args[1] : arg)).filter(isFunctionItem); @@ -57,6 +65,20 @@ export function getParamAtPosition( return params.length > position ? params[position] : minParams ? params[params.length - 1] : null; } +/** + * Given a function signature, returns the parameter at the given position, even if it's undefined or null + * + * @param {params} + * @param position + * @returns + */ +export function strictlyGetParamAtPosition( + { params }: FunctionDefinition['signatures'][number], + position: number +) { + return params[position] ? params[position] : null; +} + export function getQueryForFields(queryString: string, commands: ESQLCommand[]) { // If there is only one source command and it does not require fields, do not // fetch fields, hence return an empty string. @@ -93,6 +115,63 @@ export function getSupportedTypesForBinaryOperators( : [previousType]; } +export function getValidFunctionSignaturesForPreviousArgs( + fnDefinition: FunctionDefinition, + enrichedArgs: Array< + ESQLAstItem & { + dataType: string; + } + >, + argIndex: number +) { + // Filter down to signatures that match every params up to the current argIndex + // e.g. BUCKET(longField, /) => all signatures with first param as long column type + // or BUCKET(longField, 2, /) => all signatures with (longField, integer, ...) + const relevantFuncSignatures = fnDefinition.signatures.filter( + (s) => + s.params?.length >= argIndex && + s.params.slice(0, argIndex).every(({ type: dataType }, idx) => { + return ( + dataType === enrichedArgs[idx].dataType || + compareTypesWithLiterals(dataType, enrichedArgs[idx].dataType) + ); + }) + ); + return relevantFuncSignatures; +} + +/** + * Given a function signature, returns the compatible types to suggest for the next argument + * + * @param fnDefinition: the function definition + * @param enrichedArgs: AST args with enriched esType info to match with function signatures + * @param argIndex: the index of the argument to suggest for + * @returns + */ +export function getCompatibleTypesToSuggestNext( + fnDefinition: FunctionDefinition, + enrichedArgs: Array< + ESQLAstItem & { + dataType: string; + } + >, + argIndex: number +) { + // First, narrow down to valid function signatures based on previous arguments + const relevantFuncSignatures = getValidFunctionSignaturesForPreviousArgs( + fnDefinition, + enrichedArgs, + argIndex + ); + + // Then, get the compatible types to suggest for the next argument + const compatibleTypesToSuggestForArg = uniqBy( + relevantFuncSignatures.map((f) => f.params[argIndex]).filter((d) => d), + (o) => `${o.type}-${o.constantOnly}` + ); + return compatibleTypesToSuggestForArg; +} + /** * Checks the suggestion text for overlap with the current query. * @@ -131,3 +210,24 @@ export function getOverlapRange( end: query.length, }; } + +function isValidDateString(dateString: string): boolean { + const timestamp = Date.parse(dateString.replace(/\"/g, '')); + return !isNaN(timestamp); +} + +/** + * Returns true is node is a valid literal that represents a date + * either a system time parameter or a date string generated by date picker + * @param dateString + * @returns + */ +export function isLiteralDateItem(nodeArg: ESQLAstItem): boolean { + return ( + isLiteralItem(nodeArg) && + // If text is ?start or ?end, it's a system time parameter + (TIME_SYSTEM_PARAMS.includes(nodeArg.text) || + // Or if it's a string generated by date picker + isValidDateString(nodeArg.text)) + ); +} diff --git a/packages/kbn-esql-validation-autocomplete/src/shared/esql_types.ts b/packages/kbn-esql-validation-autocomplete/src/shared/esql_types.ts index fada6bea88134..590541fd50719 100644 --- a/packages/kbn-esql-validation-autocomplete/src/shared/esql_types.ts +++ b/packages/kbn-esql-validation-autocomplete/src/shared/esql_types.ts @@ -72,5 +72,16 @@ export const compareTypesWithLiterals = ( if (b === 'string') { return isStringType(a); } + + // In Elasticsearch function definitions, time_literal and time_duration are used + // time_duration is seconds/min/hour interval + // date_period is day/week/month/year interval + // time_literal includes time_duration and date_period + // So they are equivalent AST's 'timeInterval' (a date unit constant: e.g. 1 year, 15 month) + if (a === 'time_literal' || a === 'time_duration') return b === 'timeInterval'; + if (b === 'time_literal' || b === 'time_duration') return a === 'timeInterval'; + if (a === 'time_literal') return b === 'time_duration'; + if (b === 'time_literal') return a === 'time_duration'; + return false; };