Skip to content

Commit d092949

Browse files
[ES|QL] Support subqueries in AST (#241227)
## Summary **Grammar** ``` fromCommand : FROM indexPatternOrSubquery (COMMA indexPatternOrSubquery)* ; ``` ``` indexPatternOrSubquery : indexPattern | subquery ; ``` ``` subquery : LP fromCommand (PIPE processingCommand)* RP ; ``` --------- Co-authored-by: Vadim Kibana <[email protected]>
1 parent 73c58b5 commit d092949

File tree

5 files changed

+211
-2
lines changed

5 files changed

+211
-2
lines changed

src/platform/packages/shared/kbn-esql-ast/src/ast/is.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,14 @@ export const isColumn = (node: unknown): node is types.ESQLColumn =>
9393
export const isSource = (node: unknown): node is types.ESQLSource =>
9494
isProperNode(node) && node.type === 'source';
9595

96+
export const isParens = (node: unknown): node is types.ESQLParens =>
97+
isProperNode(node) && node.type === 'parens';
98+
99+
export const isSubQuery = (
100+
node: unknown
101+
): node is types.ESQLParens & { child: types.ESQLAstQueryExpression } =>
102+
isParens(node) && isQuery(node.child);
103+
96104
export const isMap = (node: unknown): node is types.ESQLMap =>
97105
isProperNode(node) && node.type === 'map';
98106

src/platform/packages/shared/kbn-esql-ast/src/builder/builder.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import type {
1616
ESQLAstComment,
1717
ESQLAstCommentMultiLine,
1818
ESQLAstCommentSingleLine,
19+
ESQLAstExpression,
1920
ESQLAstQueryExpression,
2021
ESQLColumn,
2122
ESQLCommand,
@@ -28,6 +29,7 @@ import type {
2829
ESQLLocation,
2930
ESQLNamedParamLiteral,
3031
ESQLParam,
32+
ESQLParens,
3133
ESQLPositionalParamLiteral,
3234
ESQLOrderExpression,
3335
ESQLSource,
@@ -185,6 +187,18 @@ export namespace Builder {
185187
};
186188
}
187189

190+
export const parens = (
191+
child: ESQLAstExpression,
192+
fromParser?: Partial<AstNodeParserFields>
193+
): ESQLParens => {
194+
return {
195+
type: 'parens',
196+
name: '',
197+
child,
198+
...Builder.parserFields(fromParser),
199+
};
200+
};
201+
188202
export type ColumnTemplate = Omit<AstNodeTemplate<ESQLColumn>, 'name' | 'quoted' | 'parts'>;
189203

190204
export const column = (

src/platform/packages/shared/kbn-esql-ast/src/parser/__tests__/from.test.ts

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
*/
99

1010
import { parse } from '..';
11+
import type { ESQLAstQueryExpression, ESQLParens } from '../../types';
12+
import { isParens, isSubQuery } from '../../ast/is';
1113

1214
describe('FROM', () => {
1315
describe('correctly formatted', () => {
@@ -377,4 +379,125 @@ describe('FROM', () => {
377379
expect(errors.length > 0).toBe(true);
378380
});
379381
});
382+
383+
describe('subqueries', () => {
384+
it('can parse simple subquery', () => {
385+
const text = 'FROM index1, (FROM index2 | WHERE a > 10)';
386+
const { ast, errors } = parse(text);
387+
388+
expect(errors.length).toBe(0);
389+
expect(ast).toMatchObject([
390+
{
391+
type: 'command',
392+
name: 'from',
393+
args: [
394+
{
395+
type: 'source',
396+
name: 'index1',
397+
},
398+
{
399+
type: 'parens',
400+
child: {
401+
type: 'query',
402+
commands: [
403+
{ type: 'command', name: 'from' },
404+
{ type: 'command', name: 'where' },
405+
],
406+
},
407+
},
408+
],
409+
},
410+
]);
411+
});
412+
413+
it('can parse subquery as only source', () => {
414+
const text = 'FROM (FROM index1 | WHERE x > 0)';
415+
const { ast, errors } = parse(text);
416+
417+
expect(errors.length).toBe(0);
418+
expect(ast[0].args).toHaveLength(1);
419+
expect(ast[0].args[0]).toMatchObject({
420+
type: 'parens',
421+
child: {
422+
type: 'query',
423+
commands: [{ name: 'from' }, { name: 'where' }],
424+
},
425+
});
426+
});
427+
428+
it('can parse subquery followed by main query pipes', () => {
429+
const text = 'FROM (FROM index1 | WHERE a > 0) | WHERE b < 10 | LIMIT 5';
430+
const { ast, errors } = parse(text);
431+
432+
expect(errors.length).toBe(0);
433+
expect(ast).toHaveLength(3);
434+
expect(ast[0].name).toBe('from');
435+
expect(ast[1].name).toBe('where');
436+
expect(ast[2].name).toBe('limit');
437+
});
438+
439+
it('correctly captures location for deeply nested subqueries (3 levels)', () => {
440+
const text = 'FROM (FROM (FROM (FROM index | WHERE a > 0) | WHERE b < 10) | LIMIT 5)';
441+
const { ast } = parse(text);
442+
443+
// Level 1 - outermost
444+
const level1Parens = ast[0].args[0] as ESQLParens;
445+
expect(text.slice(level1Parens.location.min, level1Parens.location.max + 1)).toBe(
446+
'(FROM (FROM (FROM index | WHERE a > 0) | WHERE b < 10) | LIMIT 5)'
447+
);
448+
449+
// Level 2 - middle
450+
const level1Query = level1Parens.child as ESQLAstQueryExpression;
451+
const level2Parens = level1Query.commands[0].args[0] as ESQLParens;
452+
expect(text.slice(level2Parens.location.min, level2Parens.location.max + 1)).toBe(
453+
'(FROM (FROM index | WHERE a > 0) | WHERE b < 10)'
454+
);
455+
456+
// Level 3 - innermost
457+
const level2Query = level2Parens.child as ESQLAstQueryExpression;
458+
const level3Parens = level2Query.commands[0].args[0] as ESQLParens;
459+
expect(text.slice(level3Parens.location.min, level3Parens.location.max + 1)).toBe(
460+
'(FROM index | WHERE a > 0)'
461+
);
462+
});
463+
464+
describe('error cases', () => {
465+
it('errors on unclosed subquery and captures location up to end of content', () => {
466+
const text = 'FROM (FROM index | WHERE a > 10';
467+
const { ast, errors } = parse(text);
468+
469+
expect(errors.length).toBeGreaterThan(0);
470+
471+
const parens = ast[0].args[0] as ESQLParens;
472+
473+
expect(isParens(parens)).toBe(true);
474+
expect(isSubQuery(parens)).toBe(true);
475+
expect(parens.incomplete).toBe(true);
476+
477+
const query = parens.child as ESQLAstQueryExpression;
478+
479+
expect(query.incomplete).toBe(true);
480+
expect(query.commands).toHaveLength(2);
481+
482+
// Verify location captures everything including missing closing paren
483+
expect(text.slice(parens.location.min, parens.location.max + 1)).toBe(
484+
'(FROM index | WHERE a > 10'
485+
);
486+
});
487+
488+
it('errors on empty subquery', () => {
489+
const text = 'FROM index, ()';
490+
const { errors } = parse(text);
491+
492+
expect(errors.length).toBeGreaterThan(0);
493+
});
494+
495+
it('errors on subquery without FROM', () => {
496+
const text = 'FROM (WHERE a > 10)';
497+
const { errors } = parse(text);
498+
499+
expect(errors.length).toBeGreaterThan(0);
500+
});
501+
});
502+
});
380503
});

src/platform/packages/shared/kbn-esql-ast/src/parser/cst_to_ast_converter.ts

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -539,9 +539,17 @@ export class CstToAstConverter {
539539
const indexPatternOrSubqueryCtxs = indexPatternAndMetadataCtx.indexPatternOrSubquery_list();
540540
const sources = indexPatternOrSubqueryCtxs
541541
.map((indexPatternOrSubqueryCtx) => {
542-
// ToDo: handle subqueries when implemented
543542
const indexPatternCtx = indexPatternOrSubqueryCtx.indexPattern();
544-
return indexPatternCtx ? this.toSource(indexPatternCtx) : null;
543+
if (indexPatternCtx) {
544+
return this.toSource(indexPatternCtx);
545+
}
546+
547+
const subqueryCtx = indexPatternOrSubqueryCtx.subquery();
548+
if (subqueryCtx) {
549+
return this.fromSubquery(subqueryCtx);
550+
}
551+
552+
return null;
545553
})
546554
.filter((source): source is ast.ESQLSource => source !== null);
547555

@@ -559,6 +567,49 @@ export class CstToAstConverter {
559567
return command;
560568
}
561569

570+
private fromSubquery(ctx: cst.SubqueryContext): ast.ESQLParens {
571+
const fromCommandCtx = ctx.fromCommand();
572+
const processingCommandCtxs = ctx.processingCommand_list();
573+
const commands: ast.ESQLCommand[] = [];
574+
575+
if (fromCommandCtx) {
576+
const fromCommand = this.fromFromCommand(fromCommandCtx);
577+
578+
if (fromCommand) {
579+
commands.push(fromCommand);
580+
}
581+
}
582+
583+
for (const procCmdCtx of processingCommandCtxs) {
584+
const procCommand = this.fromProcessingCommand(procCmdCtx);
585+
586+
if (procCommand) {
587+
commands.push(procCommand);
588+
}
589+
}
590+
591+
const openParen = ctx.LP();
592+
const closeParen = ctx.RP();
593+
594+
// ANTLR inserts tokens with text like "<missing ')'>" when they're missing
595+
const closeParenText = closeParen?.getText() ?? '';
596+
const hasCloseParen = closeParen && !/<missing /.test(closeParenText);
597+
const incomplete = Boolean(ctx.exception) || !hasCloseParen;
598+
599+
const query = Builder.expression.query(commands, {
600+
...this.getParserFields(ctx),
601+
incomplete,
602+
});
603+
604+
return Builder.expression.parens(query, {
605+
incomplete: incomplete || query.incomplete,
606+
location: getPosition(
607+
openParen?.symbol ?? ctx.start,
608+
hasCloseParen ? closeParen.symbol : ctx.stop
609+
),
610+
});
611+
}
612+
562613
// ---------------------------------------------------------------------- ROW
563614

564615
private fromRowCommand(ctx: cst.RowCommandContext): ast.ESQLCommand<'row'> {

src/platform/packages/shared/kbn-esql-ast/src/types.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export type ESQLSingleAstItem =
3434
| ESQLFunction
3535
| ESQLCommandOption
3636
| ESQLSource
37+
| ESQLParens
3738
| ESQLColumn
3839
| ESQLDatePeriodLiteral
3940
| ESQLTimeDurationLiteral
@@ -372,6 +373,18 @@ export interface ESQLSource extends ESQLAstBaseItem {
372373
selector?: ESQLStringLiteral | undefined;
373374
}
374375

376+
/**
377+
* Represents any expression wrapped in parentheses.
378+
*
379+
* ```
380+
* FROM ( <query> )
381+
* ```
382+
*/
383+
export interface ESQLParens extends ESQLAstBaseItem {
384+
type: 'parens';
385+
child: ESQLAstExpression;
386+
}
387+
375388
export interface ESQLColumn extends ESQLAstBaseItem {
376389
type: 'column';
377390

0 commit comments

Comments
 (0)