[ES|QL] Adds parsing support for CHANGE_POINT command (#213982)

## Summary

Partially addresses https://github.com/elastic/kibana/issues/211543

- Implements parser support for `CHANGE_POINT` command.
- Introduces `ESQLAstChangePointCommand` interface for `CHANGE_POINT`
commands AST nodes.
- Parses command arguments into `args` array as well as more structural
fields `value`, `key?`, `target?`.

### 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-12 15:43:09 +01:00 committed by GitHub
parent bac2f0c6bd
commit dd9b5bb1b7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 335 additions and 6 deletions

View file

@ -0,0 +1,223 @@
/*
* 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 { EsqlQuery } from '../../query';
import { ESQLCommandOption } from '../../types';
import { Walker } from '../../walker';
describe('CHANGE_POINT command', () => {
describe('correctly formatted', () => {
describe('CHANGE_POINT <value> ...', () => {
it('can parse the command', () => {
const text = `FROM index | CHANGE_POINT value`;
const query = EsqlQuery.fromSrc(text);
expect(query.ast.commands[1]).toMatchObject({
type: 'command',
name: 'change_point',
});
});
it('parses value expression as command field', () => {
const text = `FROM index | CHANGE_POINT value`;
const query = EsqlQuery.fromSrc(text);
expect(query.ast.commands[1]).toMatchObject({
value: {
type: 'column',
name: 'value',
},
});
});
it('parses value expression as the first argument', () => {
const text = `FROM index | CHANGE_POINT value`;
const query = EsqlQuery.fromSrc(text);
expect(query.ast.commands[1]).toMatchObject({
args: [
{
type: 'column',
name: 'value',
},
],
});
});
});
describe('... ON key ...', () => {
it('parses key expression as command field', () => {
const text = `FROM index | CHANGE_POINT value ON key`;
const query = EsqlQuery.fromSrc(text);
expect(query.ast.commands[1]).toMatchObject({
key: {
type: 'column',
name: 'key',
},
});
});
it('parses key expression as command argument', () => {
const text = `FROM index | CHANGE_POINT value ON key`;
const query = EsqlQuery.fromSrc(text);
expect(query.ast.commands[1]).toMatchObject({
args: [
{},
{
type: 'option',
name: 'on',
args: [
{
type: 'column',
name: 'key',
},
],
},
],
});
});
it('parses correctly ON option location', () => {
const text = `FROM index | CHANGE_POINT value ON key`;
const query = EsqlQuery.fromSrc(text);
const option = query.ast.commands[1].args[1] as ESQLCommandOption;
expect(option).toMatchObject({
type: 'option',
name: 'on',
});
expect(text.slice(option.location!.min, option.location!.max + 1)).toBe('ON key');
});
});
describe('... AS type, pvalue', () => {
it('parses AS option as command field', () => {
const text = `FROM index | CHANGE_POINT value AS type, pvalue`;
const query = EsqlQuery.fromSrc(text);
expect(query.ast.commands[1]).toMatchObject({
target: {
type: {
type: 'column',
name: 'type',
},
pvalue: {
type: 'column',
name: 'pvalue',
},
},
});
});
it('parses AS option as command argument', () => {
const text = `FROM index | CHANGE_POINT value AS type, pvalue`;
const query = EsqlQuery.fromSrc(text);
expect(query.ast.commands[1]).toMatchObject({
args: [
{},
{
type: 'option',
name: 'as',
args: [
{
type: 'column',
name: 'type',
},
{
type: 'column',
name: 'pvalue',
},
],
},
],
});
});
it('correctly reports AS option location', () => {
const text = `FROM index | CHANGE_POINT value AS /* hello */ type, /* world */ pvalue`;
const query = EsqlQuery.fromSrc(text);
const option = query.ast.commands[1].args[1] as ESQLCommandOption;
expect(option).toMatchObject({
type: 'option',
name: 'as',
});
expect(text.slice(option.location!.min, option.location!.max + 1)).toBe(
'AS /* hello */ type, /* world */ pvalue'
);
});
});
it('parses example query with all options', () => {
const text = `
FROM k8s
| STATS count=COUNT() BY @timestamp=BUCKET(@timestamp, 1 MINUTE)
| CHANGE_POINT count ON @timestamp AS type, pvalue
`;
const query = EsqlQuery.fromSrc(text);
const command = Walker.match(query.ast, { type: 'command', name: 'change_point' });
expect(command).toMatchObject({
type: 'command',
name: 'change_point',
value: {
type: 'column',
name: 'count',
},
key: {
type: 'column',
name: '@timestamp',
},
target: {
type: {
type: 'column',
name: 'type',
},
pvalue: {
type: 'column',
name: 'pvalue',
},
},
args: [
{
type: 'column',
name: 'count',
},
{
type: 'option',
name: 'on',
args: [
{
type: 'column',
name: '@timestamp',
},
],
},
{
type: 'option',
name: 'as',
args: [
{
type: 'column',
name: 'type',
},
{
type: 'column',
name: 'pvalue',
},
],
},
],
});
});
});
});

View file

@ -31,6 +31,7 @@ import {
IndexPatternContext,
InlinestatsCommandContext,
JoinCommandContext,
type ChangePointCommandContext,
} from '../antlr/esql_parser';
import { default as ESQLParserListener } from '../antlr/esql_parser_listener';
import {
@ -61,6 +62,7 @@ import { createJoinCommand } from './factories/join';
import { createDissectCommand } from './factories/dissect';
import { createGrokCommand } from './factories/grok';
import { createStatsCommand } from './factories/stats';
import { createChangePointCommand } from './factories/change_point';
export class ESQLAstBuilderListener implements ESQLParserListener {
private ast: ESQLAst = [];
@ -317,6 +319,21 @@ export class ESQLAstBuilderListener implements ESQLParserListener {
this.ast.push(command);
}
/**
* Exit a parse tree produced by `esql_parser.changePointCommand`.
*
* Parse the CHANGE_POINT command:
*
* CHANGE_POINT <value> [ ON <key> ] [ AS <target-type>, <target-pvalue> ]
*
* @param ctx the parse tree
*/
exitChangePointCommand(ctx: ChangePointCommandContext): void {
const command = createChangePointCommand(ctx);
this.ast.push(command);
}
enterEveryRule(ctx: ParserRuleContext): void {
// method not implemented, added to satisfy interface expectation
}

View file

@ -58,6 +58,7 @@ import type {
ESQLIdentifier,
ESQLBinaryExpression,
BinaryExpressionOperator,
ESQLCommand,
} from '../types';
import { parseIdentifier, getPosition } from './helpers';
import { Builder, type AstNodeParserFields } from '../builder';
@ -84,8 +85,22 @@ const createParserFields = (ctx: ParserRuleContext): AstNodeParserFields => ({
incomplete: Boolean(ctx.exception),
});
export const createCommand = <Name extends string>(name: Name, ctx: ParserRuleContext) =>
Builder.command({ name, args: [] }, createParserFields(ctx));
export const createCommand = <
Name extends string,
Cmd extends ESQLCommand<Name> = ESQLCommand<Name>
>(
name: Name,
ctx: ParserRuleContext,
partial?: Partial<Cmd>
): Cmd => {
const command = Builder.command({ name, args: [] }, createParserFields(ctx)) as Cmd;
if (partial) {
Object.assign(command, partial);
}
return command;
};
export const createInlineCast = (ctx: InlineCastContext, value: ESQLInlineCast['value']) =>
Builder.expression.inlineCast(

View file

@ -0,0 +1,63 @@
/*
* 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 { ChangePointCommandContext } from '../../antlr/esql_parser';
import { Builder } from '../../builder';
import { ESQLAstChangePointCommand } from '../../types';
import { createColumn, createCommand } from '../factories';
import { getPosition } from '../helpers';
export const createChangePointCommand = (
ctx: ChangePointCommandContext
): ESQLAstChangePointCommand => {
const value = createColumn(ctx._value);
const command = createCommand<'change_point', ESQLAstChangePointCommand>('change_point', ctx, {
value,
});
command.args.push(value);
if (ctx._key) {
const key = createColumn(ctx._key);
const option = Builder.option(
{
name: 'on',
args: [key],
},
{
location: getPosition(ctx.ON().symbol, ctx._key.stop),
}
);
command.key = key;
command.args.push(option);
}
if (ctx._targetType && ctx._targetPvalue) {
const type = createColumn(ctx._targetType);
const pvalue = createColumn(ctx._targetPvalue);
const option = Builder.option(
{
name: 'as',
args: [type, pvalue],
},
{
location: getPosition(ctx.AS().symbol, ctx._targetPvalue.stop),
}
);
command.target = {
type,
pvalue,
};
command.args.push(option);
}
return command;
};

View file

@ -8,7 +8,7 @@
*/
import { JoinCommandContext, JoinTargetContext } from '../../antlr/esql_parser';
import { ESQLAstItem, ESQLCommand, ESQLIdentifier, ESQLSource } from '../../types';
import { ESQLAstItem, ESQLAstJoinCommand, ESQLIdentifier, ESQLSource } from '../../types';
import { createCommand, createOption, createSource } from '../factories';
import { visitValueExpression } from '../walkers';
@ -16,11 +16,13 @@ const createNodeFromJoinTarget = (ctx: JoinTargetContext): ESQLSource | ESQLIden
return createSource(ctx._index);
};
export const createJoinCommand = (ctx: JoinCommandContext): ESQLCommand => {
const command = createCommand('join', ctx);
export const createJoinCommand = (ctx: JoinCommandContext): ESQLAstJoinCommand => {
const command = createCommand<'join', ESQLAstJoinCommand>('join', ctx);
// Pick-up the <TYPE> of the command.
command.commandType = (ctx._type_?.text ?? 'lookup').toLocaleLowerCase();
command.commandType = (
ctx._type_?.text ?? 'lookup'
).toLocaleLowerCase() as ESQLAstJoinCommand['commandType'];
const joinTarget = createNodeFromJoinTarget(ctx.joinTarget());
const joinCondition = ctx.joinCondition();

View file

@ -96,6 +96,15 @@ export interface ESQLAstJoinCommand extends ESQLCommand<'join'> {
commandType: 'lookup' | 'left' | 'right';
}
export interface ESQLAstChangePointCommand extends ESQLCommand<'change_point'> {
value: ESQLColumn;
key?: ESQLColumn;
target?: {
type: ESQLColumn;
pvalue: ESQLColumn;
};
}
export interface ESQLCommandOption extends ESQLAstBaseItem {
type: 'option';
args: ESQLAstItem[];