From 2173af79fde374008b181ca42cf98a7137a7bb24 Mon Sep 17 00:00:00 2001 From: Drew Tate <drew.tate@elastic.co> Date: Fri, 18 Oct 2024 10:15:11 -0600 Subject: [PATCH] [ES|QL] Create expression type evaluator (#195989) ## Summary Close https://github.com/elastic/kibana/issues/195682 Close https://github.com/elastic/kibana/issues/195430 Introduces `getExpressionType`, the ES|QL expression type evaluator to rule them all! Also, fixes several validation bugs related to the faulty logic that existed before with variable type detection (some noted in https://github.com/elastic/kibana/issues/192255#issuecomment-2394613881). ### Checklist - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios --------- Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com> --- .../src/parser/__tests__/literal.test.ts | 4 +- packages/kbn-esql-ast/src/parser/factories.ts | 9 +- packages/kbn-esql-ast/src/parser/walkers.ts | 14 +- .../wrapping_pretty_printer.comments.test.ts | 2 +- .../src/pretty_print/leaf_printer.ts | 4 +- packages/kbn-esql-ast/src/types.ts | 31 +- packages/kbn-esql-ast/src/visitor/contexts.ts | 2 +- .../kbn-esql-ast/src/walker/walker.test.ts | 20 +- .../scripts/generate_function_definitions.ts | 2 +- .../autocomplete.command.stats.test.ts | 9 +- .../src/autocomplete/autocomplete.ts | 9 +- .../src/autocomplete/complete_items.ts | 6 +- .../src/autocomplete/helper.ts | 17 - .../src/definitions/builtin.ts | 47 +-- .../definitions/generated/scalar_functions.ts | 2 +- .../src/definitions/options.ts | 2 +- .../src/definitions/types.ts | 6 +- .../src/shared/context.ts | 2 +- .../src/shared/esql_types.ts | 36 +-- .../src/shared/helpers.test.ts | 297 +++++++++++++++++- .../src/shared/helpers.ts | 192 +++++++++-- .../src/shared/variables.ts | 73 +---- .../validation/__tests__/functions.test.ts | 24 +- .../esql_validation_meta_tests.json | 22 +- .../src/validation/types.ts | 7 +- .../src/validation/validation.test.ts | 14 +- .../src/validation/validation.ts | 23 +- .../translations/translations/fr-FR.json | 1 - .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - 30 files changed, 609 insertions(+), 270 deletions(-) diff --git a/packages/kbn-esql-ast/src/parser/__tests__/literal.test.ts b/packages/kbn-esql-ast/src/parser/__tests__/literal.test.ts index 7f50198c96047..ede899d4d8058 100644 --- a/packages/kbn-esql-ast/src/parser/__tests__/literal.test.ts +++ b/packages/kbn-esql-ast/src/parser/__tests__/literal.test.ts @@ -24,7 +24,7 @@ describe('literal expression', () => { }); }); - it('decimals vs integers', () => { + it('doubles vs integers', () => { const text = 'ROW a(1.0, 1)'; const { ast } = parse(text); @@ -36,7 +36,7 @@ describe('literal expression', () => { args: [ { type: 'literal', - literalType: 'decimal', + literalType: 'double', }, { type: 'literal', diff --git a/packages/kbn-esql-ast/src/parser/factories.ts b/packages/kbn-esql-ast/src/parser/factories.ts index 321ca6a40dcd0..0fffb3a970e4c 100644 --- a/packages/kbn-esql-ast/src/parser/factories.ts +++ b/packages/kbn-esql-ast/src/parser/factories.ts @@ -41,6 +41,7 @@ import type { FunctionSubtype, ESQLNumericLiteral, ESQLOrderExpression, + InlineCastingType, } from '../types'; import { parseIdentifier, getPosition } from './helpers'; import { Builder, type AstNodeParserFields } from '../builder'; @@ -72,7 +73,7 @@ export const createCommand = (name: string, ctx: ParserRuleContext) => export const createInlineCast = (ctx: InlineCastContext, value: ESQLInlineCast['value']) => Builder.expression.inlineCast( - { castType: ctx.dataType().getText(), value }, + { castType: ctx.dataType().getText().toLowerCase() as InlineCastingType, value }, createParserFields(ctx) ); @@ -107,7 +108,7 @@ export function createLiteralString(token: Token): ESQLLiteral { const text = token.text!; return { type: 'literal', - literalType: 'string', + literalType: 'keyword', text, name: text, value: text, @@ -149,13 +150,13 @@ export function createLiteral( location: getPosition(node.symbol), incomplete: isMissingText(text), }; - if (type === 'decimal' || type === 'integer') { + if (type === 'double' || type === 'integer') { return { ...partialLiteral, literalType: type, value: Number(text), paramType: 'number', - } as ESQLNumericLiteral<'decimal'> | ESQLNumericLiteral<'integer'>; + } as ESQLNumericLiteral<'double'> | ESQLNumericLiteral<'integer'>; } else if (type === 'param') { throw new Error('Should never happen'); } diff --git a/packages/kbn-esql-ast/src/parser/walkers.ts b/packages/kbn-esql-ast/src/parser/walkers.ts index cccc215ec365e..30c17c56483f8 100644 --- a/packages/kbn-esql-ast/src/parser/walkers.ts +++ b/packages/kbn-esql-ast/src/parser/walkers.ts @@ -346,7 +346,7 @@ function getConstant(ctx: ConstantContext): ESQLAstItem { // Decimal type covers multiple ES|QL types: long, double, etc. if (ctx instanceof DecimalLiteralContext) { - return createNumericLiteral(ctx.decimalValue(), 'decimal'); + return createNumericLiteral(ctx.decimalValue(), 'double'); } // Integer type encompasses integer @@ -358,7 +358,7 @@ function getConstant(ctx: ConstantContext): ESQLAstItem { } if (ctx instanceof StringLiteralContext) { // String literal covers multiple ES|QL types: text and keyword types - return createLiteral('string', ctx.string_().QUOTED_STRING()); + return createLiteral('keyword', ctx.string_().QUOTED_STRING()); } if ( ctx instanceof NumericArrayLiteralContext || @@ -371,14 +371,14 @@ function getConstant(ctx: ConstantContext): ESQLAstItem { const isDecimal = numericValue.decimalValue() !== null && numericValue.decimalValue() !== undefined; const value = numericValue.decimalValue() || numericValue.integerValue(); - values.push(createNumericLiteral(value!, isDecimal ? 'decimal' : 'integer')); + values.push(createNumericLiteral(value!, isDecimal ? 'double' : 'integer')); } for (const booleanValue of ctx.getTypedRuleContexts(BooleanValueContext)) { values.push(getBooleanValue(booleanValue)!); } for (const string of ctx.getTypedRuleContexts(StringContext)) { // String literal covers multiple ES|QL types: text and keyword types - const literal = createLiteral('string', string.QUOTED_STRING()); + const literal = createLiteral('keyword', string.QUOTED_STRING()); if (literal) { values.push(literal); } @@ -534,7 +534,7 @@ function collectRegexExpression(ctx: BooleanExpressionContext): ESQLFunction[] { const arg = visitValueExpression(regex.valueExpression()); if (arg) { fn.args.push(arg); - const literal = createLiteral('string', regex._pattern.QUOTED_STRING()); + const literal = createLiteral('keyword', regex._pattern.QUOTED_STRING()); if (literal) { fn.args.push(literal); } @@ -672,7 +672,7 @@ export function visitDissect(ctx: DissectCommandContext) { return [ visitPrimaryExpression(ctx.primaryExpression()), ...(pattern && textExistsAndIsValid(pattern.getText()) - ? [createLiteral('string', pattern), ...visitDissectOptions(ctx.commandOptions())] + ? [createLiteral('keyword', pattern), ...visitDissectOptions(ctx.commandOptions())] : []), ].filter(nonNullable); } @@ -682,7 +682,7 @@ export function visitGrok(ctx: GrokCommandContext) { return [ visitPrimaryExpression(ctx.primaryExpression()), ...(pattern && textExistsAndIsValid(pattern.getText()) - ? [createLiteral('string', pattern)] + ? [createLiteral('keyword', pattern)] : []), ].filter(nonNullable); } diff --git a/packages/kbn-esql-ast/src/pretty_print/__tests__/wrapping_pretty_printer.comments.test.ts b/packages/kbn-esql-ast/src/pretty_print/__tests__/wrapping_pretty_printer.comments.test.ts index 3ac79acda8af3..861d274493a42 100644 --- a/packages/kbn-esql-ast/src/pretty_print/__tests__/wrapping_pretty_printer.comments.test.ts +++ b/packages/kbn-esql-ast/src/pretty_print/__tests__/wrapping_pretty_printer.comments.test.ts @@ -399,7 +399,7 @@ ROW // 2 /* 3 */ // 4 - /* 5 */ /* 6 */ 1::INTEGER /* 7 */ /* 8 */ // 9`); + /* 5 */ /* 6 */ 1::integer /* 7 */ /* 8 */ // 9`); }); }); diff --git a/packages/kbn-esql-ast/src/pretty_print/leaf_printer.ts b/packages/kbn-esql-ast/src/pretty_print/leaf_printer.ts index eb7eaf1075c70..3c12de90e4454 100644 --- a/packages/kbn-esql-ast/src/pretty_print/leaf_printer.ts +++ b/packages/kbn-esql-ast/src/pretty_print/leaf_printer.ts @@ -64,10 +64,10 @@ export const LeafPrinter = { return '?'; } } - case 'string': { + case 'keyword': { return String(node.value); } - case 'decimal': { + case 'double': { const isRounded = node.value % 1 === 0; if (isRounded) { diff --git a/packages/kbn-esql-ast/src/types.ts b/packages/kbn-esql-ast/src/types.ts index 1bac6e0cff5b3..0df75ee2e8f24 100644 --- a/packages/kbn-esql-ast/src/types.ts +++ b/packages/kbn-esql-ast/src/types.ts @@ -193,10 +193,33 @@ export type BinaryExpressionAssignmentOperator = '='; export type BinaryExpressionComparisonOperator = '==' | '=~' | '!=' | '<' | '<=' | '>' | '>='; export type BinaryExpressionRegexOperator = 'like' | 'not_like' | 'rlike' | 'not_rlike'; +// from https://github.com/elastic/elasticsearch/blob/122e7288200ee03e9087c98dff6cebbc94e774aa/docs/reference/esql/functions/kibana/inline_cast.json +export type InlineCastingType = + | 'bool' + | 'boolean' + | 'cartesian_point' + | 'cartesian_shape' + | 'date_nanos' + | 'date_period' + | 'datetime' + | 'double' + | 'geo_point' + | 'geo_shape' + | 'int' + | 'integer' + | 'ip' + | 'keyword' + | 'long' + | 'string' + | 'text' + | 'time_duration' + | 'unsigned_long' + | 'version'; + export interface ESQLInlineCast<ValueType = ESQLAstItem> extends ESQLAstBaseItem { type: 'inlineCast'; value: ValueType; - castType: string; + castType: InlineCastingType; } /** @@ -270,7 +293,7 @@ export interface ESQLList extends ESQLAstBaseItem { values: ESQLLiteral[]; } -export type ESQLNumericLiteralType = 'decimal' | 'integer'; +export type ESQLNumericLiteralType = 'double' | 'integer'; export type ESQLLiteral = | ESQLDecimalLiteral @@ -290,7 +313,7 @@ export interface ESQLNumericLiteral<T extends ESQLNumericLiteralType> extends ES } // We cast anything as decimal (e.g. 32.12) as generic decimal numeric type here // @internal -export type ESQLDecimalLiteral = ESQLNumericLiteral<'decimal'>; +export type ESQLDecimalLiteral = ESQLNumericLiteral<'double'>; // @internal export type ESQLIntegerLiteral = ESQLNumericLiteral<'integer'>; @@ -312,7 +335,7 @@ export interface ESQLNullLiteral extends ESQLAstBaseItem { // @internal export interface ESQLStringLiteral extends ESQLAstBaseItem { type: 'literal'; - literalType: 'string'; + literalType: 'keyword'; value: string; } diff --git a/packages/kbn-esql-ast/src/visitor/contexts.ts b/packages/kbn-esql-ast/src/visitor/contexts.ts index 4b4f04fdca4bb..086a217d8f117 100644 --- a/packages/kbn-esql-ast/src/visitor/contexts.ts +++ b/packages/kbn-esql-ast/src/visitor/contexts.ts @@ -337,7 +337,7 @@ export class LimitCommandVisitorContext< if ( arg && arg.type === 'literal' && - (arg.literalType === 'integer' || arg.literalType === 'decimal') + (arg.literalType === 'integer' || arg.literalType === 'double') ) { return arg; } diff --git a/packages/kbn-esql-ast/src/walker/walker.test.ts b/packages/kbn-esql-ast/src/walker/walker.test.ts index 9900f586dc4a0..8dd40b1a87bd1 100644 --- a/packages/kbn-esql-ast/src/walker/walker.test.ts +++ b/packages/kbn-esql-ast/src/walker/walker.test.ts @@ -342,7 +342,7 @@ describe('structurally can walk all nodes', () => { }, { type: 'literal', - literalType: 'string', + literalType: 'keyword', name: '"foo"', }, { @@ -375,7 +375,7 @@ describe('structurally can walk all nodes', () => { }, { type: 'literal', - literalType: 'string', + literalType: 'keyword', name: '"2"', }, { @@ -390,7 +390,7 @@ describe('structurally can walk all nodes', () => { }, { type: 'literal', - literalType: 'decimal', + literalType: 'double', name: '3.14', }, ]); @@ -473,7 +473,7 @@ describe('structurally can walk all nodes', () => { values: [ { type: 'literal', - literalType: 'decimal', + literalType: 'double', name: '3.3', }, ], @@ -492,7 +492,7 @@ describe('structurally can walk all nodes', () => { }, { type: 'literal', - literalType: 'decimal', + literalType: 'double', name: '3.3', }, ]); @@ -600,27 +600,27 @@ describe('structurally can walk all nodes', () => { expect(literals).toMatchObject([ { type: 'literal', - literalType: 'string', + literalType: 'keyword', name: '"a"', }, { type: 'literal', - literalType: 'string', + literalType: 'keyword', name: '"b"', }, { type: 'literal', - literalType: 'string', + literalType: 'keyword', name: '"c"', }, { type: 'literal', - literalType: 'string', + literalType: 'keyword', name: '"d"', }, { type: 'literal', - literalType: 'string', + literalType: 'keyword', name: '"e"', }, ]); diff --git a/packages/kbn-esql-validation-autocomplete/scripts/generate_function_definitions.ts b/packages/kbn-esql-validation-autocomplete/scripts/generate_function_definitions.ts index 13f0b2c66ce32..fe2a85456aa12 100644 --- a/packages/kbn-esql-validation-autocomplete/scripts/generate_function_definitions.ts +++ b/packages/kbn-esql-validation-autocomplete/scripts/generate_function_definitions.ts @@ -52,7 +52,7 @@ const extraFunctions: FunctionDefinition[] = [ { name: 'value', type: 'any' }, ], minParams: 2, - returnType: 'any', + returnType: 'unknown', }, ], examples: [ 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 6b2f5fea0cc8d..b3884f5cb96be 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 @@ -259,7 +259,6 @@ describe('autocomplete.suggest', () => { ...getFieldNamesByType('integer'), ...getFieldNamesByType('double'), ...getFieldNamesByType('long'), - '`avg(b)`', ...getFunctionSignaturesByReturnType('eval', ['integer', 'double', 'long'], { scalar: true, }), @@ -284,11 +283,19 @@ describe('autocomplete.suggest', () => { const { assertSuggestions } = await setup(); await assertSuggestions('from a | stats avg(b) by doubleField % 2 /', [',', '| ']); + await assertSuggestions('from a | stats avg(b) by doubleField % 2 /', [',', '| '], { + triggerCharacter: ' ', + }); await assertSuggestions( 'from a | stats var0 = AVG(doubleField) BY var1 = BUCKET(dateField, 1 day)/', [',', '| ', '+ $0', '- $0'] ); + await assertSuggestions( + 'from a | stats var0 = AVG(doubleField) BY var1 = BUCKET(dateField, 1 day) /', + [',', '| ', '+ $0', '- $0'], + { triggerCharacter: ' ' } + ); }); test('on space within bucket()', async () => { const { assertSuggestions } = await setup(); diff --git a/packages/kbn-esql-validation-autocomplete/src/autocomplete/autocomplete.ts b/packages/kbn-esql-validation-autocomplete/src/autocomplete/autocomplete.ts index cf41fd2506c72..cbc84232b8eb6 100644 --- a/packages/kbn-esql-validation-autocomplete/src/autocomplete/autocomplete.ts +++ b/packages/kbn-esql-validation-autocomplete/src/autocomplete/autocomplete.ts @@ -1780,10 +1780,15 @@ async function getOptionArgsSuggestions( innerText, command, option, - { type: argDef?.type || 'any' }, + { type: argDef?.type || 'unknown' }, nodeArg, nodeArgType as string, - references, + { + fields: references.fields, + // you can't use a variable defined + // in the stats command in the by clause + variables: new Map(), + }, getFieldsByType )) ); 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 8d598eb5f2f11..000c196b49e5e 100644 --- a/packages/kbn-esql-validation-autocomplete/src/autocomplete/complete_items.ts +++ b/packages/kbn-esql-validation-autocomplete/src/autocomplete/complete_items.ts @@ -65,7 +65,6 @@ export const getBuiltinCompatibleFunctionDefinition = ( ({ name, supportedCommands, supportedOptions, signatures, ignoreAsSuggestion }) => (command === 'where' && commandsToInclude ? commandsToInclude.indexOf(name) > -1 : true) && !ignoreAsSuggestion && - !/not_/.test(name) && (!skipAssign || name !== '=') && (option ? supportedOptions?.includes(option) : supportedCommands.includes(command)) && signatures.some( @@ -79,7 +78,10 @@ export const getBuiltinCompatibleFunctionDefinition = ( return compatibleFunctions .filter((mathDefinition) => mathDefinition.signatures.some( - (signature) => returnTypes[0] === 'any' || returnTypes.includes(signature.returnType) + (signature) => + returnTypes[0] === 'unknown' || + returnTypes[0] === 'any' || + returnTypes.includes(signature.returnType) ) ) .map(getSuggestionBuiltinDefinition); diff --git a/packages/kbn-esql-validation-autocomplete/src/autocomplete/helper.ts b/packages/kbn-esql-validation-autocomplete/src/autocomplete/helper.ts index 41f6a92dc313d..dd450e28b66a9 100644 --- a/packages/kbn-esql-validation-autocomplete/src/autocomplete/helper.ts +++ b/packages/kbn-esql-validation-autocomplete/src/autocomplete/helper.ts @@ -52,23 +52,6 @@ export function getFunctionsToIgnoreForStats(command: ESQLCommand, argIndex: num return isFunctionItem(arg) ? getFnContent(arg) : []; } -/** - * Given a function signature, returns the parameter at the given position. - * - * Takes into account variadic functions (minParams), returning the last - * parameter if the position is greater than the number of parameters. - * - * @param signature - * @param position - * @returns - */ -export function getParamAtPosition( - { params, minParams }: FunctionDefinition['signatures'][number], - position: number -) { - 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 * diff --git a/packages/kbn-esql-validation-autocomplete/src/definitions/builtin.ts b/packages/kbn-esql-validation-autocomplete/src/definitions/builtin.ts index c59daa2130417..e71ed32e4c79d 100644 --- a/packages/kbn-esql-validation-autocomplete/src/definitions/builtin.ts +++ b/packages/kbn-esql-validation-autocomplete/src/definitions/builtin.ts @@ -423,6 +423,20 @@ const likeFunctions: FunctionDefinition[] = [ ], returnType: 'boolean', }, + { + params: [ + { name: 'left', type: 'text' as const }, + { name: 'right', type: 'keyword' as const }, + ], + returnType: 'boolean', + }, + { + params: [ + { name: 'left', type: 'keyword' as const }, + { name: 'right', type: 'text' as const }, + ], + returnType: 'boolean', + }, { params: [ { name: 'left', type: 'keyword' as const }, @@ -609,25 +623,12 @@ const otherDefinitions: FunctionDefinition[] = [ { name: 'left', type: 'any' }, { name: 'right', type: 'any' }, ], - returnType: 'void', - }, - ], - }, - { - name: 'functions', - type: 'builtin', - description: i18n.translate('kbn-esql-validation-autocomplete.esql.definition.functionsDoc', { - defaultMessage: 'Show ES|QL avaialble functions with signatures', - }), - supportedCommands: ['meta'], - signatures: [ - { - params: [], - returnType: 'void', + returnType: 'unknown', }, ], }, { + // TODO — this shouldn't be a function or an operator... name: 'info', type: 'builtin', description: i18n.translate('kbn-esql-validation-autocomplete.esql.definition.infoDoc', { @@ -637,21 +638,7 @@ const otherDefinitions: FunctionDefinition[] = [ signatures: [ { params: [], - returnType: 'void', - }, - ], - }, - { - name: 'order-expression', - type: 'builtin', - description: i18n.translate('kbn-esql-validation-autocomplete.esql.definition.infoDoc', { - defaultMessage: 'Specify column sorting modifiers', - }), - supportedCommands: ['sort'], - signatures: [ - { - params: [{ name: 'column', type: 'any' }], - returnType: 'void', + returnType: 'unknown', // meaningless }, ], }, diff --git a/packages/kbn-esql-validation-autocomplete/src/definitions/generated/scalar_functions.ts b/packages/kbn-esql-validation-autocomplete/src/definitions/generated/scalar_functions.ts index b25d3ad8b6563..ea5f8f86e1909 100644 --- a/packages/kbn-esql-validation-autocomplete/src/definitions/generated/scalar_functions.ts +++ b/packages/kbn-esql-validation-autocomplete/src/definitions/generated/scalar_functions.ts @@ -9033,7 +9033,7 @@ const caseDefinition: FunctionDefinition = { }, ], minParams: 2, - returnType: 'any', + returnType: 'unknown', }, ], supportedCommands: ['stats', 'inlinestats', 'metrics', 'eval', 'where', 'row', 'sort'], diff --git a/packages/kbn-esql-validation-autocomplete/src/definitions/options.ts b/packages/kbn-esql-validation-autocomplete/src/definitions/options.ts index 2e6fbc791b747..31d443a8cbb2b 100644 --- a/packages/kbn-esql-validation-autocomplete/src/definitions/options.ts +++ b/packages/kbn-esql-validation-autocomplete/src/definitions/options.ts @@ -129,7 +129,7 @@ export const appendSeparatorOption: CommandOptionsDefinition = { const [firstArg] = option.args; if ( !Array.isArray(firstArg) && - (!isLiteralItem(firstArg) || firstArg.literalType !== 'string') + (!isLiteralItem(firstArg) || firstArg.literalType !== 'keyword') ) { const value = 'value' in firstArg && !isInlineCastItem(firstArg) ? firstArg.value : firstArg.name; diff --git a/packages/kbn-esql-validation-autocomplete/src/definitions/types.ts b/packages/kbn-esql-validation-autocomplete/src/definitions/types.ts index 9ce286796c971..dee08766745df 100644 --- a/packages/kbn-esql-validation-autocomplete/src/definitions/types.ts +++ b/packages/kbn-esql-validation-autocomplete/src/definitions/types.ts @@ -100,12 +100,14 @@ export const isParameterType = (str: string | undefined): str is FunctionParamet /** * This is the return type of a function definition. + * + * TODO: remove `any` */ -export type FunctionReturnType = Exclude<SupportedDataType, 'unsupported'> | 'any' | 'void'; +export type FunctionReturnType = Exclude<SupportedDataType, 'unsupported'> | 'unknown' | 'any'; export const isReturnType = (str: string | FunctionParameterType): str is FunctionReturnType => str !== 'unsupported' && - (dataTypes.includes(str as SupportedDataType) || str === 'any' || str === 'void'); + (dataTypes.includes(str as SupportedDataType) || str === 'unknown' || str === 'any'); export interface FunctionDefinition { type: 'builtin' | 'agg' | 'eval'; diff --git a/packages/kbn-esql-validation-autocomplete/src/shared/context.ts b/packages/kbn-esql-validation-autocomplete/src/shared/context.ts index 0f7f830c1417a..1c2e9075e95ff 100644 --- a/packages/kbn-esql-validation-autocomplete/src/shared/context.ts +++ b/packages/kbn-esql-validation-autocomplete/src/shared/context.ts @@ -153,7 +153,7 @@ function isBuiltinFunction(node: ESQLFunction) { export function getAstContext(queryString: string, ast: ESQLAst, offset: number) { const { command, option, setting, node } = findAstPosition(ast, offset); if (node) { - if (node.type === 'literal' && node.literalType === 'string') { + if (node.type === 'literal' && node.literalType === 'keyword') { // command ... "<here>" return { type: 'value' as const, command, node, option, setting }; } 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 66f985505a43d..dbf45437dce92 100644 --- a/packages/kbn-esql-validation-autocomplete/src/shared/esql_types.ts +++ b/packages/kbn-esql-validation-autocomplete/src/shared/esql_types.ts @@ -7,7 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { ESQLDecimalLiteral, ESQLLiteral, ESQLNumericLiteralType } from '@kbn/esql-ast/src/types'; +import { ESQLLiteral, ESQLNumericLiteralType } from '@kbn/esql-ast/src/types'; import { FunctionParameterType } from '../definitions/types'; export const ESQL_COMMON_NUMERIC_TYPES = ['double', 'long', 'integer'] as const; @@ -27,15 +27,6 @@ export const ESQL_NUMBER_TYPES = [ export const ESQL_STRING_TYPES = ['keyword', 'text'] as const; export const ESQL_DATE_TYPES = ['datetime', 'date_period'] as const; -/** - * - * @param type - * @returns - */ -export function isStringType(type: unknown) { - return typeof type === 'string' && ['keyword', 'text'].includes(type); -} - export function isNumericType(type: unknown): type is ESQLNumericLiteralType { return ( typeof type === 'string' && @@ -43,37 +34,18 @@ export function isNumericType(type: unknown): type is ESQLNumericLiteralType { ); } -export function isNumericDecimalType(type: unknown): type is ESQLDecimalLiteral { - return ( - typeof type === 'string' && - ESQL_NUMERIC_DECIMAL_TYPES.includes(type as (typeof ESQL_NUMERIC_DECIMAL_TYPES)[number]) - ); -} - /** * Compares two types, taking into account literal types * @TODO strengthen typing here (remove `string`) + * @TODO — clean up time duration and date period */ export const compareTypesWithLiterals = ( - a: ESQLLiteral['literalType'] | FunctionParameterType | string, - b: ESQLLiteral['literalType'] | FunctionParameterType | string + a: ESQLLiteral['literalType'] | FunctionParameterType | 'timeInterval' | string, + b: ESQLLiteral['literalType'] | FunctionParameterType | 'timeInterval' | string ) => { if (a === b) { return true; } - if (a === 'decimal') { - return isNumericDecimalType(b); - } - if (b === 'decimal') { - return isNumericDecimalType(a); - } - if (a === 'string') { - return isStringType(b); - } - 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 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 98d2da8d78cc0..0078e0fac119c 100644 --- a/packages/kbn-esql-validation-autocomplete/src/shared/helpers.test.ts +++ b/packages/kbn-esql-validation-autocomplete/src/shared/helpers.test.ts @@ -7,7 +7,10 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { shouldBeQuotedSource } from './helpers'; +import { parse } from '@kbn/esql-ast'; +import { getExpressionType, shouldBeQuotedSource } from './helpers'; +import { SupportedDataType } from '../definitions/types'; +import { setTestFunctions } from './test_functions'; describe('shouldBeQuotedSource', () => { it('does not have to be quoted for sources with acceptable characters @-+$', () => { @@ -47,3 +50,295 @@ describe('shouldBeQuotedSource', () => { expect(shouldBeQuotedSource('index-[dd-mm]')).toBe(true); }); }); + +describe('getExpressionType', () => { + const getASTForExpression = (expression: string) => { + const { root } = parse(`FROM index | EVAL ${expression}`); + return root.commands[1].args[0]; + }; + + describe('literal expressions', () => { + const cases: Array<{ expression: string; expectedType: SupportedDataType }> = [ + { + expression: '1.0', + expectedType: 'double', + }, + { + expression: '1', + expectedType: 'integer', + }, + { + expression: 'true', + expectedType: 'boolean', + }, + { + expression: '"foobar"', + expectedType: 'keyword', + }, + { + expression: 'NULL', + expectedType: 'null', + }, + // TODO — consider whether we need to be worried about + // differentiating between time_duration, and date_period + // instead of just using time_literal + { + expression: '1 second', + expectedType: 'time_literal', + }, + { + expression: '1 day', + expectedType: 'time_literal', + }, + ]; + test.each(cases)('detects a literal of type $expectedType', ({ expression, expectedType }) => { + const ast = getASTForExpression(expression); + expect(getExpressionType(ast)).toBe(expectedType); + }); + }); + + describe('inline casting', () => { + const cases: Array<{ expression: string; expectedType: SupportedDataType }> = [ + { expectedType: 'boolean', expression: '"true"::bool' }, + { expectedType: 'boolean', expression: '"false"::boolean' }, + { expectedType: 'boolean', expression: '"false"::BooLEAN' }, + { expectedType: 'cartesian_point', expression: '""::cartesian_point' }, + { expectedType: 'cartesian_shape', expression: '""::cartesian_shape' }, + { expectedType: 'date_nanos', expression: '1::date_nanos' }, + { expectedType: 'date_period', expression: '1::date_period' }, + { expectedType: 'date', expression: '1::datetime' }, + { expectedType: 'double', expression: '1::double' }, + { expectedType: 'geo_point', expression: '""::geo_point' }, + { expectedType: 'geo_shape', expression: '""::geo_shape' }, + { expectedType: 'integer', expression: '1.2::int' }, + { expectedType: 'integer', expression: '1.2::integer' }, + { expectedType: 'ip', expression: '"123.12.12.2"::ip' }, + { expectedType: 'keyword', expression: '1::keyword' }, + { expectedType: 'long', expression: '1::long' }, + { expectedType: 'keyword', expression: '1::string' }, + { expectedType: 'keyword', expression: '1::text' }, + { expectedType: 'time_duration', expression: '1::time_duration' }, + { expectedType: 'unsigned_long', expression: '1::unsigned_long' }, + { expectedType: 'version', expression: '"1.2.3"::version' }, + { expectedType: 'version', expression: '"1.2.3"::VERSION' }, + ]; + test.each(cases)( + 'detects a casted literal of type $expectedType ($expression)', + ({ expression, expectedType }) => { + const ast = getASTForExpression(expression); + expect(getExpressionType(ast)).toBe(expectedType); + } + ); + }); + + describe('fields and variables', () => { + it('detects the type of fields and variables which exist', () => { + expect( + getExpressionType( + getASTForExpression('fieldName'), + new Map([ + [ + 'fieldName', + { + name: 'fieldName', + type: 'geo_shape', + }, + ], + ]), + new Map() + ) + ).toBe('geo_shape'); + + expect( + getExpressionType( + getASTForExpression('var0'), + new Map(), + new Map([ + [ + 'var0', + [ + { + name: 'var0', + type: 'long', + location: { min: 0, max: 0 }, + }, + ], + ], + ]) + ) + ).toBe('long'); + }); + + it('handles fields and variables which do not exist', () => { + expect(getExpressionType(getASTForExpression('fieldName'), new Map(), new Map())).toBe( + 'unknown' + ); + }); + }); + + describe('functions', () => { + beforeAll(() => { + setTestFunctions([ + { + type: 'eval', + name: 'test', + description: 'Test function', + supportedCommands: ['eval'], + signatures: [ + { params: [{ name: 'arg', type: 'keyword' }], returnType: 'keyword' }, + { params: [{ name: 'arg', type: 'double' }], returnType: 'double' }, + { + params: [ + { name: 'arg', type: 'double' }, + { name: 'arg', type: 'keyword' }, + ], + returnType: 'long', + }, + ], + }, + { + type: 'eval', + name: 'returns_keyword', + description: 'Test function', + supportedCommands: ['eval'], + signatures: [{ params: [], returnType: 'keyword' }], + }, + { + type: 'eval', + name: 'accepts_dates', + description: 'Test function', + supportedCommands: ['eval'], + signatures: [ + { + params: [ + { name: 'arg1', type: 'date' }, + { name: 'arg2', type: 'date_period' }, + ], + returnType: 'keyword', + }, + ], + }, + ]); + }); + afterAll(() => { + setTestFunctions([]); + }); + + it('detects the return type of a function', () => { + expect( + getExpressionType(getASTForExpression('returns_keyword()'), new Map(), new Map()) + ).toBe('keyword'); + }); + + it('selects the correct signature based on the arguments', () => { + expect(getExpressionType(getASTForExpression('test("foo")'), new Map(), new Map())).toBe( + 'keyword' + ); + expect(getExpressionType(getASTForExpression('test(1.)'), new Map(), new Map())).toBe( + 'double' + ); + expect(getExpressionType(getASTForExpression('test(1., "foo")'), new Map(), new Map())).toBe( + 'long' + ); + }); + + it('supports nested functions', () => { + expect( + getExpressionType( + getASTForExpression('test(1., test(test(test(returns_keyword()))))'), + new Map(), + new Map() + ) + ).toBe('long'); + }); + + it('supports functions with casted results', () => { + expect( + getExpressionType(getASTForExpression('test(1.)::keyword'), new Map(), new Map()) + ).toBe('keyword'); + }); + + it('handles nulls and string-date casting', () => { + expect(getExpressionType(getASTForExpression('test(NULL)'), new Map(), new Map())).toBe( + 'null' + ); + expect(getExpressionType(getASTForExpression('test(NULL, NULL)'), new Map(), new Map())).toBe( + 'null' + ); + expect( + getExpressionType(getASTForExpression('accepts_dates("", "")'), new Map(), new Map()) + ).toBe('keyword'); + }); + + it('deals with functions that do not exist', () => { + expect(getExpressionType(getASTForExpression('does_not_exist()'), new Map(), new Map())).toBe( + 'unknown' + ); + }); + + it('deals with bad function invocations', () => { + expect( + getExpressionType(getASTForExpression('test(1., "foo", "bar")'), new Map(), new Map()) + ).toBe('unknown'); + + expect(getExpressionType(getASTForExpression('test()'), new Map(), new Map())).toBe( + 'unknown' + ); + + expect(getExpressionType(getASTForExpression('test("foo", 1.)'), new Map(), new Map())).toBe( + 'unknown' + ); + }); + + it('deals with the CASE function', () => { + expect(getExpressionType(getASTForExpression('CASE(true, 1, 2)'), new Map(), new Map())).toBe( + 'integer' + ); + + expect( + getExpressionType(getASTForExpression('CASE(true, 1., true, 1., 2.)'), new Map(), new Map()) + ).toBe('double'); + + expect( + getExpressionType( + getASTForExpression('CASE(true, "", true, "", keywordField)'), + new Map([[`keywordField`, { name: 'keywordField', type: 'keyword' }]]), + new Map() + ) + ).toBe('keyword'); + }); + }); + + describe('lists', () => { + const cases: Array<{ expression: string; expectedType: SupportedDataType | 'unknown' }> = [ + { + expression: '["foo", "bar"]', + expectedType: 'keyword', + }, + { + expression: '[1, 2]', + expectedType: 'integer', + }, + { + expression: '[1., 2.]', + expectedType: 'double', + }, + { + expression: '[null, null, null]', + expectedType: 'null', + }, + { + expression: '[true, false]', + expectedType: 'boolean', + }, + ]; + + test.each(cases)( + 'reports the type of $expression as $expectedType', + ({ expression, expectedType }) => { + const ast = getASTForExpression(expression); + expect(getExpressionType(ast)).toBe(expectedType); + } + ); + }); +}); diff --git a/packages/kbn-esql-validation-autocomplete/src/shared/helpers.ts b/packages/kbn-esql-validation-autocomplete/src/shared/helpers.ts index e3e3da4277344..31c2c01a11404 100644 --- a/packages/kbn-esql-validation-autocomplete/src/shared/helpers.ts +++ b/packages/kbn-esql-validation-autocomplete/src/shared/helpers.ts @@ -43,10 +43,10 @@ import { FunctionParameterType, FunctionReturnType, ArrayType, + SupportedDataType, } from '../definitions/types'; import type { ESQLRealField, ESQLVariable, ReferenceMaps } from '../validation/types'; import { removeMarkerArgFromArgsList } from './context'; -import { compareTypesWithLiterals, isNumericDecimalType } from './esql_types'; import type { ReasonTypes } from './types'; import { DOUBLE_TICKS_REGEX, EDITOR_MARKER, SINGLE_BACKTICK } from './constants'; import type { EditorContext } from '../autocomplete/types'; @@ -225,27 +225,29 @@ export function getCommandOption(optionName: CommandOptionsDefinition['name']) { } function doesLiteralMatchParameterType(argType: FunctionParameterType, item: ESQLLiteral) { - if (item.literalType === 'null') { + if (item.literalType === argType) { return true; } - if (item.literalType === 'decimal' && isNumericDecimalType(argType)) { + if (item.literalType === 'null') { + // all parameters accept null, but this is not yet reflected + // in our function definitions so we let it through here return true; } - if (item.literalType === 'string' && (argType === 'text' || argType === 'keyword')) { + // some parameters accept string literals because of ES auto-casting + if ( + item.literalType === 'keyword' && + (argType === 'date' || + argType === 'date_period' || + argType === 'version' || + argType === 'ip' || + argType === 'boolean') + ) { return true; } - if (item.literalType !== 'string') { - if (argType === item.literalType) { - return true; - } - return false; - } - - // date-type parameters accept string literals because of ES auto-casting - return ['string', 'date', 'date_period'].includes(argType); + return false; } /** @@ -417,7 +419,7 @@ export function inKnownTimeInterval(item: ESQLTimeInterval): boolean { */ export function isValidLiteralOption(arg: ESQLLiteral, argDef: FunctionParameter) { return ( - arg.literalType === 'string' && + arg.literalType === 'keyword' && argDef.acceptedValues && !argDef.acceptedValues .map((option) => option.toLowerCase()) @@ -447,7 +449,7 @@ export function checkFunctionArgMatchesDefinition( if (isSupportedFunction(arg.name, parentCommand).supported) { const fnDef = buildFunctionLookup().get(arg.name)!; return fnDef.signatures.some( - (signature) => signature.returnType === 'any' || argType === signature.returnType + (signature) => signature.returnType === 'unknown' || argType === signature.returnType ); } } @@ -460,23 +462,15 @@ export function checkFunctionArgMatchesDefinition( if (!validHit) { return false; } - const wrappedTypes = Array.isArray(validHit.type) ? validHit.type : [validHit.type]; - // if final type is of type any make it pass for now - return wrappedTypes.some( - (ct) => - ['any', 'null'].includes(ct) || - argType === ct || - (ct === 'string' && ['text', 'keyword'].includes(argType as string)) - ); + const wrappedTypes: Array<(typeof validHit)['type']> = Array.isArray(validHit.type) + ? validHit.type + : [validHit.type]; + return wrappedTypes.some((ct) => ct === argType || ct === 'null' || ct === 'unknown'); } if (arg.type === 'inlineCast') { const lowerArgType = argType?.toLowerCase(); - const lowerArgCastType = arg.castType?.toLowerCase(); - return ( - compareTypesWithLiterals(lowerArgCastType, lowerArgType) || - // for valid shorthand casts like 321.12::int or "false"::bool - (['int', 'bool'].includes(lowerArgCastType) && argType.startsWith(lowerArgCastType)) - ); + const castedType = getExpressionType(arg); + return castedType === lowerArgType; } } @@ -725,3 +719,143 @@ export function correctQuerySyntax(_query: string, context: EditorContext) { return query; } + +/** + * Gets the signatures of a function that match the number of arguments + * provided in the AST. + */ +export function getSignaturesWithMatchingArity( + fnDef: FunctionDefinition, + astFunction: ESQLFunction +) { + return fnDef.signatures.filter((def) => { + if (def.minParams) { + return astFunction.args.length >= def.minParams; + } + return ( + astFunction.args.length >= def.params.filter(({ optional }) => !optional).length && + astFunction.args.length <= def.params.length + ); + }); +} + +/** + * Given a function signature, returns the parameter at the given position. + * + * Takes into account variadic functions (minParams), returning the last + * parameter if the position is greater than the number of parameters. + * + * @param signature + * @param position + * @returns + */ +export function getParamAtPosition( + { params, minParams }: FunctionDefinition['signatures'][number], + position: number +) { + return params.length > position ? params[position] : minParams ? params[params.length - 1] : null; +} + +/** + * Determines the type of the expression + */ +export function getExpressionType( + root: ESQLAstItem, + fields?: Map<string, ESQLRealField>, + variables?: Map<string, ESQLVariable[]> +): SupportedDataType | 'unknown' { + if (!isSingleItem(root)) { + if (root.length === 0) { + return 'unknown'; + } + return getExpressionType(root[0], fields, variables); + } + + if (isLiteralItem(root) && root.literalType !== 'param') { + return root.literalType; + } + + if (isTimeIntervalItem(root)) { + return 'time_literal'; + } + + // from https://github.com/elastic/elasticsearch/blob/122e7288200ee03e9087c98dff6cebbc94e774aa/docs/reference/esql/functions/kibana/inline_cast.json + if (isInlineCastItem(root)) { + switch (root.castType) { + case 'int': + return 'integer'; + case 'bool': + return 'boolean'; + case 'string': + return 'keyword'; + case 'text': + return 'keyword'; + case 'datetime': + return 'date'; + default: + return root.castType; + } + } + + if (isColumnItem(root) && fields && variables) { + const column = getColumnForASTNode(root, { fields, variables }); + if (!column) { + return 'unknown'; + } + return column.type; + } + + if (root.type === 'list') { + return getExpressionType(root.values[0], fields, variables); + } + + if (isFunctionItem(root)) { + const fnDefinition = getFunctionDefinition(root.name); + if (!fnDefinition) { + return 'unknown'; + } + + if (fnDefinition.name === 'case' && root.args.length) { + // The CASE function doesn't fit our system of function definitions + // and needs special handling. This is imperfect, but it's a start because + // at least we know that the final argument to case will never be a conditional + // expression, always a result expression. + // + // One problem with this is that if a false case is not provided, the return type + // will be null, which we aren't detecting. But this is ok because we consider + // variables and fields to be nullable anyways and account for that during validation. + return getExpressionType(root.args[root.args.length - 1], fields, variables); + } + + const signaturesWithCorrectArity = getSignaturesWithMatchingArity(fnDefinition, root); + + if (!signaturesWithCorrectArity.length) { + return 'unknown'; + } + + const argTypes = root.args.map((arg) => getExpressionType(arg, fields, variables)); + + // When functions are passed null for any argument, they generally return null + // This is a special case that is not reflected in our function definitions + if (argTypes.some((argType) => argType === 'null')) return 'null'; + + const matchingSignature = signaturesWithCorrectArity.find((signature) => { + return argTypes.every((argType, i) => { + const param = getParamAtPosition(signature, i); + return ( + param && + (param.type === argType || + (argType === 'keyword' && ['date', 'date_period'].includes(param.type))) + ); + }); + }); + + if (!matchingSignature) { + return 'unknown'; + } + + return matchingSignature.returnType === 'any' ? 'unknown' : matchingSignature.returnType; + } + + return 'unknown'; +} diff --git a/packages/kbn-esql-validation-autocomplete/src/shared/variables.ts b/packages/kbn-esql-validation-autocomplete/src/shared/variables.ts index 29656d2f581ed..c2de407264a99 100644 --- a/packages/kbn-esql-validation-autocomplete/src/shared/variables.ts +++ b/packages/kbn-esql-validation-autocomplete/src/shared/variables.ts @@ -11,7 +11,7 @@ import type { ESQLAst, ESQLAstItem, ESQLCommand, ESQLFunction } from '@kbn/esql- import { Visitor } from '@kbn/esql-ast/src/visitor'; import type { ESQLVariable, ESQLRealField } from '../validation/types'; import { EDITOR_MARKER } from './constants'; -import { isColumnItem, isFunctionItem, getFunctionDefinition } from './helpers'; +import { isColumnItem, isFunctionItem, getExpressionType } from './helpers'; function addToVariableOccurrences(variables: Map<string, ESQLVariable[]>, instance: ESQLVariable) { if (!variables.has(instance.name)) { @@ -43,62 +43,6 @@ function addToVariables( } } -/** - * Determines the type of the expression - * - * TODO - this function needs a lot of work. For example, it needs to find the best-matching function signature - * which it isn't currently doing. See https://github.com/elastic/kibana/issues/195682 - */ -function getExpressionType( - root: ESQLAstItem, - fields: Map<string, ESQLRealField>, - variables: Map<string, ESQLVariable[]> -): string { - const fallback = 'double'; - - if (Array.isArray(root) || !root) { - return fallback; - } - if (root.type === 'literal') { - return root.literalType; - } - if (root.type === 'inlineCast') { - if (root.castType === 'int') { - return 'integer'; - } - if (root.castType === 'bool') { - return 'boolean'; - } - return root.castType; - } - if (isColumnItem(root)) { - const field = fields.get(root.parts.join('.')); - if (field) { - return field.type; - } - const variable = variables.get(root.parts.join('.')); - if (variable) { - return variable[0].type; - } - } - if (isFunctionItem(root)) { - const fnDefinition = getFunctionDefinition(root.name); - return fnDefinition?.signatures[0].returnType ?? fallback; - } - return fallback; -} - -function getAssignRightHandSideType( - item: ESQLAstItem, - fields: Map<string, ESQLRealField>, - variables: Map<string, ESQLVariable[]> -) { - if (Array.isArray(item)) { - const firstArg = item[0]; - return getExpressionType(firstArg, fields, variables); - } -} - export function excludeVariablesFromCurrentCommand( commands: ESQLCommand[], currentCommand: ESQLCommand, @@ -122,14 +66,10 @@ function addVariableFromAssignment( fields: Map<string, ESQLRealField> ) { if (isColumnItem(assignOperation.args[0])) { - const rightHandSideArgType = getAssignRightHandSideType( - assignOperation.args[1], - fields, - variables - ); + const rightHandSideArgType = getExpressionType(assignOperation.args[1], fields, variables); addToVariableOccurrences(variables, { name: assignOperation.args[0].parts.join('.'), - type: rightHandSideArgType as string /* fallback to number */, + type: rightHandSideArgType /* fallback to number */, location: assignOperation.args[0].location, }); } @@ -138,14 +78,15 @@ function addVariableFromAssignment( function addVariableFromExpression( expressionOperation: ESQLFunction, queryString: string, - variables: Map<string, ESQLVariable[]> + variables: Map<string, ESQLVariable[]>, + fields: Map<string, ESQLRealField> ) { if (!expressionOperation.text.includes(EDITOR_MARKER)) { const expressionText = queryString.substring( expressionOperation.location.min, expressionOperation.location.max + 1 ); - const expressionType = 'double'; // TODO - use getExpressionType once it actually works + const expressionType = getExpressionType(expressionOperation, fields, variables); addToVariableOccurrences(variables, { name: expressionText, type: expressionType, @@ -174,7 +115,7 @@ export function collectVariables( if (ctx.node.name === '=') { addVariableFromAssignment(ctx.node, variables, fields); } else { - addVariableFromExpression(ctx.node, queryString, variables); + addVariableFromExpression(ctx.node, queryString, variables, fields); } }) .on('visitCommandOption', (ctx) => { diff --git a/packages/kbn-esql-validation-autocomplete/src/validation/__tests__/functions.test.ts b/packages/kbn-esql-validation-autocomplete/src/validation/__tests__/functions.test.ts index 85a6fadcc8f5c..a3934c1a35627 100644 --- a/packages/kbn-esql-validation-autocomplete/src/validation/__tests__/functions.test.ts +++ b/packages/kbn-esql-validation-autocomplete/src/validation/__tests__/functions.test.ts @@ -100,12 +100,12 @@ describe('function validation', () => { // straight call await expectErrors('FROM a_index | EVAL TEST(1.1)', [ - 'Argument of [test] must be [integer], found value [1.1] type [decimal]', + 'Argument of [test] must be [integer], found value [1.1] type [double]', ]); // assignment await expectErrors('FROM a_index | EVAL var = TEST(1.1)', [ - 'Argument of [test] must be [integer], found value [1.1] type [decimal]', + 'Argument of [test] must be [integer], found value [1.1] type [double]', ]); // nested function @@ -115,7 +115,7 @@ describe('function validation', () => { // inline cast await expectErrors('FROM a_index | EVAL TEST(1::DOUBLE)', [ - 'Argument of [test] must be [integer], found value [1::DOUBLE] type [DOUBLE]', + 'Argument of [test] must be [integer], found value [1::DOUBLE] type [double]', ]); // field @@ -125,13 +125,13 @@ describe('function validation', () => { // variables await expectErrors('FROM a_index | EVAL var1 = 1. | EVAL TEST(var1)', [ - 'Argument of [test] must be [integer], found value [var1] type [decimal]', + 'Argument of [test] must be [integer], found value [var1] type [double]', ]); // multiple instances await expectErrors('FROM a_index | EVAL TEST(1.1) | EVAL TEST(1.1)', [ - 'Argument of [test] must be [integer], found value [1.1] type [decimal]', - 'Argument of [test] must be [integer], found value [1.1] type [decimal]', + 'Argument of [test] must be [integer], found value [1.1] type [double]', + 'Argument of [test] must be [integer], found value [1.1] type [double]', ]); }); @@ -190,7 +190,7 @@ describe('function validation', () => { await expectErrors('ROW "a" IN ("a", "b", "c")', []); await expectErrors('ROW "a" IN (1, "b", "c")', [ - 'Argument of [in] must be [keyword[]], found value [(1, "b", "c")] type [(integer, string, string)]', + 'Argument of [in] must be [keyword[]], found value [(1, "b", "c")] type [(integer, keyword, keyword)]', ]); }); }); @@ -238,9 +238,9 @@ describe('function validation', () => { // double, double, double await expectErrors('FROM a_index | EVAL TEST(1., 1., 1.)', []); await expectErrors('FROM a_index | EVAL TEST("", "", "")', [ - 'Argument of [test] must be [double], found value [""] type [string]', - 'Argument of [test] must be [double], found value [""] type [string]', - 'Argument of [test] must be [double], found value [""] type [string]', + 'Argument of [test] must be [double], found value [""] type [keyword]', + 'Argument of [test] must be [double], found value [""] type [keyword]', + 'Argument of [test] must be [double], found value [""] type [keyword]', ]); // int, int @@ -260,7 +260,7 @@ describe('function validation', () => { // date await expectErrors('FROM a_index | EVAL TEST(NOW())', []); await expectErrors('FROM a_index | EVAL TEST(1.)', [ - 'Argument of [test] must be [date], found value [1.] type [decimal]', + 'Argument of [test] must be [date], found value [1.] type [double]', ]); }); }); @@ -721,5 +721,7 @@ describe('function validation', () => { // 'No nested aggregation functions.', // ]); }); + + // @TODO — test function aliases }); }); diff --git a/packages/kbn-esql-validation-autocomplete/src/validation/esql_validation_meta_tests.json b/packages/kbn-esql-validation-autocomplete/src/validation/esql_validation_meta_tests.json index a646c0323a76f..51ada18f02252 100644 --- a/packages/kbn-esql-validation-autocomplete/src/validation/esql_validation_meta_tests.json +++ b/packages/kbn-esql-validation-autocomplete/src/validation/esql_validation_meta_tests.json @@ -6654,14 +6654,14 @@ { "query": "from a_index | eval 1 * \"1\"", "error": [ - "Argument of [*] must be [double], found value [\"1\"] type [string]" + "Argument of [*] must be [double], found value [\"1\"] type [keyword]" ], "warning": [] }, { "query": "from a_index | eval \"1\" * 1", "error": [ - "Argument of [*] must be [double], found value [\"1\"] type [string]" + "Argument of [*] must be [double], found value [\"1\"] type [keyword]" ], "warning": [] }, @@ -6691,14 +6691,14 @@ { "query": "from a_index | eval 1 / \"1\"", "error": [ - "Argument of [/] must be [double], found value [\"1\"] type [string]" + "Argument of [/] must be [double], found value [\"1\"] type [keyword]" ], "warning": [] }, { "query": "from a_index | eval \"1\" / 1", "error": [ - "Argument of [/] must be [double], found value [\"1\"] type [string]" + "Argument of [/] must be [double], found value [\"1\"] type [keyword]" ], "warning": [] }, @@ -6728,14 +6728,14 @@ { "query": "from a_index | eval 1 % \"1\"", "error": [ - "Argument of [%] must be [double], found value [\"1\"] type [string]" + "Argument of [%] must be [double], found value [\"1\"] type [keyword]" ], "warning": [] }, { "query": "from a_index | eval \"1\" % 1", "error": [ - "Argument of [%] must be [double], found value [\"1\"] type [string]" + "Argument of [%] must be [double], found value [\"1\"] type [keyword]" ], "warning": [] }, @@ -9513,7 +9513,7 @@ "query": "from a_index | eval doubleField = \"5\"", "error": [], "warning": [ - "Column [doubleField] of type double has been overwritten as new type: string" + "Column [doubleField] of type double has been overwritten as new type: keyword" ] }, { @@ -9655,19 +9655,19 @@ "warning": [] }, { - "query": "from a_index | eval true AND \"false\"::boolean", + "query": "from a_index | eval true AND 0::boolean", "error": [], "warning": [] }, { - "query": "from a_index | eval true AND \"false\"::bool", + "query": "from a_index | eval true AND 0::bool", "error": [], "warning": [] }, { - "query": "from a_index | eval true AND \"false\"", + "query": "from a_index | eval true AND 0", "error": [ - "Argument of [and] must be [boolean], found value [\"false\"] type [string]" + "Argument of [and] must be [boolean], found value [0] type [integer]" ], "warning": [] }, diff --git a/packages/kbn-esql-validation-autocomplete/src/validation/types.ts b/packages/kbn-esql-validation-autocomplete/src/validation/types.ts index 99ce0f8ac5196..7aac9f16ad032 100644 --- a/packages/kbn-esql-validation-autocomplete/src/validation/types.ts +++ b/packages/kbn-esql-validation-autocomplete/src/validation/types.ts @@ -8,12 +8,15 @@ */ import type { ESQLMessage, ESQLLocation } from '@kbn/esql-ast'; -import { FieldType } from '../definitions/types'; +import { FieldType, SupportedDataType } from '../definitions/types'; import type { EditorError } from '../types'; export interface ESQLVariable { name: string; - type: string; + // invalid expressions produce columns of type "unknown" + // also, there are some cases where we can't yet infer the type of + // a valid expression as with `CASE` which can return union types + type: SupportedDataType | 'unknown'; location: ESQLLocation; } diff --git a/packages/kbn-esql-validation-autocomplete/src/validation/validation.test.ts b/packages/kbn-esql-validation-autocomplete/src/validation/validation.test.ts index dd04f0e506fe8..5e636a941a86c 100644 --- a/packages/kbn-esql-validation-autocomplete/src/validation/validation.test.ts +++ b/packages/kbn-esql-validation-autocomplete/src/validation/validation.test.ts @@ -1129,13 +1129,13 @@ describe('validation logic', () => { `from a_index | eval 1 ${op} "1"`, ['+', '-'].includes(op) ? [`Argument of [${op}] must be [date_period], found value [1] type [integer]`] - : [`Argument of [${op}] must be [double], found value [\"1\"] type [string]`] + : [`Argument of [${op}] must be [double], found value [\"1\"] type [keyword]`] ); testErrorsAndWarnings( `from a_index | eval "1" ${op} 1`, ['+', '-'].includes(op) ? [`Argument of [${op}] must be [date_period], found value [1] type [integer]`] - : [`Argument of [${op}] must be [double], found value [\"1\"] type [string]`] + : [`Argument of [${op}] must be [double], found value [\"1\"] type [keyword]`] ); // TODO: enable when https://github.com/elastic/elasticsearch/issues/108432 is complete // testErrorsAndWarnings(`from a_index | eval "2022" ${op} 1 day`, []); @@ -1478,7 +1478,7 @@ describe('validation logic', () => { testErrorsAndWarnings( 'from a_index | eval doubleField = "5"', [], - ['Column [doubleField] of type double has been overwritten as new type: string'] + ['Column [doubleField] of type double has been overwritten as new type: keyword'] ); }); @@ -1674,11 +1674,11 @@ describe('validation logic', () => { testErrorsAndWarnings('from a_index | eval TRIM(23::text)', []); testErrorsAndWarnings('from a_index | eval TRIM(23::keyword)', []); - testErrorsAndWarnings('from a_index | eval true AND "false"::boolean', []); - testErrorsAndWarnings('from a_index | eval true AND "false"::bool', []); - testErrorsAndWarnings('from a_index | eval true AND "false"', [ + testErrorsAndWarnings('from a_index | eval true AND 0::boolean', []); + testErrorsAndWarnings('from a_index | eval true AND 0::bool', []); + testErrorsAndWarnings('from a_index | eval true AND 0', [ // just a counter-case to make sure the previous tests are meaningful - 'Argument of [and] must be [boolean], found value ["false"] type [string]', + 'Argument of [and] must be [boolean], found value [0] type [integer]', ]); // enforces strings for cartesian_point conversion diff --git a/packages/kbn-esql-validation-autocomplete/src/validation/validation.ts b/packages/kbn-esql-validation-autocomplete/src/validation/validation.ts index 2790a8a14a3b1..9605da8460eed 100644 --- a/packages/kbn-esql-validation-autocomplete/src/validation/validation.ts +++ b/packages/kbn-esql-validation-autocomplete/src/validation/validation.ts @@ -26,7 +26,6 @@ import { CommandModeDefinition, CommandOptionsDefinition, FunctionParameter, - FunctionDefinition, } from '../definitions/types'; import { areFieldAndVariableTypesCompatible, @@ -54,6 +53,7 @@ import { isAggFunction, getQuotedColumnName, isInlineCastItem, + getSignaturesWithMatchingArity, } from '../shared/helpers'; import { collectVariables } from '../shared/variables'; import { getMessageFromId, errors } from './errors'; @@ -74,7 +74,7 @@ import { retrieveFieldsFromStringSources, } from './resources'; import { collapseWrongArgumentTypeMessages, getMaxMinNumberOfParams } from './helpers'; -import { getParamAtPosition } from '../autocomplete/helper'; +import { getParamAtPosition } from '../shared/helpers'; import { METADATA_FIELDS } from '../shared/constants'; import { compareTypesWithLiterals } from '../shared/esql_types'; @@ -88,7 +88,7 @@ function validateFunctionLiteralArg( const messages: ESQLMessage[] = []; if (isLiteralItem(actualArg)) { if ( - actualArg.literalType === 'string' && + actualArg.literalType === 'keyword' && argDef.acceptedValues && isValidLiteralOption(actualArg, argDef) ) { @@ -309,21 +309,6 @@ function validateFunctionColumnArg( return messages; } -function extractCompatibleSignaturesForFunction( - fnDef: FunctionDefinition, - astFunction: ESQLFunction -) { - return fnDef.signatures.filter((def) => { - if (def.minParams) { - return astFunction.args.length >= def.minParams; - } - return ( - astFunction.args.length >= def.params.filter(({ optional }) => !optional).length && - astFunction.args.length <= def.params.length - ); - }); -} - function removeInlineCasts(arg: ESQLAstItem): ESQLAstItem { if (isInlineCastItem(arg)) { return removeInlineCasts(arg.value); @@ -376,7 +361,7 @@ function validateFunction( return messages; } } - const matchingSignatures = extractCompatibleSignaturesForFunction(fnDefinition, astFunction); + const matchingSignatures = getSignaturesWithMatchingArity(fnDefinition, astFunction); if (!matchingSignatures.length) { const { max, min } = getMaxMinNumberOfParams(fnDefinition); if (max === min) { diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 0eb43e13319ad..61d08b0c81a3b 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -5221,7 +5221,6 @@ "kbn-esql-validation-autocomplete.esql.definition.assignDoc": "Affecter (=)", "kbn-esql-validation-autocomplete.esql.definition.divideDoc": "Diviser (/)", "kbn-esql-validation-autocomplete.esql.definition.equalToDoc": "Égal à", - "kbn-esql-validation-autocomplete.esql.definition.functionsDoc": "Afficher les fonctions ES|QL disponibles avec signatures", "kbn-esql-validation-autocomplete.esql.definition.greaterThanDoc": "Supérieur à", "kbn-esql-validation-autocomplete.esql.definition.greaterThanOrEqualToDoc": "Supérieur ou égal à", "kbn-esql-validation-autocomplete.esql.definition.inDoc": "Teste si la valeur d'une expression est contenue dans une liste d'autres expressions", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index ac7221091cc67..61ad6c58ea44c 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -5214,7 +5214,6 @@ "kbn-esql-validation-autocomplete.esql.definition.assignDoc": "割り当て(=)", "kbn-esql-validation-autocomplete.esql.definition.divideDoc": "除算(/)", "kbn-esql-validation-autocomplete.esql.definition.equalToDoc": "等しい", - "kbn-esql-validation-autocomplete.esql.definition.functionsDoc": "ES|QLで使用可能な関数と署名を表示", "kbn-esql-validation-autocomplete.esql.definition.greaterThanDoc": "より大きい", "kbn-esql-validation-autocomplete.esql.definition.greaterThanOrEqualToDoc": "よりも大きいまたは等しい", "kbn-esql-validation-autocomplete.esql.definition.inDoc": "ある式が取る値が、他の式のリストに含まれているかどうかをテストします", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index c05573fdeb550..22061c2c36715 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -5225,7 +5225,6 @@ "kbn-esql-validation-autocomplete.esql.definition.assignDoc": "分配 (=)", "kbn-esql-validation-autocomplete.esql.definition.divideDoc": "除 (/)", "kbn-esql-validation-autocomplete.esql.definition.equalToDoc": "等于", - "kbn-esql-validation-autocomplete.esql.definition.functionsDoc": "显示带签名的 ES|QL 可用函数", "kbn-esql-validation-autocomplete.esql.definition.greaterThanDoc": "大于", "kbn-esql-validation-autocomplete.esql.definition.greaterThanOrEqualToDoc": "大于或等于", "kbn-esql-validation-autocomplete.esql.definition.inDoc": "测试某表达式接受的值是否包含在其他表达式列表中",