[ES|QL] AST/parser support for double param ?? (#215337)

## Summary

- Adds support for double param: (1) `??` unnamed; (2) `??name` named;
and (3) `??123` positional.
- In the `@kbn/esql-ast` package.
- Adds types, parsing, builder and pretty-printing support.


### 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-20 17:43:50 +01:00 committed by GitHub
parent c8abafc6e7
commit a94ff02ba0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 345 additions and 180 deletions

View file

@ -308,6 +308,20 @@ describe('param', () => {
expect(text).toBe('?');
expect(node).toMatchObject({
type: 'literal',
paramKind: '?',
literalType: 'param',
paramType: 'unnamed',
});
});
test('unnamed (double)', () => {
const node = Builder.param.build('??');
const text = BasicPrettyPrinter.expression(node);
expect(text).toBe('??');
expect(node).toMatchObject({
type: 'literal',
paramKind: '??',
literalType: 'param',
paramType: 'unnamed',
});
@ -320,6 +334,21 @@ describe('param', () => {
expect(text).toBe('?the_name');
expect(node).toMatchObject({
type: 'literal',
paramKind: '?',
literalType: 'param',
paramType: 'named',
value: 'the_name',
});
});
test('named (double)', () => {
const node = Builder.param.build('??the_name');
const text = BasicPrettyPrinter.expression(node);
expect(text).toBe('??the_name');
expect(node).toMatchObject({
type: 'literal',
paramKind: '??',
literalType: 'param',
paramType: 'named',
value: 'the_name',
@ -333,6 +362,21 @@ describe('param', () => {
expect(text).toBe('?123');
expect(node).toMatchObject({
type: 'literal',
paramKind: '?',
literalType: 'param',
paramType: 'positional',
value: 123,
});
});
test('positional (double)', () => {
const node = Builder.param.build('??123');
const text = BasicPrettyPrinter.expression(node);
expect(text).toBe('??123');
expect(node).toMatchObject({
type: 'literal',
paramKind: '??',
literalType: 'param',
paramType: 'positional',
value: 123,

View file

@ -39,6 +39,7 @@ import {
ESQLBooleanLiteral,
ESQLNullLiteral,
BinaryExpressionOperator,
ESQLParamKinds,
} from '../types';
import { AstNodeParserFields, AstNodeTemplate, PartialFields } from './types';
@ -454,9 +455,14 @@ export namespace Builder {
};
export namespace param {
export const unnamed = (fromParser?: Partial<AstNodeParserFields>): ESQLParam => {
export const unnamed = (
fromParser?: Partial<AstNodeParserFields>,
template?: Partial<Pick<ESQLParam, 'paramKind'>>
): ESQLParam => {
const node = {
...Builder.parserFields(fromParser),
paramKind: '?',
...template,
name: '',
value: '',
paramType: 'unnamed',
@ -468,10 +474,15 @@ export namespace Builder {
};
export const named = (
template: Omit<AstNodeTemplate<ESQLNamedParamLiteral>, 'name' | 'literalType' | 'paramType'>,
template: Omit<
AstNodeTemplate<ESQLNamedParamLiteral>,
'name' | 'literalType' | 'paramType' | 'paramKind'
> &
Partial<Pick<ESQLNamedParamLiteral, 'paramKind'>>,
fromParser?: Partial<AstNodeParserFields>
): ESQLNamedParamLiteral => {
const node: ESQLNamedParamLiteral = {
paramKind: '?',
...template,
...Builder.parserFields(fromParser),
name: '',
@ -486,11 +497,13 @@ export namespace Builder {
export const positional = (
template: Omit<
AstNodeTemplate<ESQLPositionalParamLiteral>,
'name' | 'literalType' | 'paramType'
>,
'name' | 'literalType' | 'paramType' | 'paramKind'
> &
Partial<Pick<ESQLPositionalParamLiteral, 'paramKind'>>,
fromParser?: Partial<AstNodeParserFields>
): ESQLPositionalParamLiteral => {
const node: ESQLPositionalParamLiteral = {
paramKind: '?',
...template,
...Builder.parserFields(fromParser),
name: '',
@ -507,18 +520,29 @@ export namespace Builder {
options: Partial<ESQLParamLiteral> = {},
fromParser?: Partial<AstNodeParserFields>
): ESQLParam => {
const value: string = name.startsWith('?') ? name.slice(1) : name;
let paramKind: ESQLParamKinds = options.paramKind ?? '?';
if (name.startsWith('??')) {
paramKind = '??';
} else if (name.startsWith('?')) {
paramKind = '?';
}
const value: string = name.startsWith('?') ? name.slice(paramKind === '?' ? 1 : 2) : name;
if (!value) {
return Builder.param.unnamed(options);
return Builder.param.unnamed(options, { paramKind });
}
const isNumeric = !isNaN(Number(value)) && String(Number(value)) === value;
if (isNumeric) {
return Builder.param.positional({ ...options, value: Number(value) }, fromParser);
return Builder.param.positional(
{ ...options, paramKind, value: Number(value) },
fromParser
);
} else {
return Builder.param.named({ ...options, value }, fromParser);
return Builder.param.named({ ...options, paramKind, value }, fromParser);
}
};
}

View file

@ -10,168 +10,246 @@
import { parse } from '..';
import { Walker } from '../../walker';
/**
* Un-named parameters are represented by a question mark "?".
*/
describe('un-named parameters', () => {
describe('correctly formatted', () => {
it('can parse a single un-named param', () => {
const query = 'ROW x = ?';
const { ast, errors } = parse(query);
const params = Walker.params(ast);
describe('single ? param', () => {
/**
* Un-named parameters are represented by a question mark "?".
*/
describe('un-named parameters', () => {
describe('correctly formatted', () => {
it('can parse a single un-named param', () => {
const query = 'ROW x = ?';
const { ast, errors } = parse(query);
const params = Walker.params(ast);
expect(errors.length).toBe(0);
expect(params).toMatchObject([
{
type: 'literal',
literalType: 'param',
paramType: 'unnamed',
location: {
min: 8,
max: 8,
expect(errors.length).toBe(0);
expect(params).toMatchObject([
{
type: 'literal',
literalType: 'param',
paramType: 'unnamed',
location: {
min: 8,
max: 8,
},
},
},
]);
]);
const { min, max } = params[0].location;
const { min, max } = params[0].location;
expect(query.slice(min, max + 1)).toBe('?');
expect(query.slice(min, max + 1)).toBe('?');
});
});
});
});
/**
* Positional parameters are represented by a question mark followed by a number "?1".
*/
describe('positional parameters', () => {
describe('correctly formatted', () => {
it('can parse a single positional param', () => {
const query = 'ROW x = ?1';
const { ast, errors } = parse(query);
const params = Walker.params(ast);
/**
* Positional parameters are represented by a question mark followed by a number "?1".
*/
describe('positional parameters', () => {
describe('correctly formatted', () => {
it('can parse a single positional param', () => {
const query = 'ROW x = ?1';
const { ast, errors } = parse(query);
const params = Walker.params(ast);
expect(errors.length).toBe(0);
expect(params).toMatchObject([
{
type: 'literal',
literalType: 'param',
paramType: 'positional',
value: 1,
location: {
min: 8,
max: 9,
expect(errors.length).toBe(0);
expect(params).toMatchObject([
{
type: 'literal',
literalType: 'param',
paramType: 'positional',
value: 1,
location: {
min: 8,
max: 9,
},
},
},
]);
]);
const { min, max } = params[0].location;
const { min, max } = params[0].location;
expect(query.slice(min, max + 1)).toBe('?1');
expect(query.slice(min, max + 1)).toBe('?1');
});
it('multiple positional params', () => {
const query = 'ROW x = ?5, x2 = ?5, y = ?6, z = ?7';
const { ast, errors } = parse(query);
const params = Walker.params(ast);
expect(errors.length).toBe(0);
expect(params.length).toBe(4);
params.sort((a, b) => a.location.min - b.location.min);
expect(params).toMatchObject([
{
type: 'literal',
literalType: 'param',
paramType: 'positional',
value: 5,
},
{
type: 'literal',
literalType: 'param',
paramType: 'positional',
value: 5,
},
{
type: 'literal',
literalType: 'param',
paramType: 'positional',
value: 6,
},
{
type: 'literal',
literalType: 'param',
paramType: 'positional',
value: 7,
},
]);
});
});
});
/**
* Named parameters are represented by a question mark followed by a name "?name".
*/
describe('named parameters', () => {
describe('correctly formatted', () => {
it('can parse a single named param', () => {
const query = 'ROW x = ?theName';
const { ast, errors } = parse(query);
const params = Walker.params(ast);
expect(errors.length).toBe(0);
expect(params).toMatchObject([
{
type: 'literal',
literalType: 'param',
paramType: 'named',
value: 'theName',
location: {
min: 8,
max: 15,
},
},
]);
const { min, max } = params[0].location;
expect(query.slice(min, max + 1)).toBe('?theName');
});
});
it('multiple positional params', () => {
const query = 'ROW x = ?5, x2 = ?5, y = ?6, z = ?7';
it('multiple named params', () => {
const query = 'ROW x = ?a, y = ?b, z = ?c';
const { ast, errors } = parse(query);
const params = Walker.params(ast);
expect(errors.length).toBe(0);
expect(params.length).toBe(4);
expect(params.length).toBe(3);
params.sort((a, b) => a.location.min - b.location.min);
expect(params).toMatchObject([
{
type: 'literal',
literalType: 'param',
paramType: 'positional',
value: 5,
paramType: 'named',
value: 'a',
},
{
type: 'literal',
literalType: 'param',
paramType: 'positional',
value: 5,
},
{
type: 'literal',
literalType: 'param',
paramType: 'positional',
value: 6,
},
{
type: 'literal',
literalType: 'param',
paramType: 'positional',
value: 7,
},
]);
});
});
});
/**
* Named parameters are represented by a question mark followed by a name "?name".
*/
describe('named parameters', () => {
describe('correctly formatted', () => {
it('can parse a single named param', () => {
const query = 'ROW x = ?theName';
const { ast, errors } = parse(query);
const params = Walker.params(ast);
expect(errors.length).toBe(0);
expect(params).toMatchObject([
{
type: 'literal',
literalType: 'param',
paramType: 'named',
value: 'theName',
location: {
min: 8,
max: 15,
},
value: 'b',
},
{
type: 'literal',
literalType: 'param',
paramType: 'named',
value: 'c',
},
]);
const { min, max } = params[0].location;
expect(query.slice(min, max + 1)).toBe('?theName');
});
});
it('multiple named params', () => {
const query = 'ROW x = ?a, y = ?b, z = ?c';
const { ast, errors } = parse(query);
const params = Walker.params(ast);
expect(errors.length).toBe(0);
expect(params.length).toBe(3);
params.sort((a, b) => a.location.min - b.location.min);
expect(params).toMatchObject([
{
type: 'literal',
literalType: 'param',
paramType: 'named',
value: 'a',
},
{
type: 'literal',
literalType: 'param',
paramType: 'named',
value: 'b',
},
{
type: 'literal',
literalType: 'param',
paramType: 'named',
value: 'c',
},
]);
});
describe('when incorrectly formatted, returns errors', () => {
it('three question marks "?" in a row', () => {
const text = 'ROW x = ???';
const { errors } = parse(text);
expect(errors.length > 0).toBe(true);
describe('when incorrectly formatted, returns errors', () => {
it('three question marks "?" in a row', () => {
const text = 'ROW x = ???';
const { errors } = parse(text);
expect(errors.length > 0).toBe(true);
});
});
});
});
describe('double ?? param', () => {
describe('unnamed parameters', () => {
describe('correctly formatted', () => {
it('can parse a single named param', () => {
const query = 'ROW ??';
const { ast, errors } = parse(query);
const params = Walker.params(ast);
expect(errors.length).toBe(0);
expect(params).toMatchObject([
{
type: 'literal',
paramKind: '??',
literalType: 'param',
paramType: 'unnamed',
},
]);
const { min, max } = params[0].location;
expect(query.slice(min, max + 1)).toBe('??');
});
});
});
describe('named parameters', () => {
describe('correctly formatted', () => {
it('can parse a single named param', () => {
const query = 'ROW ??theName';
const { ast, errors } = parse(query);
const params = Walker.params(ast);
expect(errors.length).toBe(0);
expect(params).toMatchObject([
{
type: 'literal',
paramKind: '??',
literalType: 'param',
paramType: 'named',
value: 'theName',
},
]);
const { min, max } = params[0].location;
expect(query.slice(min, max + 1)).toBe('??theName');
});
});
});
describe('positional parameters', () => {
describe('correctly formatted', () => {
it('can parse a single named param', () => {
const query = 'ROW ??123';
const { ast, errors } = parse(query);
const params = Walker.params(ast);
expect(errors.length).toBe(0);
expect(params).toMatchObject([
{
type: 'literal',
paramKind: '??',
literalType: 'param',
paramType: 'positional',
value: 123,
},
]);
const { min, max } = params[0].location;
expect(query.slice(min, max + 1)).toBe('??123');
});
});
});
});

View file

@ -33,6 +33,8 @@ import {
InputNamedOrPositionalParamContext,
IdentifierOrParameterContext,
StringContext,
InputNamedOrPositionalDoubleParamsContext,
InputDoubleParamsContext,
} from '../antlr/esql_parser';
import { DOUBLE_TICKS_REGEX, SINGLE_BACKTICK, TICKS_REGEX } from './constants';
import type {
@ -59,6 +61,7 @@ import type {
ESQLBinaryExpression,
BinaryExpressionOperator,
ESQLCommand,
ESQLParamKinds,
} from '../types';
import { parseIdentifier, getPosition } from './helpers';
import { Builder, type AstNodeParserFields } from '../builder';
@ -289,13 +292,15 @@ export const createBinaryExpression = (
export const createIdentifierOrParam = (ctx: IdentifierOrParameterContext) => {
const identifier = ctx.identifier();
if (identifier) {
return createIdentifier(identifier);
} else {
const parameter = ctx.parameter();
if (parameter) {
return createParam(parameter);
}
}
const parameter = ctx.parameter() ?? ctx.doubleParameter();
if (parameter) {
return createParam(parameter);
}
};
@ -307,19 +312,27 @@ export const createIdentifier = (identifier: IdentifierContext): ESQLIdentifier
};
export const createParam = (ctx: ParseTree) => {
if (ctx instanceof InputParamContext) {
return Builder.param.unnamed(createParserFields(ctx));
} else if (ctx instanceof InputNamedOrPositionalParamContext) {
if (ctx instanceof InputParamContext || ctx instanceof InputDoubleParamsContext) {
const isDoubleParam = ctx instanceof InputDoubleParamsContext;
const paramKind: ESQLParamKinds = isDoubleParam ? '??' : '?';
return Builder.param.unnamed(createParserFields(ctx), { paramKind });
} else if (
ctx instanceof InputNamedOrPositionalParamContext ||
ctx instanceof InputNamedOrPositionalDoubleParamsContext
) {
const isDoubleParam = ctx instanceof InputNamedOrPositionalDoubleParamsContext;
const paramKind: ESQLParamKinds = isDoubleParam ? '??' : '?';
const text = ctx.getText();
const value = text.slice(1);
const value = text.slice(isDoubleParam ? 2 : 1);
const valueAsNumber = Number(value);
const isPositional = String(valueAsNumber) === value;
const parserFields = createParserFields(ctx);
if (isPositional) {
return Builder.param.positional({ value: valueAsNumber }, parserFields);
return Builder.param.positional({ paramKind, value: valueAsNumber }, parserFields);
} else {
return Builder.param.named({ value }, parserFields);
return Builder.param.named({ paramKind, value }, parserFields);
}
}
};

View file

@ -310,8 +310,7 @@ function visitOperatorExpression(
fn.args.push(arg);
}
return fn;
}
if (ctx instanceof ArithmeticBinaryContext) {
} else if (ctx instanceof ArithmeticBinaryContext) {
const fn = createFunction(getMathOperation(ctx), ctx, undefined, 'binary-expression');
const args = [visitOperatorExpression(ctx._left), visitOperatorExpression(ctx._right)];
for (const arg of args) {
@ -323,8 +322,7 @@ function visitOperatorExpression(
const argsLocationExtends = computeLocationExtends(fn);
fn.location = argsLocationExtends;
return fn;
}
if (ctx instanceof OperatorExpressionDefaultContext) {
} else if (ctx instanceof OperatorExpressionDefaultContext) {
return visitPrimaryExpression(ctx.primaryExpression());
}
}
@ -424,14 +422,11 @@ export function visitRenameClauses(clausesCtx: RenameClauseContext[]): ESQLAstIt
export function visitPrimaryExpression(ctx: PrimaryExpressionContext): ESQLAstItem | ESQLAstItem[] {
if (ctx instanceof ConstantDefaultContext) {
return getConstant(ctx.constant());
}
if (ctx instanceof DereferenceContext) {
} else if (ctx instanceof DereferenceContext) {
return createColumn(ctx.qualifiedName());
}
if (ctx instanceof ParenthesizedExpressionContext) {
} else if (ctx instanceof ParenthesizedExpressionContext) {
return collectBooleanExpression(ctx.booleanExpression());
}
if (ctx instanceof FunctionContext) {
} else if (ctx instanceof FunctionContext) {
const functionExpressionCtx = ctx.functionExpression();
const fn = createFunctionCall(ctx);
const asteriskArg = functionExpressionCtx.ASTERISK()
@ -448,8 +443,7 @@ export function visitPrimaryExpression(ctx: PrimaryExpressionContext): ESQLAstIt
fn.args.push(...functionArgs);
}
return fn;
}
if (ctx instanceof InlineCastContext) {
} else if (ctx instanceof InlineCastContext) {
return collectInlineCast(ctx);
}
return createUnknownItem(ctx);

View file

@ -127,12 +127,14 @@ export const LeafPrinter = {
},
param: (node: ESQLParamLiteral) => {
const paramKind = node.paramKind || '?';
switch (node.paramType) {
case 'named':
case 'positional':
return '?' + node.value;
return paramKind + node.value;
default:
return '?';
return paramKind;
}
},

View file

@ -387,29 +387,52 @@ export interface ESQLStringLiteral extends ESQLAstBaseItem {
}
// @internal
export interface ESQLParamLiteral<ParamType extends string = string> extends ESQLAstBaseItem {
export interface ESQLParamLiteral<
ParamType extends string = string,
ParamKind extends ESQLParamKinds = ESQLParamKinds
> extends ESQLAstBaseItem {
type: 'literal';
literalType: 'param';
paramKind: ParamKind;
paramType: ParamType;
value: string | number;
}
export type ESQLParamKinds = '?' | '??';
/**
* *Unnamed* parameter is not named, just a question mark "?".
*
* @internal
*/
export type ESQLUnnamedParamLiteral = ESQLParamLiteral<'unnamed'>;
export type ESQLUnnamedParamLiteral<ParamKind extends ESQLParamKinds = ESQLParamKinds> =
ESQLParamLiteral<'unnamed', ParamKind>;
/**
* *Named* parameter is a question mark followed by a name "?name".
*
* @internal
*/
export interface ESQLNamedParamLiteral extends ESQLParamLiteral<'named'> {
export interface ESQLNamedParamLiteral<ParamKind extends ESQLParamKinds = ESQLParamKinds>
extends ESQLParamLiteral<'named', ParamKind> {
value: string;
}
/**
* *Positional* parameter is a question mark followed by a number "?1".
*
* @internal
*/
export interface ESQLPositionalParamLiteral<ParamKind extends ESQLParamKinds = ESQLParamKinds>
extends ESQLParamLiteral<'positional', ParamKind> {
value: number;
}
export type ESQLParam =
| ESQLUnnamedParamLiteral
| ESQLNamedParamLiteral
| ESQLPositionalParamLiteral;
export interface ESQLIdentifier extends ESQLAstBaseItem {
type: 'identifier';
}
@ -418,19 +441,6 @@ export const isESQLNamedParamLiteral = (node: ESQLAstItem): node is ESQLNamedPar
isESQLAstBaseItem(node) &&
(node as ESQLNamedParamLiteral).literalType === 'param' &&
(node as ESQLNamedParamLiteral).paramType === 'named';
/**
* *Positional* parameter is a question mark followed by a number "?1".
*
* @internal
*/
export interface ESQLPositionalParamLiteral extends ESQLParamLiteral<'positional'> {
value: number;
}
export type ESQLParam =
| ESQLUnnamedParamLiteral
| ESQLNamedParamLiteral
| ESQLPositionalParamLiteral;
export interface ESQLMessage {
type: 'error' | 'warning';