[ES|QL] validation and autocomplete for FORK (#216743)

## Summary

Part of https://github.com/elastic/kibana/issues/210339

Adds
- AST support
- Validation (mostly delegates validation to the subcommands)
- Autocomplete


https://github.com/user-attachments/assets/9fed4401-adf9-48b7-a43f-096e07054966

Also, reworked the `WHERE` replacement range logic, cleaning things up
and fixing small things to make it work within `FORK`.

### Formatting support

https://github.com/user-attachments/assets/3cf5960f-0daf-4339-ad8b-58b30ce86975

### Constraints

- Only one `FORK` command per query

<img width="847" alt="Screenshot 2025-04-04 at 10 43 23 AM"
src="https://github.com/user-attachments/assets/a3b3b5dc-4c86-498b-934c-68d3461f4a89"
/>

- At least two branches per FORK command

<img width="737" alt="Screenshot 2025-04-04 at 10 51 35 AM"
src="https://github.com/user-attachments/assets/6bf921aa-7167-4791-a29a-66624d2cb75a"
/>

- Only supports `WHERE`, `SORT`, and `LIMIT` (currently)

<img width="816" alt="Screenshot 2025-04-04 at 10 52 39 AM"
src="https://github.com/user-attachments/assets/0fa286c9-9676-471c-93a4-b01ae42d0c6f"
/>

### Checklist

- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-i18n/README.md)
- [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

---------

Co-authored-by: Stratoula Kalafateli <efstratia.kalafateli@elastic.co>
This commit is contained in:
Drew Tate 2025-04-09 08:39:09 -06:00 committed by GitHub
parent 1b10f35b3d
commit 7e35e92b4b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 1204 additions and 406 deletions

View file

@ -0,0 +1,76 @@
/*
* 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('FORK', () => {
describe('correctly formatted', () => {
it('can parse single-command FORK query', () => {
const text = `FROM kibana_ecommerce_data
| FORK
(WHERE bytes > 1)
(SORT bytes ASC)
(LIMIT 100)`;
const { ast } = parse(text);
expect(ast[1].args).toHaveLength(3);
expect(ast[1].args).toMatchObject([
{ type: 'query', commands: [{ name: 'where' }] },
{ type: 'query', commands: [{ name: 'sort' }] },
{ type: 'query', commands: [{ name: 'limit' }] },
]);
});
it('can parse composite-command FORK query', () => {
const text = `FROM kibana_ecommerce_data
| FORK
(WHERE bytes > 1 | SORT bytes ASC | LIMIT 1)
(WHERE extension.keyword == "txt" | LIMIT 100)`;
const { ast } = parse(text);
expect(ast[1].args).toHaveLength(2);
expect(ast[1].args).toMatchObject([
{ type: 'query', commands: [{ name: 'where' }, { name: 'sort' }, { name: 'limit' }] },
{ type: 'query', commands: [{ name: 'where' }, { name: 'limit' }] },
]);
});
});
describe('when incorrectly formatted, returns errors', () => {
it('when no pipe', () => {
const text = `FROM kibana_ecommerce_data
| FORK
(WHERE bytes > 1 LIMIT 1)`;
const { errors } = parse(text);
expect(errors.length > 0).toBe(true);
});
it('when bad parens', () => {
const text = `FROM kibana_ecommerce_data
| FORK
WHERE bytes > 1)`;
const { errors } = parse(text);
expect(errors.length > 0).toBe(true);
});
it('when unsupported command', () => {
const text = `FROM kibana_ecommerce_data
| FORK
(EVAL bytes > 1)`;
const { errors } = parse(text);
expect(errors.length > 0).toBe(true);
});
});
});

View file

@ -9,63 +9,65 @@
import type { ErrorNode, ParserRuleContext, TerminalNode } from 'antlr4';
import {
type ShowInfoContext,
type SingleStatementContext,
type RowCommandContext,
type FromCommandContext,
type EvalCommandContext,
type StatsCommandContext,
type LimitCommandContext,
type SortCommandContext,
type KeepCommandContext,
type DropCommandContext,
type RenameCommandContext,
type DissectCommandContext,
type GrokCommandContext,
type MvExpandCommandContext,
type ShowCommandContext,
type EnrichCommandContext,
type WhereCommandContext,
default as esql_parser,
type TimeSeriesCommandContext,
IndexPatternContext,
InlinestatsCommandContext,
JoinCommandContext,
type ChangePointCommandContext,
type DissectCommandContext,
type DropCommandContext,
type EnrichCommandContext,
type EvalCommandContext,
type ForkCommandContext,
type FromCommandContext,
type GrokCommandContext,
type KeepCommandContext,
type LimitCommandContext,
type MvExpandCommandContext,
type RenameCommandContext,
type RowCommandContext,
type ShowCommandContext,
type ShowInfoContext,
type SingleStatementContext,
type SortCommandContext,
type StatsCommandContext,
type TimeSeriesCommandContext,
type WhereCommandContext,
} from '../antlr/esql_parser';
import { default as ESQLParserListener } from '../antlr/esql_parser_listener';
import type { ESQLAst, ESQLAstTimeseriesCommand } from '../types';
import {
createAstBaseItem,
createCommand,
createFunction,
createLiteral,
textExistsAndIsValid,
visitSource,
createAstBaseItem,
} from './factories';
import { createChangePointCommand } from './factories/change_point';
import { createDissectCommand } from './factories/dissect';
import { createForkCommand } from './factories/fork';
import { createFromCommand } from './factories/from';
import { createGrokCommand } from './factories/grok';
import { createJoinCommand } from './factories/join';
import { createLimitCommand } from './factories/limit';
import { createRowCommand } from './factories/row';
import { createSortCommand } from './factories/sort';
import { createStatsCommand } from './factories/stats';
import { createWhereCommand } from './factories/where';
import { getPosition } from './helpers';
import {
collectAllFields,
collectAllAggFields,
visitByOption,
collectAllColumnIdentifiers,
visitRenameClauses,
visitOrderExpressions,
getPolicyName,
getMatchField,
collectAllFields,
getEnrichClauses,
getMatchField,
getPolicyName,
visitByOption,
visitRenameClauses,
} from './walkers';
import type { ESQLAst, ESQLAstTimeseriesCommand } from '../types';
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';
import { createWhereCommand } from './factories/where';
import { createRowCommand } from './factories/row';
import { createFromCommand } from './factories/from';
export class ESQLAstBuilderListener implements ESQLParserListener {
private ast: ESQLAst = [];
private inFork: boolean = false;
constructor(public src: string) {}
@ -102,6 +104,10 @@ export class ESQLAstBuilderListener implements ESQLParserListener {
* @param ctx the parse tree
*/
exitWhereCommand(ctx: WhereCommandContext) {
if (this.inFork) {
return;
}
const command = createWhereCommand(ctx);
this.ast.push(command);
@ -187,14 +193,13 @@ export class ESQLAstBuilderListener implements ESQLParserListener {
* @param ctx the parse tree
*/
exitLimitCommand(ctx: LimitCommandContext) {
const command = createCommand('limit', ctx);
this.ast.push(command);
if (ctx.getToken(esql_parser.INTEGER_LITERAL, 0)) {
const literal = createLiteral('integer', ctx.INTEGER_LITERAL());
if (literal) {
command.args.push(literal);
}
if (this.inFork) {
return;
}
const command = createLimitCommand(ctx);
this.ast.push(command);
}
/**
@ -202,9 +207,13 @@ export class ESQLAstBuilderListener implements ESQLParserListener {
* @param ctx the parse tree
*/
exitSortCommand(ctx: SortCommandContext) {
const command = createCommand('sort', ctx);
if (this.inFork) {
return;
}
const command = createSortCommand(ctx);
this.ast.push(command);
command.args.push(...visitOrderExpressions(ctx.orderExpression_list()));
}
/**
@ -303,6 +312,22 @@ export class ESQLAstBuilderListener implements ESQLParserListener {
this.ast.push(command);
}
enterForkCommand() {
this.inFork = true;
}
/**
* NOTE every new command supported in fork needs to be added
* to createForkCommand!
*/
exitForkCommand(ctx: ForkCommandContext): void {
const command = createForkCommand(ctx);
this.ast.push(command);
this.inFork = false;
}
/**
* Exit a parse tree produced by `esql_parser.changePointCommand`.
*

View file

@ -12,62 +12,61 @@
*/
import type {
Token,
ParserRuleContext,
TerminalNode,
RecognitionException,
ParseTree,
ParserRuleContext,
RecognitionException,
TerminalNode,
Token,
} from 'antlr4';
import {
FunctionContext,
IdentifierContext,
IdentifierOrParameterContext,
IndexPatternContext,
InputDoubleParamsContext,
InputNamedOrPositionalDoubleParamsContext,
InputNamedOrPositionalParamContext,
InputParamContext,
QualifiedNameContext,
QualifiedNamePatternContext,
SelectorStringContext,
StringContext,
type ArithmeticUnaryContext,
type DecimalValueContext,
type InlineCastContext,
type IntegerValueContext,
type QualifiedIntegerLiteralContext,
QualifiedNamePatternContext,
FunctionContext,
IdentifierContext,
InputParamContext,
InputNamedOrPositionalParamContext,
IdentifierOrParameterContext,
StringContext,
InputNamedOrPositionalDoubleParamsContext,
InputDoubleParamsContext,
SelectorStringContext,
} from '../antlr/esql_parser';
import { DOUBLE_TICKS_REGEX, SINGLE_BACKTICK, TICKS_REGEX } from './constants';
import type {
ESQLAstBaseItem,
ESQLLiteral,
ESQLList,
ESQLTimeInterval,
ESQLLocation,
ESQLFunction,
ESQLSource,
ESQLColumn,
ESQLCommandOption,
ESQLAstItem,
ESQLCommandMode,
ESQLInlineCast,
ESQLUnknownItem,
ESQLNumericLiteralType,
FunctionSubtype,
ESQLNumericLiteral,
ESQLOrderExpression,
InlineCastingType,
ESQLFunctionCallExpression,
ESQLIdentifier,
ESQLBinaryExpression,
BinaryExpressionOperator,
ESQLCommand,
ESQLParamKinds,
ESQLStringLiteral,
} from '../types';
import { parseIdentifier, getPosition } from './helpers';
import { Builder, type AstNodeParserFields } from '../builder';
import { LeafPrinter } from '../pretty_print';
import type {
BinaryExpressionOperator,
ESQLAstBaseItem,
ESQLAstItem,
ESQLBinaryExpression,
ESQLColumn,
ESQLCommand,
ESQLCommandMode,
ESQLCommandOption,
ESQLFunction,
ESQLFunctionCallExpression,
ESQLIdentifier,
ESQLInlineCast,
ESQLList,
ESQLLiteral,
ESQLLocation,
ESQLNumericLiteral,
ESQLNumericLiteralType,
ESQLParamKinds,
ESQLSource,
ESQLStringLiteral,
ESQLTimeInterval,
ESQLUnknownItem,
FunctionSubtype,
InlineCastingType,
} from '../types';
import { DOUBLE_TICKS_REGEX, SINGLE_BACKTICK, TICKS_REGEX } from './constants';
import { getPosition, parseIdentifier } from './helpers';
export function nonNullable<T>(v: T): v is NonNullable<T> {
return v != null;
@ -85,7 +84,7 @@ export function createAstBaseItem<Name = string>(
};
}
const createParserFields = (ctx: ParserRuleContext): AstNodeParserFields => ({
export const createParserFields = (ctx: ParserRuleContext): AstNodeParserFields => ({
text: ctx.getText(),
location: getPosition(ctx.start, ctx.stop),
incomplete: Boolean(ctx.exception),
@ -354,21 +353,6 @@ export const createParam = (ctx: ParseTree) => {
}
};
export const createOrderExpression = (
ctx: ParserRuleContext,
arg: ESQLColumn,
order: ESQLOrderExpression['order'],
nulls: ESQLOrderExpression['nulls']
) => {
const node = Builder.expression.order(
arg as ESQLColumn,
{ order, nulls },
createParserFields(ctx)
);
return node;
};
function walkFunctionStructure(
args: ESQLAstItem[],
initialLocation: ESQLLocation,

View file

@ -0,0 +1,80 @@
/*
* 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 {
CompositeForkSubQueryContext,
ForkCommandContext,
ForkSubQueryCommandContext,
ForkSubQueryProcessingCommandContext,
SingleForkSubQueryCommandContext,
} from '../../antlr/esql_parser';
import { Builder } from '../../builder';
import { ESQLCommand } from '../../types';
import { createCommand, createParserFields } from '../factories';
import { createLimitCommand } from './limit';
import { createSortCommand } from './sort';
import { createWhereCommand } from './where';
export const createForkCommand = (ctx: ForkCommandContext): ESQLCommand<'fork'> => {
const command = createCommand<'fork'>('fork', ctx);
const subQueryContexts = ctx.forkSubQueries().forkSubQuery_list();
for (const subCtx of subQueryContexts) {
const subCommands = visitForkSubQueryContext(subCtx.forkSubQueryCommand());
const branch = Builder.expression.query(subCommands, createParserFields(subCtx));
command.args.push(branch);
}
return command;
};
function visitForkSubQueryContext(ctx: ForkSubQueryCommandContext) {
const commands = [];
let nextCtx: ForkSubQueryCommandContext = ctx;
while (nextCtx instanceof CompositeForkSubQueryContext) {
const command = visitForkSubQueryProcessingCommandContext(
nextCtx.forkSubQueryProcessingCommand()
);
if (command) {
commands.unshift(command);
}
nextCtx = nextCtx.forkSubQueryCommand();
}
if (nextCtx instanceof SingleForkSubQueryCommandContext) {
const command = visitForkSubQueryProcessingCommandContext(
nextCtx.forkSubQueryProcessingCommand()
);
if (command) {
commands.unshift(command);
}
}
return commands;
}
function visitForkSubQueryProcessingCommandContext(ctx: ForkSubQueryProcessingCommandContext) {
const whereCtx = ctx.whereCommand();
if (whereCtx) {
return createWhereCommand(whereCtx);
}
const sortCtx = ctx.sortCommand();
if (sortCtx) {
return createSortCommand(sortCtx);
}
const limitCtx = ctx.limitCommand();
if (limitCtx) {
return createLimitCommand(limitCtx);
}
}

View file

@ -0,0 +1,23 @@
/*
* 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 esql_parser, { LimitCommandContext } from '../../antlr/esql_parser';
import { createCommand, createLiteral } from '../factories';
export const createLimitCommand = (ctx: LimitCommandContext) => {
const command = createCommand('limit', ctx);
if (ctx.getToken(esql_parser.INTEGER_LITERAL, 0)) {
const literal = createLiteral('integer', ctx.INTEGER_LITERAL());
if (literal) {
command.args.push(literal);
}
}
return command;
};

View file

@ -0,0 +1,76 @@
/*
* 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 { ParserRuleContext } from 'antlr4';
import { OrderExpressionContext, SortCommandContext } from '../../antlr/esql_parser';
import { Builder } from '../../builder';
import { ESQLAstItem, ESQLColumn, ESQLOrderExpression } from '../../types';
import { createCommand, createParserFields } from '../factories';
import { collectBooleanExpression } from '../walkers';
export const createSortCommand = (ctx: SortCommandContext) => {
const command = createCommand('sort', ctx);
command.args.push(...visitOrderExpressions(ctx.orderExpression_list()));
return command;
};
export function visitOrderExpressions(
ctx: OrderExpressionContext[]
): Array<ESQLOrderExpression | ESQLAstItem> {
const ast: Array<ESQLOrderExpression | ESQLAstItem> = [];
for (const orderCtx of ctx) {
ast.push(visitOrderExpression(orderCtx));
}
return ast;
}
const visitOrderExpression = (ctx: OrderExpressionContext): ESQLOrderExpression | ESQLAstItem => {
const arg = collectBooleanExpression(ctx.booleanExpression())[0];
let order: ESQLOrderExpression['order'] = '';
let nulls: ESQLOrderExpression['nulls'] = '';
const ordering = ctx._ordering?.text?.toUpperCase();
if (ordering) order = ordering as ESQLOrderExpression['order'];
const nullOrdering = ctx._nullOrdering?.text?.toUpperCase();
switch (nullOrdering) {
case 'LAST':
nulls = 'NULLS LAST';
break;
case 'FIRST':
nulls = 'NULLS FIRST';
break;
}
if (!order && !nulls) {
return arg;
}
return createOrderExpression(ctx, arg as ESQLColumn, order, nulls);
};
const createOrderExpression = (
ctx: ParserRuleContext,
arg: ESQLColumn,
order: ESQLOrderExpression['order'],
nulls: ESQLOrderExpression['nulls']
) => {
const node = Builder.expression.order(
arg as ESQLColumn,
{ order, nulls },
createParserFields(ctx)
);
return node;
};

View file

@ -9,102 +9,99 @@
import { ParserRuleContext, TerminalNode } from 'antlr4';
import {
default as esql_parser,
ArithmeticBinaryContext,
ArithmeticUnaryContext,
BooleanArrayLiteralContext,
BooleanDefaultContext,
type BooleanExpressionContext,
BooleanLiteralContext,
InputParameterContext,
BooleanValueContext,
ComparisonContext,
type ComparisonOperatorContext,
type ConstantContext,
ConstantDefaultContext,
DecimalLiteralContext,
DereferenceContext,
type DropCommandContext,
type EnrichCommandContext,
type FieldContext,
type FieldsContext,
type AggFieldsContext,
EntryExpressionContext,
FunctionContext,
InlineCastContext,
InlinestatsCommandContext,
InputParameterContext,
IntegerLiteralContext,
IsNullContext,
type KeepCommandContext,
LogicalBinaryContext,
LogicalInContext,
LogicalNotContext,
MapExpressionContext,
MatchBooleanExpressionContext,
MatchExpressionContext,
MetadataContext,
MvExpandCommandContext,
NullLiteralContext,
NumericArrayLiteralContext,
NumericValueContext,
type OperatorExpressionContext,
OperatorExpressionDefaultContext,
type OrderExpressionContext,
ParenthesizedExpressionContext,
type PrimaryExpressionContext,
QualifiedIntegerLiteralContext,
RegexBooleanExpressionContext,
type RenameClauseContext,
type StatsCommandContext,
StringArrayLiteralContext,
StringContext,
StringLiteralContext,
type ValueExpressionContext,
ValueExpressionDefaultContext,
InlineCastContext,
InlinestatsCommandContext,
MatchExpressionContext,
MatchBooleanExpressionContext,
MapExpressionContext,
EntryExpressionContext,
default as esql_parser,
type AggFieldsContext,
type BooleanExpressionContext,
type ComparisonOperatorContext,
type ConstantContext,
type DropCommandContext,
type EnrichCommandContext,
type FieldContext,
type FieldsContext,
type KeepCommandContext,
type OperatorExpressionContext,
type PrimaryExpressionContext,
type RenameClauseContext,
type StatsCommandContext,
type ValueExpressionContext,
} from '../antlr/esql_parser';
import {
createColumn,
createOption,
nonNullable,
createFunction,
createLiteral,
createTimeUnit,
createFakeMultiplyLiteral,
createList,
createNumericLiteral,
computeLocationExtends,
createBinaryExpression,
createColumn,
createColumnStar,
wrapIdentifierAsArray,
createFakeMultiplyLiteral,
createFunction,
createFunctionCall,
createInlineCast,
createList,
createLiteral,
createLiteralString,
createNumericLiteral,
createOption,
createParam,
createPolicy,
createSetting,
textExistsAndIsValid,
createInlineCast,
createTimeUnit,
createUnknownItem,
createOrderExpression,
createFunctionCall,
createParam,
createLiteralString,
createBinaryExpression,
nonNullable,
textExistsAndIsValid,
wrapIdentifierAsArray,
} from './factories';
import { Builder } from '../builder';
import {
ESQLLiteral,
ESQLColumn,
ESQLFunction,
ESQLCommandOption,
ESQLAstItem,
ESQLAstExpression,
ESQLAstField,
ESQLInlineCast,
ESQLOrderExpression,
ESQLAstItem,
ESQLBinaryExpression,
InlineCastingType,
ESQLColumn,
ESQLCommandOption,
ESQLFunction,
ESQLInlineCast,
ESQLLiteral,
ESQLMap,
ESQLMapEntry,
ESQLStringLiteral,
ESQLAstExpression,
InlineCastingType,
} from '../types';
import { firstItem, lastItem } from '../visitor/utils';
import { Builder } from '../builder';
import { getPosition } from './helpers';
function terminalNodeToParserRuleContext(node: TerminalNode): ParserRuleContext {
@ -667,43 +664,3 @@ export function visitByOption(
if (lastArg) option.location.max = lastArg.location.max;
return [option];
}
const visitOrderExpression = (ctx: OrderExpressionContext): ESQLOrderExpression | ESQLAstItem => {
const arg = collectBooleanExpression(ctx.booleanExpression())[0];
let order: ESQLOrderExpression['order'] = '';
let nulls: ESQLOrderExpression['nulls'] = '';
const ordering = ctx._ordering?.text?.toUpperCase();
if (ordering) order = ordering as ESQLOrderExpression['order'];
const nullOrdering = ctx._nullOrdering?.text?.toUpperCase();
switch (nullOrdering) {
case 'LAST':
nulls = 'NULLS LAST';
break;
case 'FIRST':
nulls = 'NULLS FIRST';
break;
}
if (!order && !nulls) {
return arg;
}
return createOrderExpression(ctx, arg as ESQLColumn, order, nulls);
};
export function visitOrderExpressions(
ctx: OrderExpressionContext[]
): Array<ESQLOrderExpression | ESQLAstItem> {
const ast: Array<ESQLOrderExpression | ESQLAstItem> = [];
for (const orderCtx of ctx) {
ast.push(visitOrderExpression(orderCtx));
}
return ast;
}

View file

@ -179,6 +179,30 @@ describe('single line query', () => {
);
});
});
describe('FORK', () => {
test('from single line', () => {
const { text } =
reprint(`FROM index | FORK (WHERE keywordField != "" | LIMIT 100) (SORT doubleField ASC NULLS LAST)
`);
expect(text).toBe(
'FROM index | FORK (WHERE keywordField != "" | LIMIT 100) (SORT doubleField ASC NULLS LAST)'
);
});
test('from multiline', () => {
const { text } = reprint(`FROM index
| FORK
(WHERE keywordField != "" | LIMIT 100)
(SORT doubleField ASC NULLS LAST)
`);
expect(text).toBe(
'FROM index | FORK (WHERE keywordField != "" | LIMIT 100) (SORT doubleField ASC NULLS LAST)'
);
});
});
});
describe('expressions', () => {
@ -628,6 +652,17 @@ describe('multiline query', () => {
expect(text).toBe(query);
});
test('keeps FORK branches on single lines', () => {
const { text } = multiline(
`FROM index| FORK (WHERE keywordField != "" | LIMIT 100)(SORT doubleField ASC NULLS LAST)`
);
expect(text).toBe(`FROM index
| FORK
(WHERE keywordField != "" | LIMIT 100)
(SORT doubleField ASC NULLS LAST)`);
});
});
describe('single line command', () => {

View file

@ -400,6 +400,27 @@ export class BasicPrettyPrinter {
? ''
: (opts.lowercaseCommands ? node.commandType : node.commandType.toUpperCase()) + ' ';
if (cmd === 'FORK') {
const branches = node.args
.map((branch) => {
if (Array.isArray(branch) || branch.type !== 'query') {
return undefined;
}
return ctx.visitSubQuery(branch);
})
.filter(Boolean) as string[];
const spaces = (n: number) => ' '.repeat(n);
const branchSeparator = opts.multiline ? `)\n${spaces(4)}(` : `) (`;
return this.decorateWithComments(
ctx.node,
`FORK${opts.multiline ? `\n${spaces(4)}` : ' '}(${branches.join(branchSeparator)})`
);
}
let args = '';
let options = '';
@ -423,7 +444,16 @@ export class BasicPrettyPrinter {
.on('visitQuery', (ctx) => {
const opts = this.opts;
const cmdSeparator = opts.multiline ? `\n${opts.pipeTab ?? ' '}| ` : ' | ';
let parentNode;
if (ctx.parent?.node && !Array.isArray(ctx.parent.node)) {
parentNode = ctx.parent.node;
}
const useMultiLine =
opts.multiline && !Array.isArray(parentNode) && parentNode?.name !== 'fork';
const cmdSeparator = useMultiLine ? `\n${opts.pipeTab ?? ' '}| ` : ' | ';
let text = '';
for (const cmd of ctx.visitCommands()) {

View file

@ -16,9 +16,10 @@ export type ESQLAstNode = ESQLAstCommand | ESQLAstExpression | ESQLAstItem;
/**
* Represents an *expression* in the AST.
*/
export type ESQLAstExpression = ESQLSingleAstItem | ESQLAstQueryExpression;
export type ESQLAstExpression = ESQLSingleAstItem;
export type ESQLSingleAstItem =
| ESQLAstQueryExpression
| ESQLFunction
| ESQLCommandOption
| ESQLSource

View file

@ -8,6 +8,7 @@
*/
import { parse } from '../../parser';
import { ESQLAstQueryExpression } from '../../types';
import { CommandVisitorContext, WhereCommandVisitorContext } from '../contexts';
import { Visitor } from '../visitor';
@ -49,6 +50,28 @@ test('can pass inputs to visitors', () => {
expect(res).toEqual(['pfx:from', 'pfx:limit']);
});
test('a query can have a parent fork command', () => {
const { ast } = parse('FROM index | FORK (WHERE 1) (WHERE 2)');
let parentCount = 0;
new Visitor()
.on('visitCommand', (ctx) => {
if (ctx.node.name === 'fork') {
ctx.node.args.forEach((subQuery) => ctx.visitSubQuery(subQuery as ESQLAstQueryExpression));
}
})
.on('visitQuery', (ctx) => {
if (ctx.parent) parentCount++;
for (const _cmdResult of ctx.visitCommands()) {
// nothing
}
})
.visitQuery(ast);
expect(parentCount).toBe(2);
});
test('can specify specific visitors for commands', () => {
const { ast } = parse('FROM index | SORT asfd | WHERE 1 | ENRICH adsf | LIMIT 123');
const res = new Visitor()

View file

@ -19,6 +19,7 @@ import type {
ESQLAstExpression,
ESQLAstItem,
ESQLAstJoinCommand,
ESQLAstQueryExpression,
ESQLAstRenameExpression,
ESQLColumn,
ESQLCommandOption,
@ -191,6 +192,11 @@ export class CommandVisitorContext<
return this.node.name.toUpperCase();
}
public visitSubQuery(queryNode: ESQLAstQueryExpression) {
this.ctx.assertMethodExists('visitQuery');
return this.ctx.visitQuery(this, queryNode, undefined as any);
}
public *options(): Iterable<ESQLCommandOption> {
for (const arg of this.node.args) {
if (!arg || Array.isArray(arg)) {
@ -484,6 +490,12 @@ export class ChangePointCommandVisitorContext<
Data extends SharedData = SharedData
> extends CommandVisitorContext<Methods, Data, ESQLAstChangePointCommand> {}
// FORK (COMMAND ... [| COMMAND ...]) [(COMMAND ... [| COMMAND ...])]
export class ForkCommandVisitorContext<
Methods extends VisitorMethods = VisitorMethods,
Data extends SharedData = SharedData
> extends CommandVisitorContext<Methods, Data, ESQLAstCommand> {}
// Expressions -----------------------------------------------------------------
export class ExpressionVisitorContext<

View file

@ -12,6 +12,7 @@ import type {
ESQLAstChangePointCommand,
ESQLAstCommand,
ESQLAstJoinCommand,
ESQLAstQueryExpression,
ESQLAstRenameExpression,
ESQLColumn,
ESQLFunction,
@ -66,7 +67,7 @@ export class GlobalVisitorContext<
return this.methods[method]!(context as any, input);
}
// Command visiting ----------------------------------------------------------
// #region Command visiting ----------------------------------------------------------
public visitCommandGeneric(
parent: contexts.VisitorContext | null,
@ -180,6 +181,10 @@ export class GlobalVisitorContext<
input as any
);
}
case 'fork': {
if (!this.methods.visitForkCommand) break;
return this.visitForkCommand(parent, commandNode, input as any);
}
}
return this.visitCommandGeneric(parent, commandNode, input as any);
}
@ -382,7 +387,18 @@ export class GlobalVisitorContext<
return this.visitWithSpecificContext('visitChangePointCommand', context, input);
}
// Expression visiting -------------------------------------------------------
public visitForkCommand(
parent: contexts.VisitorContext | null,
node: ESQLAstCommand,
input: types.VisitorInput<Methods, 'visitForkCommand'>
): types.VisitorOutput<Methods, 'visitForkCommand'> {
const context = new contexts.ForkCommandVisitorContext(this, node, parent);
return this.visitWithSpecificContext('visitForkCommand', context, input);
}
// #endregion
// #region Expression visiting -------------------------------------------------------
public visitExpressionGeneric(
parent: contexts.VisitorContext | null,
@ -454,10 +470,23 @@ export class GlobalVisitorContext<
}
}
}
case 'query': {
if (!this.methods.visitQuery || expressionNode.type !== 'query') break;
return this.visitQuery(parent, expressionNode, input as any);
}
}
return this.visitExpressionGeneric(parent, expressionNode, input as any);
}
public visitQuery(
parent: contexts.VisitorContext | null,
node: ESQLAstQueryExpression,
input: types.VisitorInput<Methods, 'visitQuery'>
): types.ExpressionVisitorOutput<Methods> {
const context = new contexts.QueryVisitorContext(this, node, parent);
return this.visitWithSpecificContext('visitQuery', context, input);
}
public visitColumnExpression(
parent: contexts.VisitorContext | null,
node: ESQLColumn,
@ -548,3 +577,5 @@ export class GlobalVisitorContext<
return this.visitWithSpecificContext('visitIdentifierExpression', context, input);
}
}
// #endregion

View file

@ -53,6 +53,7 @@ export type VisitorOutput<
*/
export type ExpressionVisitorInput<Methods extends VisitorMethods> = AnyToVoid<
| VisitorInput<Methods, 'visitExpression'> &
VisitorInput<Methods, 'visitQuery'> &
VisitorInput<Methods, 'visitColumnExpression'> &
VisitorInput<Methods, 'visitSourceExpression'> &
VisitorInput<Methods, 'visitFunctionCallExpression'> &
@ -70,6 +71,7 @@ export type ExpressionVisitorInput<Methods extends VisitorMethods> = AnyToVoid<
*/
export type ExpressionVisitorOutput<Methods extends VisitorMethods> =
| VisitorOutput<Methods, 'visitExpression'>
| VisitorOutput<Methods, 'visitQuery'>
| VisitorOutput<Methods, 'visitColumnExpression'>
| VisitorOutput<Methods, 'visitSourceExpression'>
| VisitorOutput<Methods, 'visitFunctionCallExpression'>
@ -173,7 +175,12 @@ export interface VisitorMethods<
visitEnrichCommand?: Visitor<contexts.EnrichCommandVisitorContext<Visitors, Data>, any, any>;
visitMvExpandCommand?: Visitor<contexts.MvExpandCommandVisitorContext<Visitors, Data>, any, any>;
visitJoinCommand?: Visitor<contexts.JoinCommandVisitorContext<Visitors, Data>, any, any>;
visitChangePointCommand?: Visitor<contexts.JoinCommandVisitorContext<Visitors, Data>, any, any>;
visitChangePointCommand?: Visitor<
contexts.ChangePointCommandVisitorContext<Visitors, Data>,
any,
any
>;
visitForkCommand?: Visitor<contexts.ForkCommandVisitorContext<Visitors, Data>, any, any>;
visitCommandOption?: Visitor<contexts.CommandOptionVisitorContext<Visitors, Data>, any, any>;
visitExpression?: Visitor<contexts.ExpressionVisitorContext<Visitors, Data>, any, any>;
visitSourceExpression?: Visitor<

View file

@ -0,0 +1,108 @@
/*
* 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 { EXPECTED_FIELD_AND_FUNCTION_SUGGESTIONS } from './autocomplete.command.sort.test';
import {
EMPTY_WHERE_SUGGESTIONS,
EXPECTED_COMPARISON_WITH_TEXT_FIELD_SUGGESTIONS,
} from './autocomplete.command.where.test';
import { AssertSuggestionsFn, SuggestFn, setup } from './helpers';
describe('autocomplete.suggest', () => {
describe('FORK (COMMAND ... [| COMMAND ...]) [(COMMAND ... [| COMMAND ...])]', () => {
let assertSuggestions: AssertSuggestionsFn;
let suggest: SuggestFn;
beforeEach(async function () {
const result = await setup();
assertSuggestions = result.assertSuggestions;
suggest = result.suggest;
});
describe('FORK ...', () => {
test('suggests new branch on empty command', async () => {
await assertSuggestions('FROM a | FORK /', [{ text: '($0)', asSnippet: true }]);
await assertSuggestions('FROM a | fork /', [{ text: '($0)', asSnippet: true }]);
});
test('suggests pipe and new branch after complete branch', async () => {
await assertSuggestions('FROM a | FORK (LIMIT 100) /', [{ text: '($0)', asSnippet: true }]);
await assertSuggestions('FROM a | FORK (LIMIT 100) (SORT keywordField ASC) /', [
{ text: '($0)', asSnippet: true },
'| ',
]);
});
describe('(COMMAND ... | COMMAND ...)', () => {
const FORK_SUBCOMMANDS = ['WHERE ', 'SORT ', 'LIMIT '];
it('suggests FORK sub commands in an open branch', async () => {
await assertSuggestions('FROM a | FORK (/)', FORK_SUBCOMMANDS);
await assertSuggestions('FROM a | FORK (WHERE 1) (/)', FORK_SUBCOMMANDS);
});
describe('delegation to subcommands', () => {
test('where', async () => {
await assertSuggestions('FROM a | FORK (WHERE /)', EMPTY_WHERE_SUGGESTIONS);
await assertSuggestions('FROM a | FORK (WHERE key/)', EMPTY_WHERE_SUGGESTIONS);
await assertSuggestions(
'FROM a | FORK (WHERE textField != /)',
EXPECTED_COMPARISON_WITH_TEXT_FIELD_SUGGESTIONS
);
await assertSuggestions(
'FROM a | FORK (WHERE textField != text/)',
EXPECTED_COMPARISON_WITH_TEXT_FIELD_SUGGESTIONS
);
});
test('limit', async () => {
await assertSuggestions('FROM a | FORK (LIMIT /)', ['10 ', '100 ', '1000 ']);
});
test('sort', async () => {
await assertSuggestions(
'FROM a | FORK (SORT /)',
EXPECTED_FIELD_AND_FUNCTION_SUGGESTIONS
);
await assertSuggestions('FROM a | FORK (SORT integerField /)', [
'ASC',
'DESC',
', ',
'| ',
'NULLS FIRST',
'NULLS LAST',
]);
});
});
it('suggests pipe after complete subcommands', async () => {
const assertSuggestsPipe = async (query: string) => {
const suggestions = await suggest(query);
expect(suggestions.map(({ text }) => text)).toContain('| ');
};
await assertSuggestsPipe('FROM a | FORK (WHERE keywordField IS NOT NULL /)');
await assertSuggestsPipe('FROM a | FORK (LIMIT 1234 /)');
await assertSuggestsPipe('FROM a | FORK (SORT keywordField ASC /)');
});
it('suggests FORK subcommands after in-branch pipe', async () => {
await assertSuggestions('FROM a | FORK (LIMIT 1234 | /)', FORK_SUBCOMMANDS);
await assertSuggestions(
'FROM a | FORK (WHERE keywordField IS NULL | LIMIT 1234 | /)',
FORK_SUBCOMMANDS
);
await assertSuggestions(
'FROM a | FORK (SORT longField ASC NULLS LAST) (WHERE keywordField IS NULL | LIMIT 1234 | /)',
FORK_SUBCOMMANDS
);
});
});
});
});
});

View file

@ -15,21 +15,23 @@ import {
getFunctionSignaturesByReturnType,
} from './helpers';
const expectedFieldSuggestions = getFieldNamesByType('any').map(attachTriggerCommand);
const expectedFunctionSuggestions = getFunctionSignaturesByReturnType(Location.SORT, 'any', {
scalar: true,
}).map(attachTriggerCommand);
export const EXPECTED_FIELD_AND_FUNCTION_SUGGESTIONS = [
...expectedFieldSuggestions,
...expectedFunctionSuggestions,
];
describe('autocomplete.suggest', () => {
describe('SORT ( <column> [ ASC / DESC ] [ NULLS FIST / NULLS LAST ] )+', () => {
describe('SORT <column> ...', () => {
const expectedFieldSuggestions = getFieldNamesByType('any').map(attachTriggerCommand);
const expectedFunctionSuggestions = getFunctionSignaturesByReturnType(Location.SORT, 'any', {
scalar: true,
}).map(attachTriggerCommand);
test('suggests column', async () => {
const { assertSuggestions } = await setup();
await assertSuggestions('from a | sort /', [
...expectedFieldSuggestions,
...expectedFunctionSuggestions,
]);
await assertSuggestions('from a | sort /', EXPECTED_FIELD_AND_FUNCTION_SUGGESTIONS);
await assertSuggestions('from a | sort keyw/', [
...expectedFieldSuggestions,
...expectedFunctionSuggestions,

View file

@ -21,19 +21,29 @@ import {
import { FULL_TEXT_SEARCH_FUNCTIONS } from '../../shared/constants';
import { Location } from '../../definitions/types';
describe('WHERE <expression>', () => {
const allEvalFns = getFunctionSignaturesByReturnType(Location.WHERE, 'any', {
const allEvalFns = getFunctionSignaturesByReturnType(Location.WHERE, 'any', {
scalar: true,
});
export const EMPTY_WHERE_SUGGESTIONS = [
...getFieldNamesByType('any')
.map((field) => `${field} `)
.map(attachTriggerCommand),
...allEvalFns,
];
export const EXPECTED_COMPARISON_WITH_TEXT_FIELD_SUGGESTIONS = [
...getFieldNamesByType(['text', 'keyword', 'ip', 'version']),
...getFunctionSignaturesByReturnType(Location.WHERE, ['text', 'keyword', 'ip', 'version'], {
scalar: true,
});
}),
];
describe('WHERE <expression>', () => {
test('beginning an expression', async () => {
const { assertSuggestions } = await setup();
await assertSuggestions('from a | where /', [
...getFieldNamesByType('any')
.map((field) => `${field} `)
.map(attachTriggerCommand),
...allEvalFns,
]);
await assertSuggestions('from a | where /', EMPTY_WHERE_SUGGESTIONS);
await assertSuggestions(
'from a | eval var0 = 1 | where /',
[
@ -67,6 +77,19 @@ describe('WHERE <expression>', () => {
['and', 'or', 'not']
),
]);
await assertSuggestions('from a | where keywordField I/', [
// all functions compatible with a keywordField type
...getFunctionSignaturesByReturnType(
Location.WHERE,
'boolean',
{
operators: true,
},
undefined,
['and', 'or', 'not']
),
]);
});
test('suggests dates after a comparison with a date', async () => {
@ -99,20 +122,13 @@ describe('WHERE <expression>', () => {
test('after a comparison with a string field', async () => {
const { assertSuggestions } = await setup();
const expectedComparisonWithTextFieldSuggestions = [
...getFieldNamesByType(['text', 'keyword', 'ip', 'version']),
...getFunctionSignaturesByReturnType(Location.WHERE, ['text', 'keyword', 'ip', 'version'], {
scalar: true,
}),
];
await assertSuggestions(
'from a | where textField >= /',
expectedComparisonWithTextFieldSuggestions
EXPECTED_COMPARISON_WITH_TEXT_FIELD_SUGGESTIONS
);
await assertSuggestions(
'from a | where textField >= textField/',
expectedComparisonWithTextFieldSuggestions
EXPECTED_COMPARISON_WITH_TEXT_FIELD_SUGGESTIONS
);
});
@ -356,7 +372,7 @@ describe('WHERE <expression>', () => {
(await suggest('FROM index | WHERE some.prefix/')).forEach((suggestion) => {
expect(suggestion.rangeToReplace).toEqual({
start: 20,
start: 19,
end: 30,
});
});
@ -368,13 +384,13 @@ describe('WHERE <expression>', () => {
const suggestions = await suggest('FROM index | WHERE doubleField IS N/');
expect(suggestions.find((s) => s.text === 'IS NOT NULL')?.rangeToReplace).toEqual({
start: 32,
end: 36,
start: 31,
end: 35,
});
expect(suggestions.find((s) => s.text === 'IS NULL')?.rangeToReplace).toEqual({
start: 32,
end: 36,
start: 31,
end: 35,
});
});
@ -384,13 +400,13 @@ describe('WHERE <expression>', () => {
const suggestions = await suggest('FROM index | WHERE doubleField IS /');
expect(suggestions.find((s) => s.text === 'IS NOT NULL')?.rangeToReplace).toEqual({
start: 32,
end: 35,
start: 31,
end: 34,
});
expect(suggestions.find((s) => s.text === 'IS NULL')?.rangeToReplace).toEqual({
start: 32,
end: 35,
start: 31,
end: 34,
});
});
});
@ -414,7 +430,6 @@ describe('WHERE <expression>', () => {
detail: 'Click to create',
command: { id: 'esql.control.values.create', title: 'Click to create' },
sortText: '11',
rangeToReplace: { start: 30, end: 30 },
});
});
@ -442,7 +457,6 @@ describe('WHERE <expression>', () => {
detail: 'Named parameter',
command: undefined,
sortText: '11A',
rangeToReplace: { start: 30, end: 30 },
});
});

View file

@ -665,19 +665,19 @@ describe('autocomplete', () => {
text: 'foo$bar | ',
filterText: 'foo$bar',
command: TRIGGER_SUGGESTION_COMMAND,
rangeToReplace: { start: 6, end: 13 },
rangeToReplace: { start: 5, end: 12 },
},
{
text: 'foo$bar, ',
filterText: 'foo$bar',
command: TRIGGER_SUGGESTION_COMMAND,
rangeToReplace: { start: 6, end: 13 },
rangeToReplace: { start: 5, end: 12 },
},
{
text: 'foo$bar METADATA ',
filterText: 'foo$bar',
command: TRIGGER_SUGGESTION_COMMAND,
rangeToReplace: { start: 6, end: 13 },
rangeToReplace: { start: 5, end: 12 },
},
...recommendedQuerySuggestions.map((q) => q.queryString),
],
@ -882,7 +882,7 @@ describe('autocomplete', () => {
getFieldNamesByType('any')
.map<PartialSuggestionWithText>((text) => ({
text,
rangeToReplace: { start: 15, end: 16 },
rangeToReplace: { start: 14, end: 15 },
}))
.map(attachTriggerCommand)
);
@ -896,7 +896,7 @@ describe('autocomplete', () => {
.map((text) => ({
text,
filterText: 'doubleField',
rangeToReplace: { start: 15, end: 26 },
rangeToReplace: { start: 14, end: 25 },
}))
.map(attachTriggerCommand)
);
@ -909,7 +909,7 @@ describe('autocomplete', () => {
.map((text) => ({
text,
filterText: '@timestamp',
rangeToReplace: { start: 15, end: 25 },
rangeToReplace: { start: 14, end: 24 },
}))
.map(attachTriggerCommand),
undefined,
@ -926,7 +926,7 @@ describe('autocomplete', () => {
.map((text) => ({
text,
filterText: 'foo.bar',
rangeToReplace: { start: 15, end: 22 },
rangeToReplace: { start: 14, end: 21 },
}))
.map(attachTriggerCommand),
undefined,
@ -1004,8 +1004,8 @@ describe('autocomplete', () => {
describe('Replacement ranges are attached when needed', () => {
testSuggestions('FROM a | WHERE doubleField IS NOT N/', [
{ text: 'IS NOT NULL', rangeToReplace: { start: 28, end: 36 } },
{ text: 'IS NULL', rangeToReplace: { start: 35, end: 35 } },
{ text: 'IS NOT NULL', rangeToReplace: { start: 27, end: 35 } },
{ text: 'IS NULL', rangeToReplace: undefined },
'!= $0',
'== $0',
'IN $0',
@ -1014,9 +1014,9 @@ describe('autocomplete', () => {
'OR $0',
]);
testSuggestions('FROM a | WHERE doubleField IS N/', [
{ text: 'IS NOT NULL', rangeToReplace: { start: 28, end: 32 } },
{ text: 'IS NULL', rangeToReplace: { start: 28, end: 32 } },
{ text: '!= $0', rangeToReplace: { start: 31, end: 31 } },
{ text: 'IS NOT NULL', rangeToReplace: { start: 27, end: 31 } },
{ text: 'IS NULL', rangeToReplace: { start: 27, end: 31 } },
{ text: '!= $0', rangeToReplace: { start: 30, end: 31 } },
'== $0',
'IN $0',
'AND $0',
@ -1024,7 +1024,7 @@ describe('autocomplete', () => {
'OR $0',
]);
testSuggestions('FROM a | EVAL doubleField IS NOT N/', [
{ text: 'IS NOT NULL', rangeToReplace: { start: 27, end: 35 } },
{ text: 'IS NOT NULL', rangeToReplace: { start: 26, end: 34 } },
'IS NULL',
'!= $0',
'== $0',
@ -1037,21 +1037,21 @@ describe('autocomplete', () => {
describe('dot-separated field names', () => {
testSuggestions(
'FROM a | KEEP field.nam/',
[{ text: 'field.name', rangeToReplace: { start: 15, end: 24 } }],
[{ text: 'field.name', rangeToReplace: { start: 14, end: 23 } }],
undefined,
[[{ name: 'field.name', type: 'double' }]]
);
// multi-line
testSuggestions(
'FROM a\n| KEEP field.nam/',
[{ text: 'field.name', rangeToReplace: { start: 15, end: 24 } }],
[{ text: 'field.name', rangeToReplace: { start: 14, end: 23 } }],
undefined,
[[{ name: 'field.name', type: 'double' }]]
);
// triple separator
testSuggestions(
'FROM a\n| KEEP field.name.f/',
[{ text: 'field.name.foo', rangeToReplace: { start: 15, end: 27 } }],
[{ text: 'field.name.foo', rangeToReplace: { start: 14, end: 26 } }],
undefined,
[[{ name: 'field.name.foo', type: 'double' }]]
);
@ -1059,7 +1059,7 @@ describe('autocomplete', () => {
// we are relying on string checking instead of the AST :(
testSuggestions.skip(
'FROM a | KEEP field . n/',
[{ text: 'field . name', rangeToReplace: { start: 15, end: 23 } }],
[{ text: 'field . name', rangeToReplace: { start: 14, end: 22 } }],
undefined,
[[{ name: 'field.name', type: 'double' }]]
);

View file

@ -0,0 +1,85 @@
/*
* 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 { ESQLCommand } from '@kbn/esql-ast';
import { i18n } from '@kbn/i18n';
import { CommandSuggestParams } from '../../../definitions/types';
import {
getCommandDefinition,
getCommandsByName,
pipePrecedesCurrentWord,
} from '../../../shared/helpers';
import { getCommandAutocompleteDefinitions, pipeCompleteItem } from '../../complete_items';
import { TRIGGER_SUGGESTION_COMMAND } from '../../factories';
import type { SuggestionRawDefinition } from '../../types';
export async function suggest(
params: CommandSuggestParams<'fork'>
): Promise<SuggestionRawDefinition[]> {
if (/FORK\s+$/i.test(params.innerText)) {
return [newBranchSuggestion];
}
const activeBranch = getActiveBranch(params.command);
const withinActiveBranch =
activeBranch &&
activeBranch.location.min <= params.innerText.length &&
activeBranch.location.max >= params.innerText.length;
if (!withinActiveBranch && /\)\s+$/i.test(params.innerText)) {
const suggestions = [newBranchSuggestion];
if (params.command.args.length > 1) {
suggestions.push(pipeCompleteItem);
}
return suggestions;
}
// within a branch
if (activeBranch?.commands.length === 0 || pipePrecedesCurrentWord(params.innerText)) {
return getCommandAutocompleteDefinitions(getCommandsByName(['limit', 'sort', 'where']));
}
const subCommand = activeBranch?.commands[activeBranch.commands.length - 1];
if (!subCommand) {
return [];
}
const commandDef = getCommandDefinition(subCommand?.name);
return commandDef.suggest({
...params,
command: subCommand as ESQLCommand,
definition: commandDef,
});
}
const newBranchSuggestion: SuggestionRawDefinition = {
kind: 'Issue',
label: i18n.translate('kbn-esql-validation-autocomplete.esql.suggestions.newBranchLabel', {
defaultMessage: 'New branch',
}),
detail: i18n.translate('kbn-esql-validation-autocomplete.esql.suggestions.newBranchDetail', {
defaultMessage: 'Add a new branch to the fork',
}),
text: '($0)',
asSnippet: true,
command: TRIGGER_SUGGESTION_COMMAND,
};
const getActiveBranch = (command: ESQLCommand<'fork'>) => {
const finalBranch = command.args[command.args.length - 1];
if (Array.isArray(finalBranch) || finalBranch.type !== 'query') {
// should never happen
return;
}
return finalBranch;
};

View file

@ -75,11 +75,7 @@ export async function suggest({
suggestions.push(...(await getRecommendedQueriesSuggestions()));
}
// FROM something MET/
else if (
indexes.length > 0 &&
/^FROM\s+\S+\s+/i.test(innerText) &&
metadataOverlap.start !== metadataOverlap.end
) {
else if (indexes.length > 0 && /^FROM\s+\S+\s+/i.test(innerText) && metadataOverlap) {
suggestions.push(metadataSuggestion);
}
// FROM someth/

View file

@ -10,9 +10,9 @@
import { TRIGGER_SUGGESTION_COMMAND } from '../../factories';
import { SuggestionRawDefinition } from '../../types';
const regexStart = /.+\|\s*so?r?(?<start>t?)(.+,)?(?<space1>\s+)?/i;
const regexStart = /so?r?(?<start>t?)(.+,)?(?<space1>\s+)?/i;
const regex =
/.+\|\s*sort(.+,)?((?<space1>\s+)(?<column>[^\s]+)(?<space2>\s*)(?<order>(AS?C?)|(DE?S?C?))?(?<space3>\s*)(?<nulls>NU?L?L?S? ?(FI?R?S?T?|LA?S?T?)?)?(?<space4>\s*))?/i;
/sort(.+,)?((?<space1>\s+)(?<column>[^\s]+)(?<space2>\s*)(?<order>(AS?C?)|(DE?S?C?))?(?<space3>\s*)(?<nulls>NU?L?L?S? ?(FI?R?S?T?|LA?S?T?)?)?(?<space4>\s*))?/i;
export interface SortCaretPosition {
/**

View file

@ -19,10 +19,13 @@ export async function suggest({
innerText,
getColumnsByType,
columnExists,
command,
}: CommandSuggestParams<'sort'>): Promise<SuggestionRawDefinition[]> {
const prependSpace = (s: SuggestionRawDefinition) => ({ ...s, text: ' ' + s.text });
const { pos, nulls } = getSortPos(innerText);
const commandText = innerText.slice(command.location.min);
const { pos, nulls } = getSortPos(commandText);
switch (pos) {
case 'space2': {

View file

@ -11,14 +11,15 @@ import { buildPartialMatcher, getOverlapRange } from './helper';
describe('getOverlapRange', () => {
it('should return the overlap range', () => {
expect(getOverlapRange('IS N', 'IS NOT NULL')).toEqual({ start: 1, end: 5 });
expect(getOverlapRange('I', 'IS NOT NULL')).toEqual({ start: 1, end: 2 });
expect(getOverlapRange('IS N', 'IS NOT NULL')).toEqual({ start: 0, end: 4 });
expect(getOverlapRange('I', 'IS NOT NULL')).toEqual({ start: 0, end: 1 });
expect(getOverlapRange('j', 'IS NOT NULL')).toBeUndefined();
});
it('full query', () => {
expect(getOverlapRange('FROM index | WHERE field IS N', 'IS NOT NULL')).toEqual({
start: 26,
end: 30,
start: 25,
end: 29,
});
});
});

View file

@ -8,27 +8,34 @@
*/
import {
ESQLSingleAstItem,
Walker,
isIdentifier,
type ESQLAstItem,
type ESQLCommand,
type ESQLFunction,
type ESQLLiteral,
type ESQLSource,
ESQLSingleAstItem,
Walker,
} from '@kbn/esql-ast';
import { uniqBy } from 'lodash';
import { ESQLVariableType } from '@kbn/esql-types';
import { uniqBy } from 'lodash';
import { logicalOperators } from '../definitions/all_operators';
import {
CommandSuggestParams,
FunctionDefinitionTypes,
Location,
isParameterType,
isReturnType,
type FunctionDefinition,
type FunctionReturnType,
type SupportedDataType,
isReturnType,
FunctionDefinitionTypes,
CommandSuggestParams,
Location,
} from '../definitions/types';
import {
EDITOR_MARKER,
UNSUPPORTED_COMMANDS_BEFORE_MATCH,
UNSUPPORTED_COMMANDS_BEFORE_QSTR,
} from '../shared/constants';
import { compareTypesWithLiterals } from '../shared/esql_types';
import {
findFinalWord,
getColumnForASTNode,
@ -40,27 +47,19 @@ import {
isLiteralItem,
isTimeIntervalItem,
} from '../shared/helpers';
import type { GetColumnsByTypeFn, SuggestionRawDefinition } from './types';
import { compareTypesWithLiterals } from '../shared/esql_types';
import { ESQLRealField, ESQLVariable, ReferenceMaps } from '../validation/types';
import { listCompleteItem } from './complete_items';
import {
TIME_SYSTEM_PARAMS,
buildVariablesDefinitions,
getFunctionSuggestions,
getCompatibleLiterals,
getDateLiterals,
getFunctionSuggestions,
getOperatorSuggestion,
getOperatorSuggestions,
getSuggestionsAfterNot,
getOperatorSuggestion,
} from './factories';
import {
EDITOR_MARKER,
UNSUPPORTED_COMMANDS_BEFORE_MATCH,
UNSUPPORTED_COMMANDS_BEFORE_QSTR,
} from '../shared/constants';
import { ESQLRealField, ESQLVariable, ReferenceMaps } from '../validation/types';
import { listCompleteItem } from './complete_items';
import { removeMarkerArgFromArgsList } from '../shared/context';
import { logicalOperators } from '../definitions/all_operators';
import type { GetColumnsByTypeFn, SuggestionRawDefinition } from './types';
function extractFunctionArgs(args: ESQLAstItem[]): ESQLFunction[] {
return args.flatMap((arg) => (isAssignment(arg) ? arg.args[1] : arg)).filter(isFunctionItem);
@ -219,7 +218,7 @@ export function getCompatibleTypesToSuggestNext(
export function getOverlapRange(
query: string,
suggestionText: string
): { start: number; end: number } {
): { start: number; end: number } | undefined {
let overlapLength = 0;
// Convert both strings to lowercase for case-insensitive comparison
@ -233,10 +232,13 @@ export function getOverlapRange(
}
}
// add one since Monaco columns are 1-based
if (overlapLength === 0) {
return;
}
return {
start: query.length - overlapLength + 1,
end: query.length + 1,
start: query.length - overlapLength,
end: query.length,
};
}
@ -366,8 +368,8 @@ export function handleFragment(
return getSuggestionsForIncomplete('');
} else {
const rangeToReplace = {
start: innerText.length - fragment.length + 1,
end: innerText.length + 1,
start: innerText.length - fragment.length,
end: innerText.length,
};
if (isFragmentComplete(fragment)) {
return getSuggestionsForComplete(fragment, rangeToReplace);
@ -511,6 +513,29 @@ export function extractTypeFromASTArg(
}
}
/**
* In several cases we don't want to count the last arg if it is
* of type unknown.
*
* this solves for the case where the user has typed a
* prefix (e.g. "keywordField != tex/")
*
* "tex" is not a recognizable identifier so it is of
* type "unknown" which leads us to continue suggesting
* fields/functions.
*
* Monaco will then filter our suggestions list
* based on the "tex" prefix which gives the correct UX
*/
function removeFinalUnknownIdentiferArg(
args: ESQLAstItem[],
getExpressionType: (expression: ESQLAstItem) => SupportedDataType | 'unknown'
) {
return getExpressionType(args[args.length - 1]) === 'unknown'
? args.slice(0, args.length - 1)
: args;
}
// @TODO: refactor this to be shared with validation
export function checkFunctionInvocationComplete(
func: ESQLFunction,
@ -523,7 +548,9 @@ export function checkFunctionInvocationComplete(
if (!fnDefinition) {
return { complete: false };
}
const cleanedArgs = removeMarkerArgFromArgsList(func)!.args;
const cleanedArgs = removeFinalUnknownIdentiferArg(func.args, getExpressionType);
const argLengthCheck = fnDefinition.signatures.some((def) => {
if (def.minParams && cleanedArgs.length >= def.minParams) {
return true;
@ -604,7 +631,7 @@ export async function getSuggestionsToRightOfOperatorExpression({
// and suggest the next argument based on types
// pick the last arg and check its type to verify whether is incomplete for the given function
const cleanedArgs = removeMarkerArgFromArgsList(operator)!.args;
const cleanedArgs = removeFinalUnknownIdentiferArg(operator.args, getExpressionType);
const leftArgType = getExpressionType(operator.args[cleanedArgs.length - 1]);
if (isFnComplete.reason === 'tooFewArgs') {
@ -675,10 +702,7 @@ export async function getSuggestionsToRightOfOperatorExpression({
const overlap = getOverlapRange(queryText, s.text);
return {
...s,
rangeToReplace: {
start: overlap.start,
end: overlap.end,
},
rangeToReplace: overlap,
};
});
}
@ -719,7 +743,11 @@ export const getExpressionPosition = (
}
if (expressionRoot) {
if (isColumnItem(expressionRoot)) {
if (
isColumnItem(expressionRoot) &&
// and not directly after the column name or prefix e.g. "colu/"
!new RegExp(`${expressionRoot.parts.join('\\.')}$`).test(innerText)
) {
return 'after_column';
}
@ -892,28 +920,21 @@ export async function suggestForExpression({
* TODO - think about how to generalize this issue: https://github.com/elastic/kibana/issues/209905
*/
const hasNonWhitespacePrefix = !/\s/.test(innerText[innerText.length - 1]);
if (hasNonWhitespacePrefix) {
// get index of first char of final word
const lastWhitespaceIndex = innerText.search(/\S(?=\S*$)/);
suggestions.forEach((s) => {
if (['IS NULL', 'IS NOT NULL'].includes(s.text)) {
// this suggestion has spaces in it (e.g. "IS NOT NULL")
// so we need to see if there's an overlap
const overlap = getOverlapRange(innerText, s.text);
if (overlap.start < overlap.end) {
// there's an overlap so use that
s.rangeToReplace = overlap;
return;
}
}
// no overlap, so just replace from the last whitespace
suggestions.forEach((s) => {
if (['IS NULL', 'IS NOT NULL'].includes(s.text)) {
// this suggestion has spaces in it (e.g. "IS NOT NULL")
// so we need to see if there's an overlap
s.rangeToReplace = getOverlapRange(innerText, s.text);
return;
} else if (hasNonWhitespacePrefix) {
// get index of first char of final word
const lastNonWhitespaceIndex = innerText.search(/\S(?=\S*$)/);
s.rangeToReplace = {
start: lastWhitespaceIndex + 1,
start: lastNonWhitespaceIndex,
end: innerText.length,
};
});
}
}
});
return suggestions;
}

View file

@ -63,6 +63,11 @@ export interface SuggestionRawDefinition {
};
/**
* The range that should be replaced when the suggestion is applied
*
* IMPORTANT NOTE!!!
*
* This range is ZERO-based and NOT end-inclusive [start, end)
* Also, do NOT try to account for newline characters. This is taken care of later.
*/
rangeToReplace?: {
start: number;

View file

@ -45,6 +45,7 @@ import { suggest as suggestForDissect } from '../autocomplete/commands/dissect';
import { suggest as suggestForDrop } from '../autocomplete/commands/drop';
import { suggest as suggestForEnrich } from '../autocomplete/commands/enrich';
import { suggest as suggestForEval } from '../autocomplete/commands/eval';
import { suggest as suggestForFork } from '../autocomplete/commands/fork';
import { suggest as suggestForFrom } from '../autocomplete/commands/from';
import { suggest as suggestForGrok } from '../autocomplete/commands/grok';
import { suggest as suggestForJoin } from '../autocomplete/commands/join';
@ -674,4 +675,34 @@ export const commandDefinitions: Array<CommandDefinition<any>> = [
},
suggest: suggestForChangePoint,
},
{
hidden: true,
name: 'fork',
preview: true,
description: i18n.translate('kbn-esql-validation-autocomplete.esql.definitions.forkDoc', {
defaultMessage: 'Forks the stream.',
}),
declaration: `TODO`,
examples: [],
suggest: suggestForFork,
validate: (command) => {
const messages: ESQLMessage[] = [];
if (command.args.length < 2) {
messages.push({
location: command.location,
text: i18n.translate(
'kbn-esql-validation-autocomplete.esql.validation.forkTooFewBranches',
{
defaultMessage: '[FORK] Must include at least two branches.',
}
),
type: 'error',
code: 'forkTooFewBranches',
});
}
return messages;
},
},
];

View file

@ -207,7 +207,12 @@ export function getAstContext(queryString: string, ast: ESQLAst, offset: number)
}
}
}
if (!command || (queryString.length <= offset && pipePrecedesCurrentWord(queryString))) {
if (
!command ||
(queryString.length <= offset &&
pipePrecedesCurrentWord(queryString) &&
command.location.max < queryString.length)
) {
// // ... | <here>
return { type: 'newCommand' as const, command: undefined, node, option, containingFunction };
}

View file

@ -200,14 +200,23 @@ function buildCommandLookup(): Map<string, CommandDefinition<string>> {
return commandLookups!;
}
export function getCommandDefinition(name: string): CommandDefinition<string> {
return buildCommandLookup().get(name.toLowerCase())!;
export function getCommandDefinition<CommandName extends string>(
name: CommandName
): CommandDefinition<CommandName> {
return buildCommandLookup().get(name.toLowerCase()) as unknown as CommandDefinition<CommandName>;
}
export function getAllCommands() {
return Array.from(buildCommandLookup().values());
}
export function getCommandsByName(names: string[]): Array<CommandDefinition<string>> {
const commands = buildCommandLookup();
return names.map((name) => commands.get(name)).filter((command) => command) as Array<
CommandDefinition<string>
>;
}
function doesLiteralMatchParameterType(argType: FunctionParameterType, item: ESQLLiteral) {
if (item.literalType === argType) {
return true;

View file

@ -0,0 +1,129 @@
/*
* 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 * as helpers from '../helpers';
export const validationForkCommandTestSuite = (setup: helpers.Setup) => {
describe('validation', () => {
describe('command', () => {
describe('FORK', () => {
test('no errors for valid command', async () => {
const { expectErrors } = await setup();
await expectErrors(
`FROM index
| FORK
(WHERE keywordField != "" | LIMIT 100)
(SORT doubleField ASC NULLS LAST)`,
[]
);
await expectErrors(
`FROM index
| FORK
(WHERE keywordField != "" | LIMIT 100)
(SORT doubleField ASC NULLS LAST)
(LIMIT 100)`,
[]
);
});
test('requires at least two branches', async () => {
const { expectErrors } = await setup();
await expectErrors(
`FROM index
| FORK
(WHERE keywordField != "")`,
[`[FORK] Must include at least two branches.`]
);
});
test('enforces only one fork command', async () => {
const { expectErrors } = await setup();
await expectErrors(
`FROM index
| FORK
(WHERE keywordField != "" | LIMIT 100)
(SORT doubleField ASC NULLS LAST)
| KEEP keywordField
| FORK
(WHERE keywordField != "foo")
(WHERE keywordField != "bar")`,
['[FORK] a query cannot have more than one FORK command.']
);
});
describe('_fork field', () => {
test('does NOT recognize _fork field BEFORE FORK', async () => {
const { expectErrors } = await setup();
await expectErrors(
`FROM index
| KEEP _fork
| FORK
(WHERE keywordField != "" | LIMIT 100)
(SORT doubleField ASC NULLS LAST)`,
['Unknown column [_fork]']
);
});
test('DOES recognize _fork field AFTER FORK', async () => {
const { expectErrors } = await setup();
await expectErrors(
`FROM index
| FORK
(WHERE keywordField != "" | LIMIT 100)
(SORT doubleField ASC NULLS LAST)
| KEEP _fork`,
[]
);
});
});
describe('... (SUBCOMMAND ...) ...', () => {
test('validates within subcommands', async () => {
const { expectErrors } = await setup();
await expectErrors(
`FROM index
| FORK
(WHERE TO_UPPER(longField) != "" | LIMIT 100)
(WHERE TO_LOWER(doubleField) == "" | WHERE TRIM(integerField))`,
[
'Argument of [to_lower] must be [keyword], found value [doubleField] type [double]',
'Argument of [to_upper] must be [keyword], found value [longField] type [long]',
'Argument of [trim] must be [keyword], found value [integerField] type [integer]',
]
);
});
test('forwards syntax errors', async () => {
const { expectErrors } = await setup();
await expectErrors(
`FROM index
| FORK
(EVAL TO_UPPER(keywordField) | LIMIT 100)
(FORK (WHERE 1))`,
[
"SyntaxError: mismatched input ')' expecting <EOF>",
"SyntaxError: mismatched input 'EVAL' expecting {'limit', 'sort', 'where'}",
"SyntaxError: mismatched input 'keywordField' expecting {'limit', 'sort', 'where'}",
"SyntaxError: token recognition error at: ')'",
]
);
});
});
});
});
});
};

View file

@ -0,0 +1,13 @@
/*
* 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 * as helpers from './helpers';
import { validationForkCommandTestSuite } from './test_suites/validation.command.fork';
validationForkCommandTestSuite(helpers.setup);

View file

@ -443,6 +443,12 @@ function getMessageAndTypeFromId<K extends ErrorTypes>({
}
),
};
case 'tooManyForks':
return {
message: i18n.translate('kbn-esql-validation-autocomplete.esql.validation.tooManyForks', {
defaultMessage: '[FORK] a query cannot have more than one FORK command.',
}),
};
}
return { message: '' };
}
@ -508,6 +514,9 @@ export const errors = {
name: column.name,
}),
tooManyForks: (command: ESQLCommand): ESQLMessage =>
errors.byId('tooManyForks', command.location, {}),
noAggFunction: (cmd: ESQLCommand, fn: ESQLFunction): ESQLMessage =>
errors.byId('noAggFunction', fn.location, {
commandName: cmd.name,

View file

@ -211,6 +211,10 @@ export interface ValidationErrors {
message: string;
type: { identifier: string };
};
tooManyForks: {
message: string;
type: {};
};
}
export type ErrorTypes = keyof ValidationErrors;

View file

@ -28,6 +28,7 @@ import {
isFunctionItem,
isOptionItem,
isParametrized,
isSingleItem,
isSourceItem,
isTimeIntervalItem,
sourceExists,
@ -172,15 +173,23 @@ async function validateAst(
messages.push(...validateFieldsShadowing(availableFields, variables));
messages.push(...validateUnsupportedTypeFields(availableFields, ast));
const references: ReferenceMaps = {
sources,
fields: availableFields,
policies: availablePolicies,
variables,
query: queryString,
joinIndices: joinIndices?.indices || [],
};
let seenFork = false;
for (const [index, command] of ast.entries()) {
const references: ReferenceMaps = {
sources,
fields: availableFields,
policies: availablePolicies,
variables,
query: queryString,
joinIndices: joinIndices?.indices || [],
};
if (command.name === 'fork') {
if (seenFork) {
messages.push(errors.tooManyForks(command));
} else {
seenFork = true;
}
}
const commandMessages = validateCommand(command, references, ast, index);
messages.push(...commandMessages);
}
@ -225,6 +234,21 @@ function validateCommand(
messages.push(...joinCommandErrors);
break;
}
case 'fork': {
references.fields.set('_fork', {
name: '_fork',
type: 'keyword',
});
for (const arg of command.args.flat()) {
if (isSingleItem(arg) && arg.type === 'query') {
// all the args should be commands
arg.commands.forEach((subCommand) => {
messages.push(...validateCommand(subCommand, references, ast, currentCommandIndex));
});
}
}
}
default: {
// Now validate arguments
for (const arg of command.args) {

View file

@ -9,55 +9,45 @@
import { offsetRangeToMonacoRange } from './utils';
describe('offsetRangeToMonacoRange', () => {
test('should convert offset range to monaco range when the cursor is not at the end', () => {
const expression = 'FROM test | WHERE test == | LIMIT 1';
const range = { start: 26, end: 26 };
const monacoRange = offsetRangeToMonacoRange(expression, range);
expect(monacoRange).toEqual({
startColumn: 26,
endColumn: 26,
startLineNumber: 1,
endLineNumber: 1,
});
test('single line', () => {
const expression = 'FROM kibana_sample_data_logs';
const range = offsetRangeToMonacoRange(expression, { start: 5, end: 28 });
expect(range).toEqual({ startColumn: 6, endColumn: 28, startLineNumber: 1, endLineNumber: 1 });
});
test('should convert offset range to monaco range when the cursor is at the end', () => {
const expression = 'FROM test | WHERE test == 1 | LIMIT 1';
const range = { start: 37, end: 37 };
const monacoRange = offsetRangeToMonacoRange(expression, range);
expect(monacoRange).toEqual({
startColumn: 37,
endColumn: 37,
startLineNumber: 1,
endLineNumber: 1,
});
test('next line', () => {
const expression = `FROM kibana_sample_data_logs
| KEEP foo`;
const range = offsetRangeToMonacoRange(expression, { start: 36, end: 39 });
expect(range).toEqual({ startColumn: 8, endColumn: 10, startLineNumber: 2, endLineNumber: 2 });
});
test('should convert offset range to monaco range for multiple lines query when the cursor is not at the end', () => {
const expression = 'FROM test \n| WHERE test == \n| LIMIT 1';
const range = { start: 27, end: 27 };
const expression = 'FROM test \n| WHERE test == t\n| LIMIT 1';
const range = { start: 27, end: 28 };
const monacoRange = offsetRangeToMonacoRange(expression, range);
expect(monacoRange).toEqual({
startColumn: 16,
endColumn: 16,
startColumn: 17,
endColumn: 17,
startLineNumber: 2,
endLineNumber: 2,
});
});
test('should convert offset range to monaco range for multiple lines query when the cursor is at the end', () => {
const expression = 'FROM test \n| WHERE test == \n| LIMIT 1';
const range = { start: 35, end: 35 };
test('returns undefined if the start is past the end of the expression', () => {
const expression = 'FROM test | WHERE test == 1 | LIMIT 1';
const range = { start: 37, end: 37 };
const monacoRange = offsetRangeToMonacoRange(expression, range);
expect(monacoRange).toEqual({
startColumn: 7,
endColumn: 7,
startLineNumber: 3,
endLineNumber: 3,
});
expect(monacoRange).toEqual(undefined);
});
test('should not create a range if start and end are equal', () => {
const expression = 'FROM test | WHERE test == | LIMIT 1';
const range = { start: 26, end: 26 };
const monacoRange = offsetRangeToMonacoRange(expression, range);
expect(monacoRange).toEqual(undefined);
});
});

View file

@ -18,6 +18,7 @@ export function monacoPositionToOffset(expression: string, position: monaco.Posi
offset += lines[i].length + 1; // +1 for the newline character
}
// one-based to zero-based indexing
offset += position.column - 1;
return offset;
@ -25,65 +26,53 @@ export function monacoPositionToOffset(expression: string, position: monaco.Posi
/**
* Given an offset range, returns a monaco IRange object.
* @param expression
* @param range
* @returns
*
* IMPORTANT NOTE:
* offset ranges are ZERO-based and NOT end-inclusive [start, end)
* monaco ranges are ONE-based and ARE end-inclusive [start, end]
*/
export const offsetRangeToMonacoRange = (
expression: string,
range: { start: number; end: number }
): {
startColumn: number;
endColumn: number;
startLineNumber: number;
endLineNumber: number;
} => {
let startColumn = 0;
let endColumn = 0;
// How far we are past the last newline character
let currentOffset = 0;
):
| {
startColumn: number;
endColumn: number;
startLineNumber: number;
endLineNumber: number;
}
| undefined => {
if (range.start === range.end) {
return;
}
let startLineNumber = 1;
let endLineNumber = 1;
let startColumn = NaN;
let endColumn = 0;
let startOfCurrentLine = 0;
let currentLine = 1;
const hasMultipleLines = expression.includes('\n');
const offset = hasMultipleLines ? 1 : 0;
// find the line and start column
for (let i = 0; i < expression.length; i++) {
if (i === range.start - offset) {
startLineNumber = currentLine;
startColumn = i - currentOffset;
}
if (i === range.end - offset) {
endLineNumber = currentLine;
endColumn = i - currentOffset;
break; // No need to continue once we find the end position
}
if (expression[i] === '\n') {
currentLine++;
currentOffset = i;
startOfCurrentLine = i + 1;
}
if (i === range.start) {
startColumn = i + 1 - startOfCurrentLine;
endColumn = startColumn + range.end - range.start - 1;
break;
}
}
// Handle the case where the start offset is past the end of the string
if (range.start >= expression.length) {
startLineNumber = currentLine;
startColumn = range.start - currentOffset;
}
// Handle the case where the end offset is at the end or past the end of the string
if (range.end >= expression.length) {
endLineNumber = currentLine;
endColumn = range.end - currentOffset;
if (isNaN(startColumn)) {
return;
}
return {
startLineNumber: currentLine,
endLineNumber: currentLine,
startColumn,
endColumn,
startLineNumber,
endLineNumber,
};
};