diff --git a/src/platform/packages/shared/kbn-esql-ast/src/builder/builder.test.ts b/src/platform/packages/shared/kbn-esql-ast/src/builder/builder.test.ts index c10c3edd49e6d..d6ed068633372 100644 --- a/src/platform/packages/shared/kbn-esql-ast/src/builder/builder.test.ts +++ b/src/platform/packages/shared/kbn-esql-ast/src/builder/builder.test.ts @@ -417,3 +417,67 @@ describe('order', () => { expect(text).toBe('a.b.c ASC NULLS FIRST'); }); }); + +describe('map', () => { + test('can construct an empty map', () => { + const node1 = Builder.expression.map(); + const node2 = Builder.expression.map({}); + const node3 = Builder.expression.map({ + entries: [], + }); + + expect(node1).toMatchObject({ + type: 'map', + entries: [], + }); + expect(node2).toMatchObject({ + type: 'map', + entries: [], + }); + expect(node3).toMatchObject({ + type: 'map', + entries: [], + }); + }); + + test('can construct a map with two keys', () => { + const node = Builder.expression.map({ + entries: [ + Builder.expression.entry('foo', Builder.expression.literal.integer(1)), + Builder.expression.entry('bar', Builder.expression.literal.integer(2)), + ], + }); + + expect(node).toMatchObject({ + type: 'map', + entries: [ + { + type: 'map-entry', + key: { + type: 'literal', + literalType: 'keyword', + valueUnquoted: 'foo', + }, + value: { + type: 'literal', + literalType: 'integer', + value: 1, + }, + }, + { + type: 'map-entry', + key: { + type: 'literal', + literalType: 'keyword', + valueUnquoted: 'bar', + }, + value: { + type: 'literal', + literalType: 'integer', + value: 2, + }, + }, + ], + }); + }); +}); diff --git a/src/platform/packages/shared/kbn-esql-ast/src/builder/builder.ts b/src/platform/packages/shared/kbn-esql-ast/src/builder/builder.ts index d047f353f52fd..ffd94ebc788d1 100644 --- a/src/platform/packages/shared/kbn-esql-ast/src/builder/builder.ts +++ b/src/platform/packages/shared/kbn-esql-ast/src/builder/builder.ts @@ -40,6 +40,8 @@ import { ESQLNullLiteral, BinaryExpressionOperator, ESQLParamKinds, + ESQLMap, + ESQLMapEntry, } from '../types'; import { AstNodeParserFields, AstNodeTemplate, PartialFields } from './types'; @@ -439,6 +441,42 @@ export namespace Builder { }; }; } + + export const map = ( + template: Omit, 'name' | 'entries'> & + Partial> = {}, + fromParser?: Partial + ): ESQLMap => { + const entries = template.entries ?? []; + + return { + ...template, + ...Builder.parserFields(fromParser), + name: '', + type: 'map', + entries, + }; + }; + + export const entry = ( + key: string | ESQLMapEntry['key'], + value: ESQLMapEntry['value'], + fromParser?: Partial, + template?: Omit, 'key' | 'value'> + ): ESQLMapEntry => { + if (typeof key === 'string') { + key = Builder.expression.literal.string(key); + } + + return { + ...template, + ...Builder.parserFields(fromParser), + name: '', + type: 'map-entry', + key, + value, + }; + }; } export const identifier = ( diff --git a/src/platform/packages/shared/kbn-esql-ast/src/parser/__tests__/map.test.ts b/src/platform/packages/shared/kbn-esql-ast/src/parser/__tests__/map.test.ts new file mode 100644 index 0000000000000..5cfb02fb9ec9d --- /dev/null +++ b/src/platform/packages/shared/kbn-esql-ast/src/parser/__tests__/map.test.ts @@ -0,0 +1,182 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { parse } from '..'; + +describe('map expression', () => { + it('function call with an empty trailing map errors', () => { + const query = 'ROW fn(1, {})'; + const { errors } = parse(query); + + expect(errors.length > 0).toBe(true); + }); + + it('errors when trailing map argument is the single function argument', () => { + const query = 'ROW fn({"foo" : "bar"})'; + const { errors } = parse(query); + + expect(errors.length > 0).toBe(true); + }); + + it('function call with a trailing map with a single entry', () => { + const query = 'ROW fn(1, {"foo" : "bar"})'; + const { ast, errors } = parse(query); + + expect(errors.length).toBe(0); + expect(ast).toMatchObject([ + { + type: 'command', + name: 'row', + args: [ + { + type: 'function', + name: 'fn', + args: [ + { + type: 'literal', + value: 1, + }, + { + type: 'map', + entries: [ + { + type: 'map-entry', + key: { + type: 'literal', + literalType: 'keyword', + valueUnquoted: 'foo', + }, + value: { + type: 'literal', + literalType: 'keyword', + valueUnquoted: 'bar', + }, + }, + ], + }, + ], + }, + ], + }, + ]); + }); + + it('multiple trailing map arguments with multiple keys', () => { + const query = + 'ROW fn(1, fn2(1, {"a": TRUE, /* asdf */ "b" : 123}), {"foo" : "bar", "baz" : [1, 2, 3]})'; + const { ast, errors } = parse(query); + + expect(errors.length).toBe(0); + expect(ast).toMatchObject([ + { + type: 'command', + name: 'row', + args: [ + { + type: 'function', + name: 'fn', + args: [ + { + type: 'literal', + value: 1, + }, + { + type: 'function', + name: 'fn2', + args: [ + { + type: 'literal', + value: 1, + }, + { + type: 'map', + entries: [ + { + type: 'map-entry', + key: { + type: 'literal', + literalType: 'keyword', + valueUnquoted: 'a', + }, + value: { + type: 'literal', + literalType: 'boolean', + value: 'TRUE', + }, + }, + { + type: 'map-entry', + key: { + type: 'literal', + literalType: 'keyword', + valueUnquoted: 'b', + }, + value: { + type: 'literal', + literalType: 'integer', + value: 123, + }, + }, + ], + }, + ], + }, + { + type: 'map', + entries: [ + { + type: 'map-entry', + key: { + type: 'literal', + literalType: 'keyword', + valueUnquoted: 'foo', + }, + value: { + type: 'literal', + literalType: 'keyword', + valueUnquoted: 'bar', + }, + }, + { + type: 'map-entry', + key: { + type: 'literal', + literalType: 'keyword', + valueUnquoted: 'baz', + }, + value: { + type: 'list', + values: [ + { + type: 'literal', + literalType: 'integer', + value: 1, + }, + { + type: 'literal', + literalType: 'integer', + value: 2, + }, + { + type: 'literal', + literalType: 'integer', + value: 3, + }, + ], + }, + }, + ], + }, + ], + }, + ], + }, + ]); + }); +}); diff --git a/src/platform/packages/shared/kbn-esql-ast/src/parser/esql_ast_builder_listener.ts b/src/platform/packages/shared/kbn-esql-ast/src/parser/esql_ast_builder_listener.ts index c2afb4405d74c..fbd31afce3361 100644 --- a/src/platform/packages/shared/kbn-esql-ast/src/parser/esql_ast_builder_listener.ts +++ b/src/platform/packages/shared/kbn-esql-ast/src/parser/esql_ast_builder_listener.ts @@ -63,6 +63,7 @@ import { createGrokCommand } from './factories/grok'; import { createStatsCommand } from './factories/stats'; import { createChangePointCommand } from './factories/change_point'; import { createWhereCommand } from './factories/where'; +import { createRowCommand } from './factories/row'; export class ESQLAstBuilderListener implements ESQLParserListener { private ast: ESQLAst = []; @@ -112,9 +113,9 @@ export class ESQLAstBuilderListener implements ESQLParserListener { * @param ctx the parse tree */ exitRowCommand(ctx: RowCommandContext) { - const command = createCommand('row', ctx); + const command = createRowCommand(ctx); + this.ast.push(command); - command.args.push(...collectAllFields(ctx.fields())); } /** diff --git a/src/platform/packages/shared/kbn-esql-ast/src/parser/factories/row.ts b/src/platform/packages/shared/kbn-esql-ast/src/parser/factories/row.ts new file mode 100644 index 0000000000000..3380274f1a4f2 --- /dev/null +++ b/src/platform/packages/shared/kbn-esql-ast/src/parser/factories/row.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { RowCommandContext } from '../../antlr/esql_parser'; +import { ESQLCommand } from '../../types'; +import { createCommand } from '../factories'; +import { collectAllFields } from '../walkers'; + +export const createRowCommand = (ctx: RowCommandContext): ESQLCommand<'row'> => { + const command = createCommand('row', ctx); + const fields = collectAllFields(ctx.fields()); + + command.args.push(...fields); + + return command; +}; diff --git a/src/platform/packages/shared/kbn-esql-ast/src/parser/walkers.ts b/src/platform/packages/shared/kbn-esql-ast/src/parser/walkers.ts index 4841b7a36515d..e27ed21375ebe 100644 --- a/src/platform/packages/shared/kbn-esql-ast/src/parser/walkers.ts +++ b/src/platform/packages/shared/kbn-esql-ast/src/parser/walkers.ts @@ -61,6 +61,8 @@ import { InlinestatsCommandContext, MatchExpressionContext, MatchBooleanExpressionContext, + MapExpressionContext, + EntryExpressionContext, } from '../antlr/esql_parser'; import { createSource, @@ -99,6 +101,10 @@ import { ESQLOrderExpression, ESQLBinaryExpression, InlineCastingType, + ESQLMap, + ESQLMapEntry, + ESQLStringLiteral, + ESQLAstExpression, } from '../types'; import { firstItem, lastItem } from '../visitor/utils'; import { Builder } from '../builder'; @@ -441,9 +447,19 @@ export function visitPrimaryExpression(ctx: PrimaryExpressionContext): ESQLAstIt .booleanExpression_list() .flatMap(collectBooleanExpression) .filter(nonNullable); + if (functionArgs.length) { fn.args.push(...functionArgs); } + + const mapExpressionCtx = functionExpressionCtx.mapExpression(); + + if (mapExpressionCtx) { + const trailingMap = visitMapExpression(mapExpressionCtx); + + fn.args.push(trailingMap); + } + return fn; } else if (ctx instanceof InlineCastContext) { return collectInlineCast(ctx); @@ -451,6 +467,38 @@ export function visitPrimaryExpression(ctx: PrimaryExpressionContext): ESQLAstIt return createUnknownItem(ctx); } +export const visitMapExpression = (ctx: MapExpressionContext): ESQLMap => { + const map = Builder.expression.map( + {}, + { + location: getPosition(ctx.start, ctx.stop), + incomplete: Boolean(ctx.exception), + } + ); + const entryCtxs = ctx.entryExpression_list(); + + for (const entryCtx of entryCtxs) { + const entry = visitMapEntryExpression(entryCtx); + + map.entries.push(entry); + } + + return map; +}; + +export const visitMapEntryExpression = (ctx: EntryExpressionContext): ESQLMapEntry => { + const keyCtx = ctx._key; + const valueCtx = ctx._value; + const key = createLiteralString(keyCtx) as ESQLStringLiteral; + const value = getConstant(valueCtx) as ESQLAstExpression; + const entry = Builder.expression.entry(key, value, { + location: getPosition(ctx.start, ctx.stop), + incomplete: Boolean(ctx.exception), + }); + + return entry; +}; + function collectInlineCast(ctx: InlineCastContext): ESQLInlineCast { const primaryExpression = visitPrimaryExpression(ctx.primaryExpression()); return createInlineCast(ctx, primaryExpression); diff --git a/src/platform/packages/shared/kbn-esql-ast/src/types.ts b/src/platform/packages/shared/kbn-esql-ast/src/types.ts index 43e6eb2aa9224..aa7ac5febec3c 100644 --- a/src/platform/packages/shared/kbn-esql-ast/src/types.ts +++ b/src/platform/packages/shared/kbn-esql-ast/src/types.ts @@ -30,7 +30,9 @@ export type ESQLSingleAstItem = | ESQLCommandMode | ESQLInlineCast | ESQLOrderExpression - | ESQLUnknownItem; + | ESQLUnknownItem + | ESQLMap + | ESQLMapEntry; export type ESQLAstField = ESQLFunction | ESQLColumn; @@ -334,6 +336,24 @@ export interface ESQLList extends ESQLAstBaseItem { values: ESQLLiteral[]; } +/** + * Represents a ES|QL "map" object, normally used as the last argument of a + * function. + */ +export interface ESQLMap extends ESQLAstBaseItem { + type: 'map'; + entries: ESQLMapEntry[]; +} + +/** + * Represents a key-value pair in a ES|QL map object. + */ +export interface ESQLMapEntry extends ESQLAstBaseItem { + type: 'map-entry'; + key: ESQLStringLiteral; + value: ESQLAstExpression; +} + export type ESQLNumericLiteralType = 'double' | 'integer'; export type ESQLLiteral = diff --git a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/definitions/commands.ts b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/definitions/commands.ts index 747454f20611b..4023fa00d15ab 100644 --- a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/definitions/commands.ts +++ b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/definitions/commands.ts @@ -437,7 +437,7 @@ export const commandDefinitions: Array> = [ messages.push( getMessageFromId({ messageId: 'wrongDissectOptionArgumentType', - values: { value: value ?? '' }, + values: { value: (value as string | number) ?? '' }, locations: firstArg.location, }) );