From 8bd6e11bd6f4402d30cd80a5ca1b0a2c9a13a3ea Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Tue, 24 Sep 2024 04:36:26 +1000 Subject: [PATCH] [8.x] [ES|QL] Implement `OrderExpression` for `SORT` command arguments (#189959) (#193379) # Backport This will backport the following commits from `main` to `8.x`: - [[ES|QL] Implement `OrderExpression` for `SORT` command arguments (#189959)](https://github.com/elastic/kibana/pull/189959) ### Questions ? Please refer to the [Backport tool documentation](https://github.com/sqren/backport) Co-authored-by: Vadim Kibana <82822460+vadimkibana@users.noreply.github.com> Co-authored-by: Elastic Machine --- .../src/__tests__/ast_parser.sort.test.ts | 141 +++++++++++++++--- packages/kbn-esql-ast/src/ast_factory.ts | 4 +- packages/kbn-esql-ast/src/ast_helpers.ts | 21 +++ packages/kbn-esql-ast/src/ast_walker.ts | 63 ++++---- .../__tests__/basic_pretty_printer.test.ts | 16 +- .../src/pretty_print/basic_pretty_printer.ts | 23 ++- packages/kbn-esql-ast/src/types.ts | 18 ++- packages/kbn-esql-ast/src/visitor/contexts.ts | 6 + .../src/visitor/global_visitor_context.ts | 14 ++ packages/kbn-esql-ast/src/visitor/types.ts | 7 +- .../autocomplete.command.sort.test.ts | 106 +++++++++++++ .../src/autocomplete/autocomplete.test.ts | 70 --------- .../src/autocomplete/autocomplete.ts | 102 +++++++++++++ .../autocomplete/commands/sort/helper.test.ts | 55 +++++++ .../src/autocomplete/commands/sort/helper.ts | 84 +++++++++++ .../src/definitions/builtin.ts | 14 ++ .../src/definitions/commands.ts | 6 +- .../src/shared/context.ts | 2 +- 18 files changed, 616 insertions(+), 136 deletions(-) create mode 100644 packages/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/autocomplete.command.sort.test.ts create mode 100644 packages/kbn-esql-validation-autocomplete/src/autocomplete/commands/sort/helper.test.ts create mode 100644 packages/kbn-esql-validation-autocomplete/src/autocomplete/commands/sort/helper.ts diff --git a/packages/kbn-esql-ast/src/__tests__/ast_parser.sort.test.ts b/packages/kbn-esql-ast/src/__tests__/ast_parser.sort.test.ts index c57a75644bcec..bb5e6aeb1e6b4 100644 --- a/packages/kbn-esql-ast/src/__tests__/ast_parser.sort.test.ts +++ b/packages/kbn-esql-ast/src/__tests__/ast_parser.sort.test.ts @@ -11,11 +11,49 @@ import { getAstAndSyntaxErrors as parse } from '../ast_parser'; describe('SORT', () => { describe('correctly formatted', () => { - // Un-skip one https://github.com/elastic/kibana/issues/189491 fixed. - it.skip('example from documentation', () => { + it('sorting order without modifiers', () => { + const text = `FROM employees | SORT height`; + const { ast, errors } = parse(text); + + expect(errors.length).toBe(0); + expect(ast).toMatchObject([ + {}, + { + type: 'command', + name: 'sort', + args: [ + { + type: 'column', + name: 'height', + }, + ], + }, + ]); + }); + + it('sort expression is a function call', () => { + const text = `from a_index | sort values(textField)`; + const { ast, errors } = parse(text); + + expect(errors.length).toBe(0); + expect(ast).toMatchObject([ + {}, + { + type: 'command', + name: 'sort', + args: [ + { + type: 'function', + name: 'values', + }, + ], + }, + ]); + }); + + it('with order modifier "DESC"', () => { const text = ` FROM employees - | KEEP first_name, last_name, height | SORT height DESC `; const { ast, errors } = parse(text); @@ -23,22 +61,57 @@ describe('SORT', () => { expect(errors.length).toBe(0); expect(ast).toMatchObject([ {}, + { + type: 'command', + name: 'sort', + args: [ + { + type: 'order', + order: 'DESC', + nulls: '', + args: [ + { + type: 'column', + name: 'height', + }, + ], + }, + ], + }, + ]); + }); + + it('with nulls modifier "NULLS LAST"', () => { + const text = ` + FROM employees + | SORT height NULLS LAST + `; + const { ast, errors } = parse(text); + + expect(errors.length).toBe(0); + expect(ast).toMatchObject([ {}, { type: 'command', name: 'sort', args: [ { - type: 'column', - name: 'height', + type: 'order', + order: '', + nulls: 'NULLS LAST', + args: [ + { + type: 'column', + name: 'height', + }, + ], }, ], }, ]); }); - // Un-skip once https://github.com/elastic/kibana/issues/189491 fixed. - it.skip('can parse various sorting columns with options', () => { + it('can parse various sorting columns with options', () => { const text = 'FROM a | SORT a, b ASC, c DESC, d NULLS FIRST, e NULLS LAST, f ASC NULLS FIRST, g DESC NULLS LAST'; const { ast, errors } = parse(text); @@ -55,28 +128,58 @@ describe('SORT', () => { name: 'a', }, { - type: 'column', - name: 'b', + order: 'ASC', + nulls: '', + args: [ + { + name: 'b', + }, + ], }, { - type: 'column', - name: 'c', + order: 'DESC', + nulls: '', + args: [ + { + name: 'c', + }, + ], }, { - type: 'column', - name: 'd', + order: '', + nulls: 'NULLS FIRST', + args: [ + { + name: 'd', + }, + ], }, { - type: 'column', - name: 'e', + order: '', + nulls: 'NULLS LAST', + args: [ + { + name: 'e', + }, + ], }, { - type: 'column', - name: 'f', + order: 'ASC', + nulls: 'NULLS FIRST', + args: [ + { + name: 'f', + }, + ], }, { - type: 'column', - name: 'g', + order: 'DESC', + nulls: 'NULLS LAST', + args: [ + { + name: 'g', + }, + ], }, ], }, diff --git a/packages/kbn-esql-ast/src/ast_factory.ts b/packages/kbn-esql-ast/src/ast_factory.ts index 44b8c03aa1e7f..f5c3ca7a3b621 100644 --- a/packages/kbn-esql-ast/src/ast_factory.ts +++ b/packages/kbn-esql-ast/src/ast_factory.ts @@ -53,7 +53,7 @@ import { visitDissect, visitGrok, collectBooleanExpression, - visitOrderExpression, + visitOrderExpressions, getPolicyName, getMatchField, getEnrichClauses, @@ -238,7 +238,7 @@ export class AstListener implements ESQLParserListener { exitSortCommand(ctx: SortCommandContext) { const command = createCommand('sort', ctx); this.ast.push(command); - command.args.push(...visitOrderExpression(ctx.orderExpression_list())); + command.args.push(...visitOrderExpressions(ctx.orderExpression_list())); } /** diff --git a/packages/kbn-esql-ast/src/ast_helpers.ts b/packages/kbn-esql-ast/src/ast_helpers.ts index 76f576f1ec019..7d4a94fde19a8 100644 --- a/packages/kbn-esql-ast/src/ast_helpers.ts +++ b/packages/kbn-esql-ast/src/ast_helpers.ts @@ -42,6 +42,7 @@ import type { ESQLNumericLiteralType, FunctionSubtype, ESQLNumericLiteral, + ESQLOrderExpression, } from './types'; import { parseIdentifier } from './parser/helpers'; @@ -222,6 +223,26 @@ export function createFunction( return node; } +export const createOrderExpression = ( + ctx: ParserRuleContext, + arg: ESQLAstItem, + order: ESQLOrderExpression['order'], + nulls: ESQLOrderExpression['nulls'] +) => { + const node: ESQLOrderExpression = { + type: 'order', + name: '', + order, + nulls, + args: [arg], + text: ctx.getText(), + location: getPosition(ctx.start, ctx.stop), + incomplete: Boolean(ctx.exception), + }; + + return node; +}; + function walkFunctionStructure( args: ESQLAstItem[], initialLocation: ESQLLocation, diff --git a/packages/kbn-esql-ast/src/ast_walker.ts b/packages/kbn-esql-ast/src/ast_walker.ts index 3599f2f5fabec..d57c4d1c64ae4 100644 --- a/packages/kbn-esql-ast/src/ast_walker.ts +++ b/packages/kbn-esql-ast/src/ast_walker.ts @@ -84,6 +84,7 @@ import { textExistsAndIsValid, createInlineCast, createUnknownItem, + createOrderExpression, } from './ast_helpers'; import { getPosition } from './ast_position_utils'; import { @@ -97,6 +98,7 @@ import { ESQLUnnamedParamLiteral, ESQLPositionalParamLiteral, ESQLNamedParamLiteral, + ESQLOrderExpression, } from './types'; export function collectAllSourceIdentifiers(ctx: FromCommandContext): ESQLAstItem[] { @@ -608,34 +610,43 @@ export function visitByOption( return [option]; } -export function visitOrderExpression(ctx: OrderExpressionContext[]) { - const ast: ESQLAstItem[] = []; - for (const orderCtx of ctx) { - const expression = collectBooleanExpression(orderCtx.booleanExpression()); - if (orderCtx._ordering) { - const terminalNode = - orderCtx.getToken(esql_parser.ASC, 0) || orderCtx.getToken(esql_parser.DESC, 0); - const literal = createLiteral('string', terminalNode); - if (literal) { - expression.push(literal); - } - } - if (orderCtx.NULLS()) { - expression.push(createLiteral('string', orderCtx.NULLS()!)!); - if (orderCtx._nullOrdering && orderCtx._nullOrdering.text !== '') { - const innerTerminalNode = - orderCtx.getToken(esql_parser.FIRST, 0) || orderCtx.getToken(esql_parser.LAST, 0); - const literal = createLiteral('string', innerTerminalNode); - if (literal) { - expression.push(literal); - } - } - } +const visitOrderExpression = (ctx: OrderExpressionContext): ESQLOrderExpression | ESQLAstItem => { + const arg = collectBooleanExpression(ctx.booleanExpression())[0]; - if (expression.length) { - ast.push(...expression); - } + 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, order, nulls); +}; + +export function visitOrderExpressions( + ctx: OrderExpressionContext[] +): Array { + const ast: Array = []; + + for (const orderCtx of ctx) { + ast.push(visitOrderExpression(orderCtx)); + } + return ast; } diff --git a/packages/kbn-esql-ast/src/pretty_print/__tests__/basic_pretty_printer.test.ts b/packages/kbn-esql-ast/src/pretty_print/__tests__/basic_pretty_printer.test.ts index 3b4734da9d45f..caf8c55dba3e0 100644 --- a/packages/kbn-esql-ast/src/pretty_print/__tests__/basic_pretty_printer.test.ts +++ b/packages/kbn-esql-ast/src/pretty_print/__tests__/basic_pretty_printer.test.ts @@ -50,18 +50,22 @@ describe('single line query', () => { expect(text).toBe('FROM a | SORT b'); }); - /** @todo Enable once order expressions are supported. */ - test.skip('order expression with ASC modifier', () => { + test('order expression with ASC modifier', () => { const { text } = reprint('FROM a | SORT b ASC'); expect(text).toBe('FROM a | SORT b ASC'); }); - /** @todo Enable once order expressions are supported. */ - test.skip('order expression with ASC and NULLS FIRST modifier', () => { - const { text } = reprint('FROM a | SORT b ASC NULLS FIRST'); + test('order expression with NULLS LAST modifier', () => { + const { text } = reprint('FROM a | SORT b NULLS LAST'); - expect(text).toBe('FROM a | SORT b ASC NULLS FIRST'); + expect(text).toBe('FROM a | SORT b NULLS LAST'); + }); + + test('order expression with DESC and NULLS FIRST modifier', () => { + const { text } = reprint('FROM a | SORT b DESC NULLS FIRST'); + + expect(text).toBe('FROM a | SORT b DESC NULLS FIRST'); }); }); diff --git a/packages/kbn-esql-ast/src/pretty_print/basic_pretty_printer.ts b/packages/kbn-esql-ast/src/pretty_print/basic_pretty_printer.ts index 6c190dcd3c5d9..1aa3d492c7583 100644 --- a/packages/kbn-esql-ast/src/pretty_print/basic_pretty_printer.ts +++ b/packages/kbn-esql-ast/src/pretty_print/basic_pretty_printer.ts @@ -133,7 +133,7 @@ export class BasicPrettyPrinter { : word.toUpperCase(); } - protected readonly visitor = new Visitor() + protected readonly visitor: Visitor = new Visitor() .on('visitExpression', (ctx) => { return ''; }) @@ -229,6 +229,21 @@ export class BasicPrettyPrinter { return `${ctx.visitArgument(0)} ${this.keyword('AS')} ${ctx.visitArgument(1)}`; }) + .on('visitOrderExpression', (ctx) => { + const node = ctx.node; + let text = ctx.visitArgument(0); + + if (node.order) { + text += ` ${node.order}`; + } + + if (node.nulls) { + text += ` ${node.nulls}`; + } + + return text; + }) + .on('visitCommandOption', (ctx) => { const opts = this.opts; const option = opts.lowercaseOptions ? ctx.node.name : ctx.node.name.toUpperCase(); @@ -281,14 +296,14 @@ export class BasicPrettyPrinter { }); public print(query: ESQLAstQueryNode) { - return this.visitor.visitQuery(query); + return this.visitor.visitQuery(query, undefined); } public printCommand(command: ESQLAstCommand) { - return this.visitor.visitCommand(command); + return this.visitor.visitCommand(command, undefined); } public printExpression(expression: ESQLAstExpressionNode) { - return this.visitor.visitExpression(expression); + return this.visitor.visitExpression(expression, undefined); } } diff --git a/packages/kbn-esql-ast/src/types.ts b/packages/kbn-esql-ast/src/types.ts index da42ec24bd69b..e98057258ee61 100644 --- a/packages/kbn-esql-ast/src/types.ts +++ b/packages/kbn-esql-ast/src/types.ts @@ -26,6 +26,7 @@ export type ESQLSingleAstItem = | ESQLLiteral // "literal expression" | ESQLCommandMode | ESQLInlineCast // "inline cast expression" + | ESQLOrderExpression | ESQLUnknownItem; export type ESQLAstField = ESQLFunction | ESQLColumn; @@ -135,11 +136,26 @@ export interface ESQLUnaryExpression extends ESQLFunction<'unary-expression'> { args: [ESQLAstItem]; } -export interface ESQLPostfixUnaryExpression extends ESQLFunction<'postfix-unary-expression'> { +export interface ESQLPostfixUnaryExpression + extends ESQLFunction<'postfix-unary-expression', Name> { subtype: 'postfix-unary-expression'; args: [ESQLAstItem]; } +/** + * Represents an order expression used in SORT commands. + * + * ``` + * ... | SORT field ASC NULLS FIRST + * ``` + */ +export interface ESQLOrderExpression extends ESQLAstBaseItem { + type: 'order'; + order: '' | 'ASC' | 'DESC'; + nulls: '' | 'NULLS FIRST' | 'NULLS LAST'; + args: [field: ESQLAstItem]; +} + export interface ESQLBinaryExpression extends ESQLFunction<'binary-expression', BinaryExpressionOperator> { subtype: 'binary-expression'; diff --git a/packages/kbn-esql-ast/src/visitor/contexts.ts b/packages/kbn-esql-ast/src/visitor/contexts.ts index 2cedf0d6ba8a3..c646b7f446227 100644 --- a/packages/kbn-esql-ast/src/visitor/contexts.ts +++ b/packages/kbn-esql-ast/src/visitor/contexts.ts @@ -26,6 +26,7 @@ import type { ESQLIntegerLiteral, ESQLList, ESQLLiteral, + ESQLOrderExpression, ESQLSingleAstItem, ESQLSource, ESQLTimeInterval, @@ -543,3 +544,8 @@ export class RenameExpressionVisitorContext< Methods extends VisitorMethods = VisitorMethods, Data extends SharedData = SharedData > extends VisitorContext {} + +export class OrderExpressionVisitorContext< + Methods extends VisitorMethods = VisitorMethods, + Data extends SharedData = SharedData +> extends VisitorContext {} diff --git a/packages/kbn-esql-ast/src/visitor/global_visitor_context.ts b/packages/kbn-esql-ast/src/visitor/global_visitor_context.ts index 8260776cca2f5..793803bc48f54 100644 --- a/packages/kbn-esql-ast/src/visitor/global_visitor_context.ts +++ b/packages/kbn-esql-ast/src/visitor/global_visitor_context.ts @@ -16,6 +16,7 @@ import type { ESQLInlineCast, ESQLList, ESQLLiteral, + ESQLOrderExpression, ESQLSource, ESQLTimeInterval, } from '../types'; @@ -400,6 +401,10 @@ export class GlobalVisitorContext< if (!this.methods.visitInlineCastExpression) break; return this.visitInlineCastExpression(parent, expressionNode, input as any); } + case 'order': { + if (!this.methods.visitOrderExpression) break; + return this.visitOrderExpression(parent, expressionNode, input as any); + } case 'option': { switch (expressionNode.name) { case 'as': { @@ -487,4 +492,13 @@ export class GlobalVisitorContext< const context = new contexts.RenameExpressionVisitorContext(this, node, parent); return this.visitWithSpecificContext('visitRenameExpression', context, input); } + + public visitOrderExpression( + parent: contexts.VisitorContext | null, + node: ESQLOrderExpression, + input: types.VisitorInput + ): types.VisitorOutput { + const context = new contexts.OrderExpressionVisitorContext(this, node, parent); + return this.visitWithSpecificContext('visitOrderExpression', context, input); + } } diff --git a/packages/kbn-esql-ast/src/visitor/types.ts b/packages/kbn-esql-ast/src/visitor/types.ts index 28259fb1cbaf4..c5b18a727bc3c 100644 --- a/packages/kbn-esql-ast/src/visitor/types.ts +++ b/packages/kbn-esql-ast/src/visitor/types.ts @@ -61,7 +61,8 @@ export type ExpressionVisitorInput = AnyToVoid< VisitorInput & VisitorInput & VisitorInput & - VisitorInput + VisitorInput & + VisitorInput >; /** @@ -76,7 +77,8 @@ export type ExpressionVisitorOutput = | VisitorOutput | VisitorOutput | VisitorOutput - | VisitorOutput; + | VisitorOutput + | VisitorOutput; /** * Input that satisfies any command visitor input constraints. @@ -203,6 +205,7 @@ export interface VisitorMethods< any, any >; + visitOrderExpression?: Visitor, any, any>; } /** diff --git a/packages/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/autocomplete.command.sort.test.ts b/packages/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/autocomplete.command.sort.test.ts new file mode 100644 index 0000000000000..924790ed470f5 --- /dev/null +++ b/packages/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/autocomplete.command.sort.test.ts @@ -0,0 +1,106 @@ +/* + * 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 { setup, getFieldNamesByType } from './helpers'; + +describe('autocomplete.suggest', () => { + describe('SORT ( [ ASC / DESC ] [ NULLS FIST / NULLS LAST ] )+', () => { + describe('SORT ...', () => { + test('suggests command on first character', async () => { + const { assertSuggestions } = await setup(); + + await assertSuggestions( + 'from a | sort /', + [...getFieldNamesByType('any')].map((field) => `${field} `) + ); + await assertSuggestions( + 'from a | sort column, /', + [...getFieldNamesByType('any')].map((field) => `${field} `) + ); + }); + }); + + describe('... [ ASC / DESC ] ...', () => { + test('suggests all modifiers on first space', async () => { + const { assertSuggestions } = await setup(); + + await assertSuggestions('from a | sort stringField /', [ + 'ASC ', + 'DESC ', + 'NULLS FIRST ', + 'NULLS LAST ', + ',', + '| ', + ]); + }); + + test('when user starts to type ASC modifier', async () => { + const { assertSuggestions } = await setup(); + + await assertSuggestions('from a | sort stringField A/', ['ASC ']); + }); + + test('when user starts to type DESC modifier', async () => { + const { assertSuggestions } = await setup(); + + await assertSuggestions('from a | sort stringField d/', ['DESC ']); + await assertSuggestions('from a | sort stringField des/', ['DESC ']); + await assertSuggestions('from a | sort stringField DES/', ['DESC ']); + }); + }); + + describe('... [ NULLS FIST / NULLS LAST ]', () => { + test('suggests command on first character', async () => { + const { assertSuggestions } = await setup(); + + await assertSuggestions('from a | sort stringField ASC /', [ + 'NULLS FIRST ', + 'NULLS LAST ', + ',', + '| ', + ]); + }); + + test('when user starts to type NULLS modifiers', async () => { + const { assertSuggestions } = await setup(); + + await assertSuggestions('from a | sort stringField N/', ['NULLS FIRST ', 'NULLS LAST ']); + await assertSuggestions('from a | sort stringField null/', ['NULLS FIRST ', 'NULLS LAST ']); + await assertSuggestions('from a | sort stringField nulls/', [ + 'NULLS FIRST ', + 'NULLS LAST ', + ]); + await assertSuggestions('from a | sort stringField nulls /', [ + 'NULLS FIRST ', + 'NULLS LAST ', + ]); + }); + + test('when user types NULLS FIRST', async () => { + const { assertSuggestions } = await setup(); + + await assertSuggestions('from a | sort stringField NULLS F/', ['NULLS FIRST ']); + await assertSuggestions('from a | sort stringField NULLS FI/', ['NULLS FIRST ']); + }); + + test('when user types NULLS LAST', async () => { + const { assertSuggestions } = await setup(); + + await assertSuggestions('from a | sort stringField NULLS L/', ['NULLS LAST ']); + await assertSuggestions('from a | sort stringField NULLS LAS/', ['NULLS LAST ']); + }); + + test('after nulls are entered, suggests comma or pipe', async () => { + const { assertSuggestions } = await setup(); + + await assertSuggestions('from a | sort stringField NULLS LAST /', [',', '| ']); + }); + }); + }); +}); diff --git a/packages/kbn-esql-validation-autocomplete/src/autocomplete/autocomplete.test.ts b/packages/kbn-esql-validation-autocomplete/src/autocomplete/autocomplete.test.ts index a58a55f124c4e..2a8c8e53fcab7 100644 --- a/packages/kbn-esql-validation-autocomplete/src/autocomplete/autocomplete.test.ts +++ b/packages/kbn-esql-validation-autocomplete/src/autocomplete/autocomplete.test.ts @@ -330,22 +330,6 @@ describe('autocomplete', () => { testSuggestions('from a | dissect keywordField/', []); }); - describe('sort', () => { - testSuggestions('from a | sort /', [ - ...getFieldNamesByType('any').map((name) => `${name} `), - ...getFunctionSignaturesByReturnType('sort', 'any', { scalar: true }), - ]); - testSuggestions('from a | sort keywordField /', ['ASC ', 'DESC ', ',', '| ']); - testSuggestions('from a | sort keywordField desc /', [ - 'NULLS FIRST ', - 'NULLS LAST ', - ',', - '| ', - ]); - // @TODO: improve here - // testSuggestions('from a | sort keywordField desc ', ['first', 'last']); - }); - describe('limit', () => { testSuggestions('from a | limit /', ['10 ', '100 ', '1000 ']); testSuggestions('from a | limit 4 /', ['| ']); @@ -672,23 +656,6 @@ describe('autocomplete', () => { // RENAME field AS var0 testSuggestions('FROM index1 | RENAME field AS v/', ['var0']); - // SORT field - testSuggestions('FROM index1 | SORT f/', [ - ...getFunctionSignaturesByReturnType('sort', 'any', { scalar: true }), - ...getFieldNamesByType('any').map((field) => `${field} `), - ]); - - // SORT field order - testSuggestions('FROM index1 | SORT keywordField a/', ['ASC ', 'DESC ', ',', '| ']); - - // SORT field order nulls - testSuggestions('FROM index1 | SORT keywordField ASC n/', [ - 'NULLS FIRST ', - 'NULLS LAST ', - ',', - '| ', - ]); - // STATS argument testSuggestions('FROM index1 | STATS f/', [ 'var0 = ', @@ -1015,27 +982,6 @@ describe('autocomplete', () => { // LIMIT number testSuggestions('FROM a | LIMIT /', ['10 ', '100 ', '1000 '].map(attachTriggerCommand)); - // SORT field - testSuggestions( - 'FROM a | SORT /', - [ - ...getFieldNamesByType('any').map((field) => `${field} `), - ...getFunctionSignaturesByReturnType('sort', 'any', { scalar: true }), - ].map(attachTriggerCommand) - ); - - // SORT field order - testSuggestions('FROM a | SORT field /', [ - ',', - ...['ASC ', 'DESC ', '| '].map(attachTriggerCommand), - ]); - - // SORT field order nulls - testSuggestions('FROM a | SORT field ASC /', [ - ',', - ...['NULLS FIRST ', 'NULLS LAST ', '| '].map(attachTriggerCommand), - ]); - // STATS argument testSuggestions( 'FROM a | STATS /', @@ -1266,22 +1212,6 @@ describe('autocomplete', () => { '>= $0', 'IN $0', ]); - testSuggestions('FROM a | SORT doubleField IS NOT N/', [ - { text: 'IS NOT NULL', rangeToReplace: { start: 27, end: 34 } }, - 'IS NULL', - '% $0', - '* $0', - '+ $0', - '- $0', - '/ $0', - '!= $0', - '< $0', - '<= $0', - '== $0', - '> $0', - '>= $0', - 'IN $0', - ]); describe('dot-separated field names', () => { testSuggestions( 'FROM a | KEEP field.nam/', diff --git a/packages/kbn-esql-validation-autocomplete/src/autocomplete/autocomplete.ts b/packages/kbn-esql-validation-autocomplete/src/autocomplete/autocomplete.ts index 2a3d676507086..a6ee324fd2621 100644 --- a/packages/kbn-esql-validation-autocomplete/src/autocomplete/autocomplete.ts +++ b/packages/kbn-esql-validation-autocomplete/src/autocomplete/autocomplete.ts @@ -102,6 +102,7 @@ import { removeQuoteForSuggestedSources, getValidSignaturesAndTypesToSuggestNext, } from './helper'; +import { getSortPos } from './commands/sort/helper'; import { FunctionParameter, FunctionReturnType, @@ -193,6 +194,10 @@ export async function suggest( } if (astContext.type === 'expression') { + if (astContext.command.name === 'sort') { + return await suggestForSortCmd(innerText, getFieldsByType); + } + // suggest next possible argument, or option // otherwise a variable return getExpressionSuggestionsByType( @@ -1840,3 +1845,100 @@ async function getOptionArgsSuggestions( } return suggestions; } + +const sortModifierSuggestions = { + ASC: { + label: 'ASC', + text: 'ASC ', + detail: '', + kind: 'Keyword', + sortText: '1-ASC', + command: TRIGGER_SUGGESTION_COMMAND, + } as SuggestionRawDefinition, + DESC: { + label: 'DESC', + text: 'DESC ', + detail: '', + kind: 'Keyword', + sortText: '1-DESC', + command: TRIGGER_SUGGESTION_COMMAND, + } as SuggestionRawDefinition, + NULLS_FIRST: { + label: 'NULLS FIRST', + text: 'NULLS FIRST ', + detail: '', + kind: 'Keyword', + sortText: '2-NULLS FIRST', + command: TRIGGER_SUGGESTION_COMMAND, + } as SuggestionRawDefinition, + NULLS_LAST: { + label: 'NULLS LAST', + text: 'NULLS LAST ', + detail: '', + kind: 'Keyword', + sortText: '2-NULLS LAST', + command: TRIGGER_SUGGESTION_COMMAND, + } as SuggestionRawDefinition, +}; + +export const suggestForSortCmd = async (innerText: string, getFieldsByType: GetFieldsByTypeFn) => { + const { pos, order, nulls } = getSortPos(innerText); + + switch (pos) { + case 'space2': { + return [ + sortModifierSuggestions.ASC, + sortModifierSuggestions.DESC, + sortModifierSuggestions.NULLS_FIRST, + sortModifierSuggestions.NULLS_LAST, + ...getFinalSuggestions({ + comma: true, + }), + ]; + } + case 'order': { + const suggestions: SuggestionRawDefinition[] = []; + for (const modifier of Object.values(sortModifierSuggestions)) { + if (modifier.label.startsWith(order)) { + suggestions.push(modifier); + } + } + return suggestions; + } + case 'space3': { + return [ + sortModifierSuggestions.NULLS_FIRST, + sortModifierSuggestions.NULLS_LAST, + ...getFinalSuggestions({ + comma: true, + }), + ]; + } + case 'nulls': { + const end = innerText.length + 1; + const start = end - nulls.length; + const suggestions: SuggestionRawDefinition[] = []; + for (const modifier of Object.values(sortModifierSuggestions)) { + if (modifier.label.startsWith(nulls)) { + suggestions.push({ + ...modifier, + rangeToReplace: { + start, + end, + }, + }); + } + } + return suggestions; + } + case 'space4': { + return [ + ...getFinalSuggestions({ + comma: true, + }), + ]; + } + } + + return (await getFieldsByType('any', [], { advanceCursor: true })) as SuggestionRawDefinition[]; +}; diff --git a/packages/kbn-esql-validation-autocomplete/src/autocomplete/commands/sort/helper.test.ts b/packages/kbn-esql-validation-autocomplete/src/autocomplete/commands/sort/helper.test.ts new file mode 100644 index 0000000000000..82059b6b7765c --- /dev/null +++ b/packages/kbn-esql-validation-autocomplete/src/autocomplete/commands/sort/helper.test.ts @@ -0,0 +1,55 @@ +/* + * 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 { getSortPos } from './helper'; + +test('returns correct position on complete modifier matches', () => { + expect(getSortPos('from a | ').pos).toBe('none'); + expect(getSortPos('from a | s').pos).toBe('pre-start'); + expect(getSortPos('from a | so').pos).toBe('pre-start'); + expect(getSortPos('from a | sor').pos).toBe('pre-start'); + expect(getSortPos('from a | sort').pos).toBe('start'); + expect(getSortPos('from a | sort ').pos).toBe('space1'); + expect(getSortPos('from a | sort col').pos).toBe('column'); + expect(getSortPos('from a | sort col ').pos).toBe('space2'); + expect(getSortPos('from a | sort col ASC').pos).toBe('order'); + expect(getSortPos('from a | sort col DESC ').pos).toBe('space3'); + expect(getSortPos('from a | sort col DESC NULLS FIRST').pos).toBe('nulls'); + expect(getSortPos('from a | sort col DESC NULLS LAST ').pos).toBe('space4'); + expect(getSortPos('from a | sort col DESC NULLS LAST, ').pos).toBe('space1'); + expect(getSortPos('from a | sort col DESC NULLS LAST, col2').pos).toBe('column'); + expect(getSortPos('from a | sort col DESC NULLS LAST, col2 DESC').pos).toBe('order'); + expect(getSortPos('from a | sort col DESC NULLS LAST, col2 NULLS LAST').pos).toBe('nulls'); + expect(getSortPos('from a | sort col DESC NULLS LAST, col2 NULLS LAST ').pos).toBe('space4'); +}); + +test('returns ASC/DESC matched text', () => { + expect(getSortPos('from a | sort col ASC').pos).toBe('order'); + expect(getSortPos('from a | sort col asc').order).toBe('ASC'); + + expect(getSortPos('from a | sort col as').pos).toBe('order'); + expect(getSortPos('from a | sort col as').order).toBe('AS'); + + expect(getSortPos('from a | sort col DE').pos).toBe('order'); + expect(getSortPos('from a | sort col DE').order).toBe('DE'); +}); + +test('returns NULLS FIRST/NULLS LAST matched text', () => { + expect(getSortPos('from a | sort col ASC NULLS FIRST').pos).toBe('nulls'); + expect(getSortPos('from a | sort col ASC NULLS FIRST').nulls).toBe('NULLS FIRST'); + + expect(getSortPos('from a | sort col ASC nulls fi').pos).toBe('nulls'); + expect(getSortPos('from a | sort col ASC nulls fi').nulls).toBe('NULLS FI'); + + expect(getSortPos('from a | sort col nul').pos).toBe('nulls'); + expect(getSortPos('from a | sort col nul').nulls).toBe('NUL'); + + expect(getSortPos('from a | sort col1, col2 NULLS LA').pos).toBe('nulls'); + expect(getSortPos('from a | sort col1, col2 NULLS LA').nulls).toBe('NULLS LA'); +}); diff --git a/packages/kbn-esql-validation-autocomplete/src/autocomplete/commands/sort/helper.ts b/packages/kbn-esql-validation-autocomplete/src/autocomplete/commands/sort/helper.ts new file mode 100644 index 0000000000000..dfa5ce0f4d5f7 --- /dev/null +++ b/packages/kbn-esql-validation-autocomplete/src/autocomplete/commands/sort/helper.ts @@ -0,0 +1,84 @@ +/* + * 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". + */ + +const regexStart = /.+\|\s*so?r?(?t?)(.+,)?(?\s+)?/i; +const regex = + /.+\|\s*sort(.+,)?((?\s+)(?[^\s]+)(?\s*)(?(AS?C?)|(DE?S?C?))?(?\s*)(?NU?L?L?S? ?(FI?R?S?T?|LA?S?T?)?)?(?\s*))?/i; + +export interface SortCaretPosition { + /** + * Position of the caret in the sort command: + * + * ``` + * SORT [ASC/DESC] [NULLS FIRST/NULLS LAST] + * | | | | | | | | + * | | | | | | | space4 + * | | | | | | nulls + * | | | | | space3 + * | | | | order + * | | | space 2 + * | | | + * | | column + * | start + * pre-start + * ``` + */ + pos: + | 'none' + | 'pre-start' + | 'start' + | 'space1' + | 'column' + | 'space2' + | 'order' + | 'space3' + | 'nulls' + | 'space4'; + order: string; + nulls: string; +} + +export const getSortPos = (query: string): SortCaretPosition => { + const match = query.match(regex); + let pos: SortCaretPosition['pos'] = 'none'; + let order: SortCaretPosition['order'] = ''; + let nulls: SortCaretPosition['nulls'] = ''; + + if (match?.groups?.space4) { + pos = 'space4'; + } else if (match?.groups?.nulls) { + pos = 'nulls'; + nulls = match.groups.nulls.toUpperCase(); + } else if (match?.groups?.space3) { + pos = 'space3'; + } else if (match?.groups?.order) { + pos = 'order'; + order = match.groups.order.toUpperCase(); + } else if (match?.groups?.space2) { + pos = 'space2'; + } else if (match?.groups?.column) { + pos = 'column'; + } else { + const match2 = query.match(regexStart); + + if (match2?.groups?.space1) { + pos = 'space1'; + } else if (match2?.groups?.start) { + pos = 'start'; + } else if (match2) { + pos = 'pre-start'; + } + } + + return { + pos, + order, + nulls, + }; +}; diff --git a/packages/kbn-esql-validation-autocomplete/src/definitions/builtin.ts b/packages/kbn-esql-validation-autocomplete/src/definitions/builtin.ts index d2ff04e4d9baa..c59daa2130417 100644 --- a/packages/kbn-esql-validation-autocomplete/src/definitions/builtin.ts +++ b/packages/kbn-esql-validation-autocomplete/src/definitions/builtin.ts @@ -641,6 +641,20 @@ const otherDefinitions: FunctionDefinition[] = [ }, ], }, + { + name: 'order-expression', + type: 'builtin', + description: i18n.translate('kbn-esql-validation-autocomplete.esql.definition.infoDoc', { + defaultMessage: 'Specify column sorting modifiers', + }), + supportedCommands: ['sort'], + signatures: [ + { + params: [{ name: 'column', type: 'any' }], + returnType: 'void', + }, + ], + }, ]; export const builtinFunctions: FunctionDefinition[] = [ diff --git a/packages/kbn-esql-validation-autocomplete/src/definitions/commands.ts b/packages/kbn-esql-validation-autocomplete/src/definitions/commands.ts index 349bfcf4a358a..979e718fb4174 100644 --- a/packages/kbn-esql-validation-autocomplete/src/definitions/commands.ts +++ b/packages/kbn-esql-validation-autocomplete/src/definitions/commands.ts @@ -383,11 +383,7 @@ export const commandDefinitions: CommandDefinition[] = [ modes: [], signature: { multipleParams: true, - params: [ - { name: 'expression', type: 'any' }, - { name: 'direction', type: 'string', optional: true, values: ['ASC', 'DESC'] }, - { name: 'nulls', type: 'string', optional: true, values: ['NULLS FIRST', 'NULLS LAST'] }, - ], + params: [{ name: 'expression', type: 'any' }], }, }, { diff --git a/packages/kbn-esql-validation-autocomplete/src/shared/context.ts b/packages/kbn-esql-validation-autocomplete/src/shared/context.ts index 22429e1ff9cb7..0f7f830c1417a 100644 --- a/packages/kbn-esql-validation-autocomplete/src/shared/context.ts +++ b/packages/kbn-esql-validation-autocomplete/src/shared/context.ts @@ -35,7 +35,7 @@ function findNode(nodes: ESQLAstItem[], offset: number): ESQLSingleAstItem | und return ret; } } else { - if (node.location.min <= offset && node.location.max >= offset) { + if (node && node.location && node.location.min <= offset && node.location.max >= offset) { if ('args' in node) { const ret = findNode(node.args, offset); // if the found node is the marker, then return its parent