mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 17:59:23 -04:00
[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:
parent
cb7b672d79
commit
404de07e4a
8 changed files with 379 additions and 4 deletions
|
@ -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,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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 = (
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
|
@ -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()));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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;
|
||||
};
|
|
@ -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);
|
||||
|
|
|
@ -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 =
|
||||
|
|
|
@ -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,
|
||||
})
|
||||
);
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue