[ES|QL] Add Builder and parser support for function map expressions (#215862)

## Summary

Closes https://github.com/elastic/kibana/issues/214361

ES|QL function can have a last optional argument which is a map of
values. This PR adds parsing support for this syntax:

```
FN(1, 2, 3, { "this" : "is", "a": "map" })
```

### 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
This commit is contained in:
Vadim Kibana 2025-03-26 13:43:24 +01:00 committed by GitHub
parent cb7b672d79
commit 404de07e4a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 379 additions and 4 deletions

View file

@ -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,
},
},
],
});
});
});

View file

@ -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<AstNodeTemplate<ESQLMap>, 'name' | 'entries'> &
Partial<Pick<ESQLMap, 'entries'>> = {},
fromParser?: Partial<AstNodeParserFields>
): 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<AstNodeParserFields>,
template?: Omit<AstNodeTemplate<ESQLMapEntry>, '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 = (

View file

@ -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,
},
],
},
},
],
},
],
},
],
},
]);
});
});

View file

@ -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()));
}
/**

View file

@ -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;
};

View file

@ -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);

View file

@ -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 =

View file

@ -437,7 +437,7 @@ export const commandDefinitions: Array<CommandDefinition<any>> = [
messages.push(
getMessageFromId({
messageId: 'wrongDissectOptionArgumentType',
values: { value: value ?? '' },
values: { value: (value as string | number) ?? '' },
locations: firstArg.location,
})
);