mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
[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:
parent
1b10f35b3d
commit
7e35e92b4b
35 changed files with 1204 additions and 406 deletions
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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`.
|
||||
*
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
};
|
|
@ -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;
|
||||
};
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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()) {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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<
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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<
|
||||
|
|
|
@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
|
|
|
@ -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 },
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -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' }]]
|
||||
);
|
||||
|
|
|
@ -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;
|
||||
};
|
|
@ -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/
|
||||
|
|
|
@ -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 {
|
||||
/**
|
||||
|
|
|
@ -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': {
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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: ')'",
|
||||
]
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
|
@ -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);
|
|
@ -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,
|
||||
|
|
|
@ -211,6 +211,10 @@ export interface ValidationErrors {
|
|||
message: string;
|
||||
type: { identifier: string };
|
||||
};
|
||||
tooManyForks: {
|
||||
message: string;
|
||||
type: {};
|
||||
};
|
||||
}
|
||||
|
||||
export type ErrorTypes = keyof ValidationErrors;
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue