diff --git a/packages/kbn-esql-ast/src/__tests__/ast_parser.literal.test.ts b/packages/kbn-esql-ast/src/__tests__/ast_parser.literal.test.ts index 1f941c49f0fbe..cc03bd3db0a9d 100644 --- a/packages/kbn-esql-ast/src/__tests__/ast_parser.literal.test.ts +++ b/packages/kbn-esql-ast/src/__tests__/ast_parser.literal.test.ts @@ -22,4 +22,28 @@ describe('literal expression', () => { value: 1, }); }); + + it('decimals vs integers', () => { + const text = 'ROW a(1.0, 1)'; + const { ast } = parse(text); + + expect(ast[0]).toMatchObject({ + type: 'command', + args: [ + { + type: 'function', + args: [ + { + type: 'literal', + literalType: 'decimal', + }, + { + type: 'literal', + literalType: 'integer', + }, + ], + }, + ], + }); + }); }); diff --git a/packages/kbn-esql-ast/src/__tests__/ast_parser.rename.test.ts b/packages/kbn-esql-ast/src/__tests__/ast_parser.rename.test.ts new file mode 100644 index 0000000000000..0ad62b59b9cb8 --- /dev/null +++ b/packages/kbn-esql-ast/src/__tests__/ast_parser.rename.test.ts @@ -0,0 +1,26 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +import { getAstAndSyntaxErrors as parse } from '../ast_parser'; + +describe('RENAME', () => { + /** + * Enable this test once RENAME commands are fixed: + * https://github.com/elastic/kibana/discussions/182393#discussioncomment-10313313 + */ + it.skip('example from documentation', () => { + const text = ` +FROM kibana_sample_data_logs +| RENAME total_visits as \`Unique Visits (Total)\`, +`; + const { ast } = parse(text); + + // eslint-disable-next-line no-console + console.log(JSON.stringify(ast, null, 2)); + }); +}); diff --git a/packages/kbn-esql-ast/src/ast/constants.ts b/packages/kbn-esql-ast/src/ast/constants.ts new file mode 100644 index 0000000000000..ee78109f7fc94 --- /dev/null +++ b/packages/kbn-esql-ast/src/ast/constants.ts @@ -0,0 +1,19 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +/** + * The group name of a binary expression. Groups are ordered by precedence. + */ +export enum BinaryExpressionGroup { + unknown = 0, + additive = 10, + multiplicative = 20, + assignment = 30, + comparison = 40, + regex = 50, +} diff --git a/packages/kbn-esql-ast/src/ast/helpers.ts b/packages/kbn-esql-ast/src/ast/helpers.ts new file mode 100644 index 0000000000000..a7961a9ed01b9 --- /dev/null +++ b/packages/kbn-esql-ast/src/ast/helpers.ts @@ -0,0 +1,69 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +import { ESQLAstNode, ESQLBinaryExpression, ESQLFunction } from '../types'; +import { BinaryExpressionGroup } from './constants'; + +export const isFunctionExpression = (node: unknown): node is ESQLFunction => + !!node && typeof node === 'object' && !Array.isArray(node) && (node as any).type === 'function'; + +/** + * Returns true if the given node is a binary expression, i.e. an operator + * surrounded by two operands: + * + * ``` + * 1 + 1 + * column LIKE "foo" + * foo = "bar" + * ``` + * + * @param node Any ES|QL AST node. + */ +export const isBinaryExpression = (node: unknown): node is ESQLBinaryExpression => + isFunctionExpression(node) && node.subtype === 'binary-expression'; + +/** + * Returns the group of a binary expression: + * + * - `additive`: `+`, `-` + * - `multiplicative`: `*`, `/`, `%` + * - `assignment`: `=` + * - `comparison`: `==`, `=~`, `!=`, `<`, `<=`, `>`, `>=` + * - `regex`: `like`, `not_like`, `rlike`, `not_rlike` + * @param node Any ES|QL AST node. + * @returns Binary expression group or undefined if the node is not a binary expression. + */ +export const binaryExpressionGroup = (node: ESQLAstNode): BinaryExpressionGroup => { + if (isBinaryExpression(node)) { + switch (node.name) { + case '+': + case '-': + return BinaryExpressionGroup.additive; + case '*': + case '/': + case '%': + return BinaryExpressionGroup.multiplicative; + case '=': + return BinaryExpressionGroup.assignment; + case '==': + case '=~': + case '!=': + case '<': + case '<=': + case '>': + case '>=': + return BinaryExpressionGroup.comparison; + case 'like': + case 'not_like': + case 'rlike': + case 'not_rlike': + return BinaryExpressionGroup.regex; + } + } + return BinaryExpressionGroup.unknown; +}; diff --git a/packages/kbn-esql-ast/src/pretty_print/README.md b/packages/kbn-esql-ast/src/pretty_print/README.md new file mode 100644 index 0000000000000..48066697a5a7e --- /dev/null +++ b/packages/kbn-esql-ast/src/pretty_print/README.md @@ -0,0 +1,23 @@ +# Pretty-printing + +*Pretty-printing* is the process of converting an ES|QL AST into a +human-readable string. This is useful for debugging or for displaying +the AST to the user. + +This module provides a number of pretty-printing options. + + +## `BasicPrettyPrinter` + +The `BasicPrettyPrinter` class provides the most basic pretty-printing—it +prints a query to a single line. Or it can print a query with each command on +a separate line, with the ability to customize the indentation before the pipe +character. + +It can also print a single command to a single line; or an expression to a +single line. + +- `BasicPrettyPrinter.print()` — prints query to a single line. +- `BasicPrettyPrinter.multiline()` — prints a query to multiple lines. +- `BasicPrettyPrinter.command()` — prints a command to a single line. +- `BasicPrettyPrinter.expression()` — prints an expression to a single line. 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 new file mode 100644 index 0000000000000..0afb4e8e42ce4 --- /dev/null +++ b/packages/kbn-esql-ast/src/pretty_print/__tests__/basic_pretty_printer.test.ts @@ -0,0 +1,457 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +import { getAstAndSyntaxErrors } from '../../ast_parser'; +import { ESQLFunction } from '../../types'; +import { Walker } from '../../walker'; +import { BasicPrettyPrinter, BasicPrettyPrinterMultilineOptions } from '../basic_pretty_printer'; + +const reprint = (src: string) => { + const { ast } = getAstAndSyntaxErrors(src); + const text = BasicPrettyPrinter.print(ast); + + // console.log(JSON.stringify(ast, null, 2)); + + return { text }; +}; + +describe('single line query', () => { + describe('commands', () => { + describe('FROM', () => { + test('FROM command with a single source', () => { + const { text } = reprint('FROM index1'); + + expect(text).toBe('FROM index1'); + }); + + test('FROM command with multiple indices', () => { + const { text } = reprint('from index1, index2, index3'); + + expect(text).toBe('FROM index1, index2, index3'); + }); + + test('FROM command with METADATA', () => { + const { text } = reprint('FROM index1, index2 METADATA field1, field2'); + + expect(text).toBe('FROM index1, index2 METADATA field1, field2'); + }); + }); + + describe('SORT', () => { + test('order expression with no modifier', () => { + const { text } = reprint('FROM a | SORT b'); + + expect(text).toBe('FROM a | SORT b'); + }); + + /** @todo Enable once order expressions are supported. */ + test.skip('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'); + + expect(text).toBe('FROM a | SORT b ASC NULLS FIRST'); + }); + }); + + describe('EXPLAIN', () => { + /** @todo Enable once query expressions are supported. */ + test.skip('a nested query', () => { + const { text } = reprint('EXPLAIN [ FROM 1 ]'); + + expect(text).toBe('EXPLAIN [ FROM 1 ]'); + }); + }); + + describe('SHOW', () => { + /** @todo Enable once show command args are parsed as columns. */ + test.skip('info page', () => { + const { text } = reprint('SHOW info'); + + expect(text).toBe('SHOW info'); + }); + }); + + describe('META', () => { + /** @todo Enable once show command args are parsed as columns. */ + test.skip('functions page', () => { + const { text } = reprint('META functions'); + + expect(text).toBe('META functions'); + }); + }); + + describe('STATS', () => { + test('with aggregates assignment', () => { + const { text } = reprint('FROM a | STATS var = agg(123, fn(true))'); + + expect(text).toBe('FROM a | STATS var = AGG(123, FN(TRUE))'); + }); + + test('with BY clause', () => { + const { text } = reprint('FROM a | STATS a(1), b(2) by asdf'); + + expect(text).toBe('FROM a | STATS A(1), B(2) BY asdf'); + }); + }); + }); + + describe('expressions', () => { + describe('source expressions', () => { + test('simple source expression', () => { + const { text } = reprint('from source'); + + expect(text).toBe('FROM source'); + }); + + test('sources with dots', () => { + const { text } = reprint('FROM a.b.c'); + + expect(text).toBe('FROM a.b.c'); + }); + + test('sources with slashes', () => { + const { text } = reprint('FROM a/b/c'); + + expect(text).toBe('FROM a/b/c'); + }); + + test('cluster source', () => { + const { text } = reprint('FROM cluster:index'); + + expect(text).toBe('FROM cluster:index'); + }); + + test('quoted source', () => { + const { text } = reprint('FROM "quoted"'); + + expect(text).toBe('FROM quoted'); + }); + + test('triple-quoted source', () => { + const { text } = reprint('FROM """quoted"""'); + + expect(text).toBe('FROM quoted'); + }); + }); + + describe('column expressions', () => { + test('simple columns expressions', () => { + const { text } = reprint('FROM a METADATA column1, _column2'); + + expect(text).toBe('FROM a METADATA column1, _column2'); + }); + + // Un-skip when columns are parsed correctly: https://github.com/elastic/kibana/issues/189913 + test.skip('nested fields', () => { + const { text } = reprint('FROM a | KEEP a.b'); + + expect(text).toBe('FROM a | KEEP a.b'); + }); + + // Un-skip when columns are parsed correctly: https://github.com/elastic/kibana/issues/189913 + test.skip('quoted nested fields', () => { + const { text } = reprint('FROM index | KEEP `a`.`b`, c.`d`'); + + expect(text).toBe('FROM index | KEEP a.b, c.d'); + }); + + // Un-skip when identifier names are escaped correctly. + test.skip('special character in identifier', () => { + const { text } = reprint('FROM a | KEEP `a 👉 b`, a.`✅`'); + + expect(text).toBe('FROM a | KEEP `a 👉 b`, a.`✅`'); + }); + }); + + describe('"function" expressions', () => { + describe('function call expression', () => { + test('no argument function', () => { + const { text } = reprint('ROW fn()'); + + expect(text).toBe('ROW FN()'); + }); + + test('functions with arguments', () => { + const { text } = reprint('ROW gg(1), wp(1, 2, 3)'); + + expect(text).toBe('ROW GG(1), WP(1, 2, 3)'); + }); + + test('functions with star argument', () => { + const { text } = reprint('ROW f(*)'); + + expect(text).toBe('ROW F(*)'); + }); + }); + + describe('unary expression', () => { + test('NOT expression', () => { + const { text } = reprint('ROW NOT a'); + + expect(text).toBe('ROW NOT a'); + }); + }); + + describe('postfix unary expression', () => { + test('IS NOT NULL expression', () => { + const { text } = reprint('ROW a IS NOT NULL'); + + expect(text).toBe('ROW a IS NOT NULL'); + }); + }); + + describe('binary expression expression', () => { + test('arithmetic expression', () => { + const { text } = reprint('ROW 1 + 2'); + + expect(text).toBe('ROW 1 + 2'); + }); + + test('assignment expression', () => { + const { text } = reprint('FROM a | STATS a != 1'); + + expect(text).toBe('FROM a | STATS a != 1'); + }); + + test('regex expression - 1', () => { + const { text } = reprint('FROM a | WHERE a NOT RLIKE "a"'); + + expect(text).toBe('FROM a | WHERE a NOT RLIKE "a"'); + }); + + test('regex expression - 2', () => { + const { text } = reprint('FROM a | WHERE a LIKE "b"'); + + expect(text).toBe('FROM a | WHERE a LIKE "b"'); + }); + }); + }); + + describe('literals expressions', () => { + test('null', () => { + const { text } = reprint('ROW null'); + + expect(text).toBe('ROW NULL'); + }); + + test('boolean', () => { + expect(reprint('ROW true').text).toBe('ROW TRUE'); + expect(reprint('ROW false').text).toBe('ROW FALSE'); + }); + + describe('numeric literal', () => { + test('integer', () => { + const { text } = reprint('ROW 1'); + + expect(text).toBe('ROW 1'); + }); + + test('decimal', () => { + const { text } = reprint('ROW 1.2'); + + expect(text).toBe('ROW 1.2'); + }); + + test('rounded decimal', () => { + const { text } = reprint('ROW 1.0'); + + expect(text).toBe('ROW 1.0'); + }); + + test('string', () => { + const { text } = reprint('ROW "abc"'); + + expect(text).toBe('ROW "abc"'); + }); + + test('string w/ special chars', () => { + const { text } = reprint('ROW "as \\" 👍"'); + + expect(text).toBe('ROW "as \\" 👍"'); + }); + }); + + describe('params', () => { + test('unnamed', () => { + const { text } = reprint('ROW ?'); + + expect(text).toBe('ROW ?'); + }); + + test('named', () => { + const { text } = reprint('ROW ?kappa'); + + expect(text).toBe('ROW ?kappa'); + }); + + test('positional', () => { + const { text } = reprint('ROW ?42'); + + expect(text).toBe('ROW ?42'); + }); + }); + }); + + describe('list literal expressions', () => { + describe('integer list', () => { + test('one element list', () => { + expect(reprint('ROW [1]').text).toBe('ROW [1]'); + }); + + test('multiple elements', () => { + expect(reprint('ROW [1, 2]').text).toBe('ROW [1, 2]'); + expect(reprint('ROW [1, 2, -1]').text).toBe('ROW [1, 2, -1]'); + }); + }); + + describe('boolean list', () => { + test('one element list', () => { + expect(reprint('ROW [true]').text).toBe('ROW [TRUE]'); + }); + + test('multiple elements', () => { + expect(reprint('ROW [TRUE, false]').text).toBe('ROW [TRUE, FALSE]'); + expect(reprint('ROW [false, FALSE, false]').text).toBe('ROW [FALSE, FALSE, FALSE]'); + }); + }); + + describe('string list', () => { + test('one element list', () => { + expect(reprint('ROW ["a"]').text).toBe('ROW ["a"]'); + }); + + test('multiple elements', () => { + expect(reprint('ROW ["a", "b"]').text).toBe('ROW ["a", "b"]'); + expect(reprint('ROW ["foo", "42", "boden"]').text).toBe('ROW ["foo", "42", "boden"]'); + }); + }); + }); + + describe('cast expressions', () => { + test('various', () => { + expect(reprint('ROW a::string').text).toBe('ROW a::string'); + expect(reprint('ROW 123::string').text).toBe('ROW 123::string'); + expect(reprint('ROW "asdf"::number').text).toBe('ROW "asdf"::number'); + }); + + test('wraps into rackets complex cast expressions', () => { + expect(reprint('ROW (1 + 2)::string').text).toBe('ROW (1 + 2)::string'); + }); + + test('does not wrap function call', () => { + expect(reprint('ROW fn()::string').text).toBe('ROW FN()::string'); + }); + }); + + describe('time interval expression', () => { + test('days', () => { + const { text } = reprint('ROW 1 d'); + + expect(text).toBe('ROW 1d'); + }); + + test('years', () => { + const { text } = reprint('ROW 42y'); + + expect(text).toBe('ROW 42y'); + }); + }); + }); +}); + +describe('multiline query', () => { + const multiline = (src: string, opts?: BasicPrettyPrinterMultilineOptions) => { + const { ast } = getAstAndSyntaxErrors(src); + const text = BasicPrettyPrinter.multiline(ast, opts); + + // console.log(JSON.stringify(ast, null, 2)); + + return { text }; + }; + + test('can print the query on multiple lines', () => { + const { text } = multiline('FROM index1 | SORT asdf | WHERE a == 1 | LIMIT 123'); + + expect(text).toBe(`FROM index1 + | SORT asdf + | WHERE a == 1 + | LIMIT 123`); + }); + + test('can customize tabbing before pipe', () => { + const query = 'FROM index1 | SORT asdf | WHERE a == 1 | LIMIT 123'; + const text1 = multiline(query, { pipeTab: '' }).text; + const text2 = multiline(query, { pipeTab: '\t' }).text; + + expect(text1).toBe(`FROM index1 +| SORT asdf +| WHERE a == 1 +| LIMIT 123`); + + expect(text2).toBe(`FROM index1 +\t| SORT asdf +\t| WHERE a == 1 +\t| LIMIT 123`); + }); + + test('large query', () => { + const query = `FROM employees, kibana_sample_data_flights, kibana_sample_data_logs, kibana_sample_data_ecommerce +| EVAL hired = DATE_FORMAT("YYYY-MM-DD", hired, "Europe/Amsterdam") +| STATS avg_salary = AVG(salary) BY hired, languages, department, dream_salary > 100000 +| EVAL avg_salary = ROUND(avg_salary) +| SORT hired, languages +| LIMIT 100`; + const text1 = multiline(query, { pipeTab: '' }).text; + + expect(text1).toBe(query); + }); +}); + +describe('single line command', () => { + test('can print an individual command', () => { + const query = `FROM employees, kibana_sample_data_flights, kibana_sample_data_logs, kibana_sample_data_ecommerce + | EVAL hired = DATE_FORMAT("YYYY-MM-DD", hired, "Europe/Amsterdam") + | STATS avg_salary = AVG(salary) BY hired, languages, department, dream_salary > 100000 + | EVAL avg_salary = ROUND(avg_salary) + | SORT hired, languages + | LIMIT 100`; + const { ast: commands } = getAstAndSyntaxErrors(query); + const line1 = BasicPrettyPrinter.command(commands[0]); + const line2 = BasicPrettyPrinter.command(commands[1]); + const line3 = BasicPrettyPrinter.command(commands[2]); + + expect(line1).toBe( + 'FROM employees, kibana_sample_data_flights, kibana_sample_data_logs, kibana_sample_data_ecommerce' + ); + expect(line2).toBe('EVAL hired = DATE_FORMAT("YYYY-MM-DD", hired, "Europe/Amsterdam")'); + expect(line3).toBe( + 'STATS avg_salary = AVG(salary) BY hired, languages, department, dream_salary > 100000' + ); + }); +}); + +describe('single line expression', () => { + test('can print a single expression', () => { + const query = `FROM a | STATS a != 1, avg(1, 2, 3)`; + const { ast } = getAstAndSyntaxErrors(query); + const comparison = Walker.match(ast, { type: 'function', name: '!=' })! as ESQLFunction; + const func = Walker.match(ast, { type: 'function', name: 'avg' })! as ESQLFunction; + + const text1 = BasicPrettyPrinter.expression(comparison); + const text2 = BasicPrettyPrinter.expression(func); + + expect(text1).toBe('a != 1'); + expect(text2).toBe('AVG(1, 2, 3)'); + }); +}); diff --git a/packages/kbn-esql-ast/src/pretty_print/__tests__/fixtures.ts b/packages/kbn-esql-ast/src/pretty_print/__tests__/fixtures.ts new file mode 100644 index 0000000000000..f50bd546fde6c --- /dev/null +++ b/packages/kbn-esql-ast/src/pretty_print/__tests__/fixtures.ts @@ -0,0 +1,66 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +export const query1 = ` +from kibana_sample_data_logs +| EVAL timestamp=DATE_TRUNC(3 hour, @timestamp), status = CASE( to_integer(response.keyword) >= 200 and to_integer(response.keyword) < 400, "HTTP 2xx and 3xx", to_integer(response.keyword) >= 400 and to_integer(response.keyword) < 500, "HTTP 4xx", "HTTP 5xx") +| stats results = count(*) by \`Over time\` = BUCKET(timestamp, 50, ?t_start, ?t_end), status +`; + +export const query2 = ` +from kibana_sample_data_logs +| sort @timestamp +| eval t = now() +| eval key = case(timestamp < t - 1 hour and timestamp > t - 2 hour, "Last hour", "Other") +| stats sum = sum(bytes), count = count_distinct(clientip) by key, extension.keyword +| eval sum_last_hour = case(key == "Last hour", sum), sum_rest = case(key == "Other", sum), count_last_hour = case(key == "Last hour", count), count_rest = case(key == "Other", count) +| stats sum_last_hour = max(sum_last_hour), sum_rest = max(sum_rest), count_last_hour = max(count_last_hour), count_rest = max(count_rest) by key, extension.keyword +| eval total_bytes = to_double(coalesce(sum_last_hour, 0::long) + coalesce(sum_rest, 0::long)) +| eval total_visits = to_double(coalesce(count_last_hour, 0::long) + coalesce(count_rest, 0::long)) +| eval bytes_transform = round(total_bytes / 1000000.0, 1) +| eval bytes_transform_last_hour = round(sum_last_hour / 1000.0, 2) +| keep count_last_hour, total_visits, bytes_transform, bytes_transform_last_hour, extension.keyword +| stats count_last_hour = sum(count_last_hour), total_visits = sum(total_visits), bytes_transform = sum(bytes_transform), bytes_transform_last_hour = sum(bytes_transform_last_hour) by extension.keyword +| rename total_visits as \`Unique Visits (Total)\`, count_last_hour as \`Unique Visits (Last hour)\`, bytes_transform as \`Bytes(Total - MB)\`, bytes_transform_last_hour as \`Bytes(Last hour - KB)\`, extension.keyword as \`Type\` +`; + +export const query3 = ` +from kibana_sample_data_logs +| keep bytes, clientip, url.keyword, response.keyword +| EVAL type = CASE(to_integer(response.keyword) >= 400 and to_integer(response.keyword) < 500, "4xx", to_integer(response.keyword) >= 500, "5xx", "Other") +| stats Visits = count(), Unique = count_distinct(clientip), p95 = percentile(bytes, 95), median = median(bytes) by type, url.keyword +| eval count_4xx = case(type == "4xx", Visits), count_5xx = case(type == "5xx", Visits), count_rest = case(type == "Other", Visits) +| stats count_4xx = sum(count_4xx), count_5xx = sum(count_5xx), count_rest = sum(count_rest), Unique = sum(Unique),\`95th percentile of bytes\` = max(p95), \`Median of bytes\` = max(median) BY url.keyword +| eval count_4xx = COALESCE(count_4xx, 0::LONG), count_5xx = COALESCE(count_5xx, 0::LONG), count_rest = COALESCE(count_rest, 0::LONG) +| eval total_records = TO_DOUBLE(count_4xx + count_5xx + count_rest) +| eval percentage_4xx = count_4xx / total_records, percentage_5xx = count_5xx / total_records +| eval percentage_4xx = round(100 * percentage_4xx, 2) +| eval percentage_5xx = round(100 * percentage_5xx, 2) +| drop count_4xx, count_rest, total_records +| RENAME percentage_4xx as \`HTTP 4xx\`, percentage_5xx as \`HTTP 5xx\` +`; + +export const query4 = ` +from kibana_sample_data_logs, kibana_sample_data_flights, kibana_sample_data_ecommerce, + index1, my-data-2024-*, my-data-2025-01-*, xxxx-xxxx-xxxx-xxxx-xxxx-xxxx-xxxx-xxxx, yyyy-yyyy-yyyy-yyyy-yyyy-yyyy-yyyy-yyyy-yyyy + METADATA _index, _id, _type, _score + +| sort @timestamp +| eval t = now() +| eval key = case(timestamp < t - 1 hour and timestamp > t - 2 hour, "Last hour", "Other") +| stats sum = sum(bytes), count = count_distinct(clientip) by key, extension.keyword +| eval sum_last_hour = case(key == "Last hour", sum), sum_rest = case(key == "Other", sum), count_last_hour = case(key == "Last hour", count), count_rest = case(key == "Other", count) +| stats sum_last_hour = max(sum_last_hour), sum_rest = max(sum_rest), count_last_hour = max(count_last_hour), count_rest = max(count_rest) by key, extension.keyword +| eval total_bytes = to_double(coalesce(sum_last_hour, 0::long) + coalesce(sum_rest, 0::long)) +| eval total_visits = to_double(coalesce(count_last_hour, 0::long) + coalesce(count_rest, 0::long)) +| eval bytes_transform = round(total_bytes / 1000000.0, 1) +| eval bytes_transform_last_hour = round(sum_last_hour / 1000.0, 2) +| keep count_last_hour, total_visits, bytes_transform, bytes_transform_last_hour, extension.keyword +| stats count_last_hour = sum(count_last_hour), total_visits = sum(total_visits), bytes_transform = sum(bytes_transform), bytes_transform_last_hour = sum(bytes_transform_last_hour) by extension.keyword +| rename total_visits as \`Unique Visits (Total)\`, count_last_hour as \`Unique Visits (Last hour)\`, bytes_transform as \`Bytes(Total - MB)\`, bytes_transform_last_hour as \`Bytes(Last hour - KB)\`, extension.keyword as \`Type\` +`; diff --git a/packages/kbn-esql-ast/src/pretty_print/__tests__/wrapping_pretty_printer.test.ts b/packages/kbn-esql-ast/src/pretty_print/__tests__/wrapping_pretty_printer.test.ts new file mode 100644 index 0000000000000..4cbebb5d66b67 --- /dev/null +++ b/packages/kbn-esql-ast/src/pretty_print/__tests__/wrapping_pretty_printer.test.ts @@ -0,0 +1,566 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +import { getAstAndSyntaxErrors } from '../../ast_parser'; +import { WrappingPrettyPrinter, WrappingPrettyPrinterOptions } from '../wrapping_pretty_printer'; + +const reprint = (src: string, opts?: WrappingPrettyPrinterOptions) => { + const { ast } = getAstAndSyntaxErrors(src); + const text = WrappingPrettyPrinter.print(ast, opts); + + // console.log(JSON.stringify(ast, null, 2)); + + return { text }; +}; + +describe('casing', () => { + test('can chose command name casing', () => { + const query = 'FROM index | WHERE a == 123'; + const text1 = reprint(query, { lowercase: true }).text; + const text2 = reprint(query, { lowercaseCommands: true }).text; + const text3 = reprint(query, { lowercaseCommands: false }).text; + + expect(text1).toBe('from index | where a == 123'); + expect(text2).toBe('from index | where a == 123'); + expect(text3).toBe('FROM index | WHERE a == 123'); + }); + + test('can chose command option name casing', () => { + const text1 = reprint('FROM a METADATA b', { lowercaseOptions: true }).text; + const text2 = reprint('FROM a METADATA b', { lowercaseOptions: false }).text; + + expect(text1).toBe('FROM a metadata b'); + expect(text2).toBe('FROM a METADATA b'); + }); + + test('can chose function name casing', () => { + const query = 'FROM index | STATS FN1(), FN2(), FN3()'; + const text1 = reprint(query, { lowercase: true }).text; + const text2 = reprint(query, { lowercaseFunctions: true }).text; + const text3 = reprint(query, { lowercaseFunctions: false }).text; + + expect(text1).toBe('from index | stats fn1(), fn2(), fn3()'); + expect(text2).toBe('FROM index | STATS fn1(), fn2(), fn3()'); + expect(text3).toBe('FROM index | STATS FN1(), FN2(), FN3()'); + }); + + test('can choose keyword casing', () => { + const query = 'FROM index | RENAME a AS b'; + const text1 = reprint(query, { lowercase: true }).text; + const text2 = reprint(query, { lowercaseKeywords: true }).text; + const text3 = reprint(query, { lowercaseKeywords: false }).text; + + expect(text1).toBe('from index | rename a as b'); + expect(text2).toBe('FROM index | RENAME a as b'); + expect(text3).toBe('FROM index | RENAME a AS b'); + }); + + test('can chose keyword casing (function nodes)', () => { + const query = 'FROM index | WHERE a LIKE "b"'; + const text1 = reprint(query, { lowercase: true }).text; + const text2 = reprint(query, { lowercaseKeywords: true }).text; + const text3 = reprint(query, { lowercaseKeywords: false }).text; + + expect(text1).toBe('from index | where a like "b"'); + expect(text2).toBe('FROM index | WHERE a like "b"'); + expect(text3).toBe('FROM index | WHERE a LIKE "b"'); + }); +}); + +describe('short query', () => { + test('can format a simple query to one line', () => { + const query = 'FROM index | WHERE a == 123'; + const text = reprint(query).text; + + expect(text).toBe('FROM index | WHERE a == 123'); + }); + + test('one line query respects indentation option', () => { + const query = 'FROM index | WHERE a == 123'; + const text = reprint(query, { indent: ' ' }).text; + + expect(text).toBe(' FROM index | WHERE a == 123'); + }); + + test('can force small query onto multiple lines', () => { + const query = 'FROM index | WHERE a == 123'; + const text = reprint(query, { multiline: true }).text; + + expect('\n' + text).toBe(` +FROM index + | WHERE a == 123`); + }); + + test('with initial indentation', () => { + const query = 'FROM index | WHERE a == 123'; + const text = reprint(query, { multiline: true, indent: '>' }).text; + + expect('\n' + text).toBe(` +>FROM index +> | WHERE a == 123`); + }); +}); + +describe('long query', () => { + describe('command arguments', () => { + test('wraps source list', () => { + const query = + 'FROM index, another_index, yet_another_index, on-more-index, last_index, very_last_index, ok_this_is_the_last_index'; + const text = reprint(query, { indent: '- ' }).text; + + expect('\n' + text).toBe(` +- FROM index, another_index, yet_another_index, on-more-index, last_index, +- very_last_index, ok_this_is_the_last_index`); + }); + + test('wraps source list, leaves one item on last line', () => { + const query = + 'FROM index, another_index, yet_another_index, on-more-index, last_index, very_last_index'; + const text = reprint(query).text; + + expect('\n' + text).toBe(` +FROM index, another_index, yet_another_index, on-more-index, last_index, + very_last_index`); + }); + + test('for a single very long source, prints a standalone line', () => { + const query = + 'FROM index_another_index_yet_another_index_on-more-index_last_index_very_last_index'; + const text = reprint(query).text; + + expect('\n' + text).toBe(` +FROM + index_another_index_yet_another_index_on-more-index_last_index_very_last_index`); + }); + + test('keeps sources in a list, as long as at least two fit per line', () => { + const query = ` +FROM xxxxxxxxxx, yyyyyyyyyyy, zzzzzzzzzzzzzzzzzzzzzzzzzzzzzz, aaaa, + bbbbbbbbbbbbbbbbbbb, ccccccccccccccccccccccccccc, gggggggggggggggg +`; + const text = reprint(query).text; + + expect('\n' + text).toBe(` +FROM xxxxxxxxxx, yyyyyyyyyyy, zzzzzzzzzzzzzzzzzzzzzzzzzzzzzz, aaaa, + bbbbbbbbbbbbbbbbbbb, ccccccccccccccccccccccccccc, gggggggggggggggg`); + }); + + test('keeps sources in a list, even if the last item consumes more than a line', () => { + const query = ` +FROM xxxxxxxxxx, yyyyyyyyyyy, zzzzzzzzzzzzzzzzzzzzzzzzzzzzzz, aaaa, + bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb +`; + const text = reprint(query).text; + + expect('\n' + text).toBe(` +FROM xxxxxxxxxx, yyyyyyyyyyy, zzzzzzzzzzzzzzzzzzzzzzzzzzzzzz, aaaa, + bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb`); + }); + + test('breaks sources per-line, if list layout results into alone source per line', () => { + const query = ` +FROM xxxxxxxxxx, yyyyyyyyyyy, zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz, + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa, // <------------ this one + bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb, ccccccc, ggggggggg +`; + const text = reprint(query).text; + + expect('\n' + text).toBe(` +FROM + xxxxxxxxxx, + yyyyyyyyyyy, + zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz, + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa, + bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb, + ccccccc, + ggggggggg`); + }); + + test('breaks sources per-line, whe there is one large source', () => { + const query = ` +FROM xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx, // <------------ this one + yyyyyyyyyyy, ccccccc, ggggggggg + `; + const text = reprint(query).text; + + expect('\n' + text).toBe(` +FROM + xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx, + yyyyyyyyyyy, + ccccccc, + ggggggggg`); + }); + }); + + describe('command option', () => { + test('prints short query on a single line', () => { + const query = 'FROM index METADATA _id'; + const text = reprint(query).text; + + expect(text).toBe(`FROM index METADATA _id`); + }); + + test('breaks METADATA option to new line, when query reaches wrapping threshold', () => { + const query = ` +FROM index1, index2, index2, index3, index4, index5, index6 METADATA _id, _source`; + const text = reprint(query, { pipeTab: ' ' }).text; + + expect('\n' + text).toBe(` +FROM index1, index2, index2, index3, index4, index5, index6 + METADATA _id, _source`); + }); + + test('indents METADATA option differently than the LIMIT pipe', () => { + const query = ` +FROM index1, index2, index2, index3, index4, index5, index6 METADATA _id, _source | LIMIT 10`; + const text = reprint(query, { pipeTab: ' ' }).text; + + expect('\n' + text).toBe(` +FROM index1, index2, index2, index3, index4, index5, index6 + METADATA _id, _source + | LIMIT 10`); + }); + + test('indents METADATA option differently than main FROM arguments', () => { + const query = ` +FROM index1, index2, index2, index3, index4, index5, index6, index7, index8, index9, index10, index11, index12, index13, index14, index15, index16, index17 METADATA _id, _source`; + const text = reprint(query, { pipeTab: ' ' }).text; + + expect('\n' + text).toBe(` +FROM index1, index2, index2, index3, index4, index5, index6, index7, index8, + index9, index10, index11, index12, index13, index14, index15, index16, + index17 + METADATA _id, _source`); + }); + + test('indents METADATA option differently than main FROM arguments when broken per line', () => { + const query = ` +FROM index_index_index_index_index_index_index_index_index_index_index_index_1, index_index_index_index_index_index_index_index_index_index_index_index_2, index_index_index_index_index_index_index_index_index_index_index_index_3 METADATA _id, _source`; + const text = reprint(query, { pipeTab: ' ' }).text; + + expect('\n' + text).toBe(` +FROM + index_index_index_index_index_index_index_index_index_index_index_index_1, + index_index_index_index_index_index_index_index_index_index_index_index_2, + index_index_index_index_index_index_index_index_index_index_index_index_3 + METADATA _id, _source`); + }); + + test('indents METADATA option different than the source list', () => { + const query = + 'FROM index, another_index, another_index, a_very_very_long_index_a_very_very_long_index_a_very_very_long_index, another_index, another_index METADATA _id, _source'; + const text = reprint(query, { indent: '👉 ' }).text; + + expect('\n' + text).toBe(` +👉 FROM +👉 index, +👉 another_index, +👉 another_index, +👉 a_very_very_long_index_a_very_very_long_index_a_very_very_long_index, +👉 another_index, +👉 another_index +👉 METADATA _id, _source`); + }); + + test('can break multiple options', () => { + const query = + 'from a | enrich policy ON match_field_which_is_very_long WITH new_name1 = field1, new_name2 = field2'; + const text = reprint(query, { indent: '👉 ' }).text; + + expect('\n' + text).toBe(` +👉 FROM a +👉 | ENRICH policy +👉 ON match_field_which_is_very_long +👉 WITH new_name1 = field1, new_name2 = field2`); + }); + + test('can break multiple options and wrap option arguments', () => { + const query = + 'from a | enrich policy ON match_field WITH new_name1 = field1, new_name2 = field2, new_name3 = field3, new_name4 = field4, new_name5 = field5, new_name6 = field6, new_name7 = field7, new_name8 = field8, new_name9 = field9, new_name10 = field10'; + const text = reprint(query, { indent: '👉 ' }).text; + + expect('\n' + text).toBe(` +👉 FROM a +👉 | ENRICH policy +👉 ON match_field +👉 WITH new_name1 = field1, new_name2 = field2, new_name3 = field3, +👉 new_name4 = field4, new_name5 = field5, new_name6 = field6, +👉 new_name7 = field7, new_name8 = field8, new_name9 = field9, +👉 new_name10 = field10`); + }); + }); + + describe('function call arguments', () => { + test('renders a one line list, if there is enough space', () => { + const query = ` +FROM index +| STATS avg(height), sum(weight), min(age), max(age), count(*) +| LIMIT 10 +`; + const text = reprint(query, { indent: '- ' }).text; + + expect('\n' + text).toBe(` +- FROM index +- | STATS AVG(height), SUM(weight), MIN(age), MAX(age), COUNT(*) +- | LIMIT 10`); + }); + + test('wraps function list', () => { + const query = ` +FROM index +| STATS avg(height), sum(weight), min(age), max(age), count(*), super_function(a, b, c, d, e, f, g, h, i, j, k, l, m, n, o, p) +| LIMIT 10 +`; + const text = reprint(query, { indent: '- ' }).text; + + expect('\n' + text).toBe(` +- FROM index +- | STATS AVG(height), SUM(weight), MIN(age), MAX(age), COUNT(*), +- SUPER_FUNCTION(a, b, c, d, e, f, g, h, i, j, k, l, m, n, o, p) +- | LIMIT 10`); + }); + + test('wraps function arguments', () => { + const query = ` +FROM index +| STATS avg(height), + super_function(some_column, another_column == "this is string", 1234567890.999991), + sum(weight) +| LIMIT 10 +`; + const text = reprint(query).text; + + expect('\n' + text).toBe(` +FROM index + | STATS + AVG(height), + SUPER_FUNCTION(some_column, another_column == "this is string", + 1234567890.999991), + SUM(weight) + | LIMIT 10`); + }); + + test('break by line function arguments, when wrapping is not enough', () => { + const query = ` +FROM index +| STATS avg(height), + super_function("xxxx-xxxx-xxxxxxxxxxxx-xxxx-xxxxxxxx", "yyyy-yyyy-yyyyyyyyyyyy-yyyy-yyyyyyyyyyyy", "zzzz-zzzz-zzzzzzzzzzzzzzz-zzzz-zzzzzzzzzzzzzz"), + sum(weight) +| LIMIT 10 +`; + const text = reprint(query).text; + + expect('\n' + text).toBe(` +FROM index + | STATS + AVG(height), + SUPER_FUNCTION( + "xxxx-xxxx-xxxxxxxxxxxx-xxxx-xxxxxxxx", + "yyyy-yyyy-yyyyyyyyyyyy-yyyy-yyyyyyyyyyyy", + "zzzz-zzzz-zzzzzzzzzzzzzzz-zzzz-zzzzzzzzzzzzzz"), + SUM(weight) + | LIMIT 10`); + }); + + test('break by line last function arguments, when wrapping is not enough', () => { + const query = ` +FROM index +| STATS avg(height), + super_function("xxxx-xxxx-xxxxxxxxxxxx-xxxx-xxxxxxxx", "yyyy-yyyy-yyyyyyyyyyyy-yyyy-yyyyyyyyyyyy", "zzzz-zzzz-zzzzzzzzzzzzzzz-zzzz-zzzzzzzzzzzzzz"), +| LIMIT 10 +`; + const text = reprint(query).text; + + expect('\n' + text).toBe(` +FROM index + | STATS + AVG(height), + SUPER_FUNCTION( + "xxxx-xxxx-xxxxxxxxxxxx-xxxx-xxxxxxxx", + "yyyy-yyyy-yyyyyyyyyyyy-yyyy-yyyyyyyyyyyy", + "zzzz-zzzz-zzzzzzzzzzzzzzz-zzzz-zzzzzzzzzzzzzz") + | LIMIT 10`); + }); + + test('break by line when wrapping would results in lines with a single item', () => { + const query = ` +FROM index +| STATS avg(height), + super_function("xxxx-xxxx-xxxxxxxxxxxxx-xxxxx-xxxxxxxx", + 1234567890 + 1234567890, + "zzzz-zzzz-zzzzzzzzzzzzzzzzz-zzzz-zzzzzzzzzzzzzz"), +| LIMIT 10 +`; + const text = reprint(query).text; + + expect('\n' + text).toBe(` +FROM index + | STATS + AVG(height), + SUPER_FUNCTION( + "xxxx-xxxx-xxxxxxxxxxxxx-xxxxx-xxxxxxxx", + 1234567890 + 1234567890, + "zzzz-zzzz-zzzzzzzzzzzzzzzzz-zzzz-zzzzzzzzzzzzzz") + | LIMIT 10`); + }); + + test('break by line when wrapping would results in lines with a single item - 2', () => { + const query = ` +FROM index +| STATS avg(height), + super_function(func1(123 + 123123 - 12333.33 / FALSE), func2("abrakadabra what?"), func3(), func4()), +| LIMIT 10 +`; + const text = reprint(query).text; + + expect('\n' + text).toBe(` +FROM index + | STATS + AVG(height), + SUPER_FUNCTION( + FUNC1(123 + 123123 - 12333.33 / FALSE), + FUNC2("abrakadabra what?"), + FUNC3(), + FUNC4()) + | LIMIT 10`); + }); + + test('can vertically flatten adjacent binary expressions of the same precedence', () => { + const query = ` +FROM index +| STATS super_function_name(0.123123123123123 + 888811112.232323123123 + 123123123123.123123123 + 23232323.23232323123 - 123 + 999)), +| LIMIT 10 +`; + const text = reprint(query).text; + + expect('\n' + text).toBe(` +FROM index + | STATS + SUPER_FUNCTION_NAME( + 0.123123123123123 + + 888811112.2323232 + + 123123123123.12312 + + 23232323.232323233 - + 123 + + 999)`); + }); + }); + + describe('binary expressions', () => { + test('can break a standalone binary expression (+) to two lines', () => { + const query = ` +FROM index +| STATS super_function_name("xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx") +| LIMIT 10 +`; + const text = reprint(query).text; + + expect('\n' + text).toBe(` +FROM index + | STATS + SUPER_FUNCTION_NAME( + "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + + "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx") + | LIMIT 10`); + }); + + describe('vertical flattening', () => { + test('binary expressions of different precedence are not flattened', () => { + const query = ` +FROM index +| STATS super_function_name(0.123123123123123 + 888811112.232323123123 * 123123123123.123123123 + 23232323.23232323123 - 123 + 999)), +| LIMIT 10 +`; + const text = reprint(query).text; + + expect('\n' + text).toBe(` +FROM index + | STATS + SUPER_FUNCTION_NAME( + 0.123123123123123 + + 888811112.2323232 * 123123123123.12312 + + 23232323.232323233 - + 123 + + 999)`); + }); + + test('binary expressions vertical flattening child function function argument wrapping', () => { + const query = ` +FROM index +| STATS super_function_name(11111111111111.111 + 11111111111111.111 * 11111111111111.111 + another_function_goes_here("this will get wrapped", "at this word", "and one more long string") - 111 + 111)), +| LIMIT 10 +`; + const text = reprint(query).text; + + expect('\n' + text).toBe(` +FROM index + | STATS + SUPER_FUNCTION_NAME( + 11111111111111.111 + + 11111111111111.111 * 11111111111111.111 + + ANOTHER_FUNCTION_GOES_HERE("this will get wrapped", "at this word", + "and one more long string") - + 111 + + 111)`); + }); + + test('two binary expression lists of different precedence group', () => { + const query = ` +FROM index +| STATS super_function_name(11111111111111.111 + 3333333333333.3333 * 3333333333333.3333 * 3333333333333.3333 * 3333333333333.3333 + 11111111111111.111 + 11111111111111.111)), +| LIMIT 10 +`; + const text = reprint(query).text; + + expect('\n' + text).toBe(` +FROM index + | STATS + SUPER_FUNCTION_NAME( + 11111111111111.111 + + 3333333333333.3335 * + 3333333333333.3335 * + 3333333333333.3335 * + 3333333333333.3335 + + 11111111111111.111 + + 11111111111111.111)`); + }); + }); + }); + + describe('inline cast expression', () => { + test('wraps complex expression into brackets where necessary', () => { + const query = ` +ROW (asdf + asdf)::string, 1.2::string, "1234"::integer, (12321342134 + 2341234123432 + 23423423423 + 234234234 + 234234323423 + 3343423424234234)::integer, + function_name(123456789 + 123456789 + 123456789 + 123456789 + 123456789 + 123456789 + 123456789, "bbbbbbbbbbbbbb", "aaaaaaaaaaa")::boolean +`; + const text = reprint(query, { indent: '- ' }).text; + + expect('\n' + text).toBe(` +- ROW +- (asdf + asdf)::string, +- 1.2::string, +- "1234"::integer, +- (12321342134 + +- 2341234123432 + +- 23423423423 + +- 234234234 + +- 234234323423 + +- 3343423424234234)::integer, +- FUNCTION_NAME( +- 123456789 + +- 123456789 + +- 123456789 + +- 123456789 + +- 123456789 + +- 123456789 + +- 123456789, +- "bbbbbbbbbbbbbb", +- "aaaaaaaaaaa")::boolean`); + }); + }); +}); 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 new file mode 100644 index 0000000000000..0aa3ccd608cc6 --- /dev/null +++ b/packages/kbn-esql-ast/src/pretty_print/basic_pretty_printer.ts @@ -0,0 +1,266 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +import { ESQLAstCommand } from '../types'; +import { ESQLAstExpressionNode, ESQLAstQueryNode, Visitor } from '../visitor'; +import { LeafPrinter } from './leaf_printer'; + +/** + * @todo + * + * 1. Add support for binary expression wrapping into brackets, due to operator + * precedence. + */ + +export interface BasicPrettyPrinterOptions { + /** + * Whether to break the query into multiple lines on each pipe. Defaults to + * `false`. + */ + multiline?: boolean; + + /** + * Tabbing string inserted before a pipe, when `multiline` is `true`. Defaults + * to two spaces. + */ + pipeTab?: string; + + /** + * The default lowercase setting to use for all options. Defaults to `false`. + */ + lowercase?: boolean; + + /** + * Whether to lowercase command names. Defaults to `false`. + */ + lowercaseCommands?: boolean; + + /** + * Whether to lowercase command options. Defaults to `false`. + */ + lowercaseOptions?: boolean; + + /** + * Whether to lowercase function names. Defaults to `false`. + */ + lowercaseFunctions?: boolean; + + /** + * Whether to lowercase keywords. Defaults to `false`. + */ + lowercaseKeywords?: boolean; +} + +export type BasicPrettyPrinterMultilineOptions = Omit; + +export class BasicPrettyPrinter { + /** + * @param query ES|QL query AST to print. + * @returns A single-line string representation of the query. + */ + public static readonly print = ( + query: ESQLAstQueryNode, + opts?: BasicPrettyPrinterOptions + ): string => { + const printer = new BasicPrettyPrinter(opts); + return printer.print(query); + }; + + /** + * Print a query with each command on a separate line. It is also possible to + * specify a tabbing option for the pipe character. + * + * @param query ES|QL query AST to print. + * @param opts Options for pretty-printing. + * @returns A multi-line string representation of the query. + */ + public static readonly multiline = ( + query: ESQLAstQueryNode, + opts?: BasicPrettyPrinterMultilineOptions + ): string => { + const printer = new BasicPrettyPrinter({ ...opts, multiline: true }); + return printer.print(query); + }; + + /** + * @param command ES|QL command AST node to print. + * @returns Prints a single-line string representation of the command. + */ + public static readonly command = ( + command: ESQLAstCommand, + opts?: BasicPrettyPrinterOptions + ): string => { + const printer = new BasicPrettyPrinter(opts); + return printer.printCommand(command); + }; + + /** + * @param expression ES|QL expression AST node to print. + * @returns Prints a single-line string representation of the expression. + */ + public static readonly expression = ( + expression: ESQLAstExpressionNode, + opts?: BasicPrettyPrinterOptions + ): string => { + const printer = new BasicPrettyPrinter(opts); + return printer.printExpression(expression); + }; + + protected readonly opts: Required; + + constructor(opts: BasicPrettyPrinterOptions = {}) { + this.opts = { + pipeTab: opts.pipeTab ?? ' ', + multiline: opts.multiline ?? false, + lowercase: opts.lowercase ?? false, + lowercaseCommands: opts.lowercaseCommands ?? opts.lowercase ?? false, + lowercaseOptions: opts.lowercaseOptions ?? opts.lowercase ?? false, + lowercaseFunctions: opts.lowercaseFunctions ?? opts.lowercase ?? false, + lowercaseKeywords: opts.lowercaseKeywords ?? opts.lowercase ?? false, + }; + } + + protected keyword(word: string) { + return this.opts.lowercaseKeywords ?? this.opts.lowercase + ? word.toLowerCase() + : word.toUpperCase(); + } + + protected readonly visitor = new Visitor() + .on('visitExpression', (ctx) => { + return ''; + }) + .on('visitSourceExpression', (ctx) => LeafPrinter.source(ctx.node)) + .on('visitColumnExpression', (ctx) => LeafPrinter.column(ctx.node)) + .on('visitLiteralExpression', (ctx) => LeafPrinter.literal(ctx.node)) + .on('visitTimeIntervalLiteralExpression', (ctx) => LeafPrinter.timeInterval(ctx.node)) + .on('visitInlineCastExpression', (ctx) => { + const value = ctx.value(); + const wrapInBrackets = + value.type !== 'literal' && + value.type !== 'column' && + !(value.type === 'function' && value.subtype === 'variadic-call'); + + let valueFormatted = ctx.visitValue(); + + if (wrapInBrackets) { + valueFormatted = `(${valueFormatted})`; + } + + return `${valueFormatted}::${ctx.node.castType}`; + }) + .on('visitListLiteralExpression', (ctx) => { + let elements = ''; + + for (const arg of ctx.visitElements()) { + elements += (elements ? ', ' : '') + arg; + } + + return `[${elements}]`; + }) + .on('visitFunctionCallExpression', (ctx) => { + const opts = this.opts; + const node = ctx.node; + + let operator = ctx.operator(); + + switch (node.subtype) { + case 'unary-expression': { + operator = this.keyword(operator); + + return `${operator} ${ctx.visitArgument(0, undefined)}`; + } + case 'postfix-unary-expression': { + operator = this.keyword(operator); + + return `${ctx.visitArgument(0)} ${operator}`; + } + case 'binary-expression': { + operator = this.keyword(operator); + + return `${ctx.visitArgument(0)} ${operator} ${ctx.visitArgument(1)}`; + } + default: { + if (opts.lowercaseFunctions) { + operator = operator.toLowerCase(); + } + + let args = ''; + + for (const arg of ctx.visitArguments()) { + args += (args ? ', ' : '') + arg; + } + + return `${operator}(${args})`; + } + } + }) + .on('visitRenameExpression', (ctx) => { + return `${ctx.visitArgument(0)} ${this.keyword('AS')} ${ctx.visitArgument(1)}`; + }) + .on('visitCommandOption', (ctx) => { + const opts = this.opts; + const option = opts.lowercaseOptions ? ctx.node.name : ctx.node.name.toUpperCase(); + + let args = ''; + + for (const arg of ctx.visitArguments()) { + args += (args ? ', ' : '') + arg; + } + + const argsFormatted = args ? ` ${args}` : ''; + const optionFormatted = `${option}${argsFormatted}`; + + return optionFormatted; + }) + .on('visitCommand', (ctx) => { + const opts = this.opts; + const cmd = opts.lowercaseCommands ? ctx.node.name : ctx.node.name.toUpperCase(); + + let args = ''; + let options = ''; + + for (const source of ctx.visitArguments()) { + args += (args ? ', ' : '') + source; + } + + for (const option of ctx.visitOptions()) { + options += (options ? ' ' : '') + option; + } + + const argsFormatted = args ? ` ${args}` : ''; + const optionsFormatted = options ? ` ${options}` : ''; + const cmdFormatted = `${cmd}${argsFormatted}${optionsFormatted}`; + + return cmdFormatted; + }) + .on('visitQuery', (ctx) => { + const opts = this.opts; + const cmdSeparator = opts.multiline ? `\n${opts.pipeTab ?? ' '}| ` : ' | '; + let text = ''; + + for (const cmd of ctx.visitCommands()) { + if (text) text += cmdSeparator; + text += cmd; + } + + return text; + }); + + public print(query: ESQLAstQueryNode) { + return this.visitor.visitQuery(query); + } + + public printCommand(command: ESQLAstCommand) { + return this.visitor.visitCommand(command); + } + + public printExpression(expression: ESQLAstExpressionNode) { + return this.visitor.visitExpression(expression); + } +} diff --git a/packages/kbn-esql-ast/src/pretty_print/leaf_printer.ts b/packages/kbn-esql-ast/src/pretty_print/leaf_printer.ts new file mode 100644 index 0000000000000..b7bd13be8e8b8 --- /dev/null +++ b/packages/kbn-esql-ast/src/pretty_print/leaf_printer.ts @@ -0,0 +1,93 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +import { ESQLColumn, ESQLLiteral, ESQLSource, ESQLTimeInterval } from '../types'; + +const regexUnquotedIdPattern = /^([a-z\*_\@]{1})[a-z0-9_\*]*$/i; + +/** + * Printer for leaf AST nodes. The printing output of these nodes should + * typically not depend on word wrapping settings, should always return an + * atomic short string. + */ +export const LeafPrinter = { + source: (node: ESQLSource) => node.name, + + /** + * @todo: Add support for: (1) escaped characters, (2) nested fields. + * + * See: https://github.com/elastic/kibana/issues/189913 + */ + column: (node: ESQLColumn) => { + // In the future "column" nodes will have a "parts" field that will be used + // specify the parts of the column name. + const parts: string[] = [node.text]; + + let formatted = ''; + + for (const part of parts) { + if (formatted.length > 0) { + formatted += '.'; + } + if (regexUnquotedIdPattern.test(part)) { + formatted += part; + } else { + // Escape backticks "`" with double backticks "``". + const escaped = part.replace(/`/g, '``'); + formatted += '`' + escaped + '`'; + } + } + + return formatted; + }, + + literal: (node: ESQLLiteral) => { + switch (node.literalType) { + case 'null': { + return 'NULL'; + } + case 'boolean': { + return String(node.value).toUpperCase() === 'TRUE' ? 'TRUE' : 'FALSE'; + } + case 'param': { + switch (node.paramType) { + case 'named': + case 'positional': + return '?' + node.value; + default: + return '?'; + } + } + case 'string': { + return String(node.value); + } + case 'decimal': { + const isRounded = node.value % 1 === 0; + + if (isRounded) { + return String(node.value) + '.0'; + } else { + return String(node.value); + } + } + default: { + return String(node.value); + } + } + }, + + timeInterval: (node: ESQLTimeInterval) => { + const { quantity, unit } = node; + + if (unit.length === 1) { + return `${quantity}${unit}`; + } else { + return `${quantity} ${unit}`; + } + }, +}; diff --git a/packages/kbn-esql-ast/src/pretty_print/pretty_print_one_line.test.ts b/packages/kbn-esql-ast/src/pretty_print/pretty_print_one_line.test.ts deleted file mode 100644 index 2112eeabe483b..0000000000000 --- a/packages/kbn-esql-ast/src/pretty_print/pretty_print_one_line.test.ts +++ /dev/null @@ -1,352 +0,0 @@ -/* - * 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 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 or the Server - * Side Public License, v 1. - */ - -import { getAstAndSyntaxErrors } from '../ast_parser'; -import { prettyPrintOneLine } from './pretty_print_one_line'; - -const reprint = (src: string) => { - const { ast } = getAstAndSyntaxErrors(src); - const text = prettyPrintOneLine(ast); - - // console.log(JSON.stringify(ast, null, 2)); - - return { text }; -}; - -describe('commands', () => { - describe('FROM', () => { - test('FROM command with a single source', () => { - const { text } = reprint('FROM index1'); - - expect(text).toBe('FROM index1'); - }); - - test('FROM command with multiple indices', () => { - const { text } = reprint('from index1, index2, index3'); - - expect(text).toBe('FROM index1, index2, index3'); - }); - - test('FROM command with METADATA', () => { - const { text } = reprint('FROM index1, index2 METADATA field1, field2'); - - expect(text).toBe('FROM index1, index2 METADATA field1, field2'); - }); - }); - - describe('SORT', () => { - test('order expression with no modifier', () => { - const { text } = reprint('FROM a | SORT b'); - - expect(text).toBe('FROM a | SORT b'); - }); - - /** @todo Enable once order expressions are supported. */ - test.skip('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'); - - expect(text).toBe('FROM a | SORT b ASC NULLS FIRST'); - }); - }); - - describe('EXPLAIN', () => { - /** @todo Enable once query expressions are supported. */ - test.skip('a nested query', () => { - const { text } = reprint('EXPLAIN [ FROM 1 ]'); - - expect(text).toBe('EXPLAIN [ FROM 1 ]'); - }); - }); - - describe('SHOW', () => { - /** @todo Enable once show command args are parsed as columns. */ - test.skip('info page', () => { - const { text } = reprint('SHOW info'); - - expect(text).toBe('SHOW info'); - }); - }); - - describe('META', () => { - /** @todo Enable once show command args are parsed as columns. */ - test.skip('functions page', () => { - const { text } = reprint('META functions'); - - expect(text).toBe('META functions'); - }); - }); - - describe('STATS', () => { - test('with aggregates assignment', () => { - const { text } = reprint('FROM a | STATS var = agg(123, fn(true))'); - - expect(text).toBe('FROM a | STATS var = AGG(123, FN(TRUE))'); - }); - - test('with BY clause', () => { - const { text } = reprint('FROM a | STATS a(1), b(2) by asdf'); - - expect(text).toBe('FROM a | STATS A(1), B(2) BY asdf'); - }); - }); -}); - -describe('expressions', () => { - describe('source expressions', () => { - test('simple source expression', () => { - const { text } = reprint('from source'); - - expect(text).toBe('FROM source'); - }); - - test('sources with dots', () => { - const { text } = reprint('FROM a.b.c'); - - expect(text).toBe('FROM a.b.c'); - }); - - test('sources with slashes', () => { - const { text } = reprint('FROM a/b/c'); - - expect(text).toBe('FROM a/b/c'); - }); - - test('cluster source', () => { - const { text } = reprint('FROM cluster:index'); - - expect(text).toBe('FROM cluster:index'); - }); - - test('quoted source', () => { - const { text } = reprint('FROM "quoted"'); - - expect(text).toBe('FROM quoted'); - }); - - test('triple-quoted source', () => { - const { text } = reprint('FROM """quoted"""'); - - expect(text).toBe('FROM quoted'); - }); - }); - - describe('column expressions', () => { - test('simple columns expressions', () => { - const { text } = reprint('FROM a METADATA column1, _column2'); - - expect(text).toBe('FROM a METADATA column1, _column2'); - }); - - test('nested fields', () => { - const { text } = reprint('FROM a | KEEP a.b'); - - expect(text).toBe('FROM a | KEEP a.b'); - }); - - // Un-skip when "IdentifierPattern" is parsed correctly. - test.skip('quoted nested fields', () => { - const { text } = reprint('FROM index | KEEP `a`.`b`, c.`d`'); - - expect(text).toBe('FROM index | KEEP a.b, c.d'); - }); - - // Un-skip when identifier names are escaped correctly. - test.skip('special character in identifier', () => { - const { text } = reprint('FROM a | KEEP `a 👉 b`, a.`✅`'); - - expect(text).toBe('FROM a | KEEP `a 👉 b`, a.`✅`'); - }); - }); - - describe('"function" expressions', () => { - describe('function call expression', () => { - test('no argument function', () => { - const { text } = reprint('ROW fn()'); - - expect(text).toBe('ROW FN()'); - }); - - test('functions with arguments', () => { - const { text } = reprint('ROW gg(1), wp(1, 2, 3)'); - - expect(text).toBe('ROW GG(1), WP(1, 2, 3)'); - }); - - test('functions with star argument', () => { - const { text } = reprint('ROW f(*)'); - - expect(text).toBe('ROW F(*)'); - }); - }); - - describe('unary expression', () => { - test('NOT expression', () => { - const { text } = reprint('ROW NOT a'); - - expect(text).toBe('ROW NOT a'); - }); - }); - - describe('postfix unary expression', () => { - test('IS NOT NULL expression', () => { - const { text } = reprint('ROW a IS NOT NULL'); - - expect(text).toBe('ROW a IS NOT NULL'); - }); - }); - - describe('binary expression expression', () => { - test('arithmetic expression', () => { - const { text } = reprint('ROW 1 + 2'); - - expect(text).toBe('ROW 1 + 2'); - }); - - test('assignment expression', () => { - const { text } = reprint('FROM a | STATS a != 1'); - - expect(text).toBe('FROM a | STATS a != 1'); - }); - - test('regex expression - 1', () => { - const { text } = reprint('FROM a | WHERE a NOT RLIKE "a"'); - - expect(text).toBe('FROM a | WHERE a NOT RLIKE "a"'); - }); - - test('regex expression - 2', () => { - const { text } = reprint('FROM a | WHERE a LIKE "b"'); - - expect(text).toBe('FROM a | WHERE a LIKE "b"'); - }); - }); - }); - - describe('literals expressions', () => { - describe('numeric literal', () => { - test('null', () => { - const { text } = reprint('ROW null'); - - expect(text).toBe('ROW NULL'); - }); - - test('boolean', () => { - expect(reprint('ROW true').text).toBe('ROW TRUE'); - expect(reprint('ROW false').text).toBe('ROW FALSE'); - }); - - test('integer', () => { - const { text } = reprint('ROW 1'); - - expect(text).toBe('ROW 1'); - }); - - test('decimal', () => { - const { text } = reprint('ROW 1.2'); - - expect(text).toBe('ROW 1.2'); - }); - - test('string', () => { - const { text } = reprint('ROW "abc"'); - - expect(text).toBe('ROW "abc"'); - }); - - test('string w/ special chars', () => { - const { text } = reprint('ROW "as \\" 👍"'); - - expect(text).toBe('ROW "as \\" 👍"'); - }); - - describe('params', () => { - test('unnamed', () => { - const { text } = reprint('ROW ?'); - - expect(text).toBe('ROW ?'); - }); - - test('named', () => { - const { text } = reprint('ROW ?kappa'); - - expect(text).toBe('ROW ?kappa'); - }); - - test('positional', () => { - const { text } = reprint('ROW ?42'); - - expect(text).toBe('ROW ?42'); - }); - }); - }); - }); - - describe('list literal expressions', () => { - describe('integer list', () => { - test('one element list', () => { - expect(reprint('ROW [1]').text).toBe('ROW [1]'); - }); - - test('multiple elements', () => { - expect(reprint('ROW [1, 2]').text).toBe('ROW [1, 2]'); - expect(reprint('ROW [1, 2, -1]').text).toBe('ROW [1, 2, -1]'); - }); - }); - - describe('boolean list', () => { - test('one element list', () => { - expect(reprint('ROW [true]').text).toBe('ROW [TRUE]'); - }); - - test('multiple elements', () => { - expect(reprint('ROW [TRUE, false]').text).toBe('ROW [TRUE, FALSE]'); - expect(reprint('ROW [false, FALSE, false]').text).toBe('ROW [FALSE, FALSE, FALSE]'); - }); - }); - - describe('string list', () => { - test('one element list', () => { - expect(reprint('ROW ["a"]').text).toBe('ROW ["a"]'); - }); - - test('multiple elements', () => { - expect(reprint('ROW ["a", "b"]').text).toBe('ROW ["a", "b"]'); - expect(reprint('ROW ["foo", "42", "boden"]').text).toBe('ROW ["foo", "42", "boden"]'); - }); - }); - }); - - describe('cast expressions', () => { - test('various', () => { - expect(reprint('ROW a::string').text).toBe('ROW a::string'); - expect(reprint('ROW 123::string').text).toBe('ROW 123::string'); - expect(reprint('ROW "asdf"::number').text).toBe('ROW "asdf"::number'); - }); - }); - - describe('time interval expression', () => { - test('days', () => { - const { text } = reprint('ROW 1 d'); - - expect(text).toBe('ROW 1d'); - }); - - test('years', () => { - const { text } = reprint('ROW 42y'); - - expect(text).toBe('ROW 42y'); - }); - }); -}); diff --git a/packages/kbn-esql-ast/src/pretty_print/pretty_print_one_line.ts b/packages/kbn-esql-ast/src/pretty_print/pretty_print_one_line.ts deleted file mode 100644 index 94f4afd1acd11..0000000000000 --- a/packages/kbn-esql-ast/src/pretty_print/pretty_print_one_line.ts +++ /dev/null @@ -1,148 +0,0 @@ -/* - * 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 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 or the Server - * Side Public License, v 1. - */ - -import { ESQLAstQueryNode, Visitor } from '../visitor'; - -export const prettyPrintOneLine = (query: ESQLAstQueryNode) => { - const visitor = new Visitor() - .on('visitSourceExpression', (ctx) => { - return ctx.node.name; - }) - .on('visitColumnExpression', (ctx) => { - /** - * @todo: Add support for: (1) escaped characters, (2) nested fields. - */ - return ctx.node.name; - }) - .on('visitFunctionCallExpression', (ctx) => { - const node = ctx.node; - let operator = node.name.toUpperCase(); - - switch (node.subtype) { - case 'unary-expression': { - return `${operator} ${ctx.visitArgument(0)}`; - } - case 'postfix-unary-expression': { - return `${ctx.visitArgument(0)} ${operator}`; - } - case 'binary-expression': { - /** @todo Make `operator` printable. */ - switch (operator) { - case 'NOT_LIKE': { - operator = 'NOT LIKE'; - break; - } - case 'NOT_RLIKE': { - operator = 'NOT RLIKE'; - break; - } - } - return `${ctx.visitArgument(0)} ${operator} ${ctx.visitArgument(1)}`; - } - default: { - let args = ''; - - for (const arg of ctx.visitArguments()) { - args += (args ? ', ' : '') + arg; - } - - return `${operator}(${args})`; - } - } - }) - .on('visitLiteralExpression', (ctx) => { - const node = ctx.node; - - switch (node.literalType) { - case 'null': { - return 'NULL'; - } - case 'boolean': { - return String(node.value).toUpperCase() === 'TRUE' ? 'TRUE' : 'FALSE'; - } - case 'param': { - switch (node.paramType) { - case 'named': - case 'positional': - return '?' + node.value; - default: - return '?'; - } - } - case 'string': { - return node.value; - } - default: { - return String(ctx.node.value); - } - } - }) - .on('visitListLiteralExpression', (ctx) => { - let elements = ''; - - for (const arg of ctx.visitElements()) { - elements += (elements ? ', ' : '') + arg; - } - - return `[${elements}]`; - }) - .on('visitTimeIntervalLiteralExpression', (ctx) => { - /** @todo Rename to `fmt`. */ - return ctx.format(); - }) - .on('visitInlineCastExpression', (ctx) => { - /** @todo Add `.fmt()` helper. */ - return `${ctx.visitValue()}::${ctx.node.castType}`; - }) - .on('visitExpression', (ctx) => { - return ctx.node.text ?? ''; - }) - .on('visitCommandOption', (ctx) => { - const option = ctx.node.name.toUpperCase(); - let args = ''; - - for (const arg of ctx.visitArguments()) { - args += (args ? ', ' : '') + arg; - } - - const argsFormatted = args ? ` ${args}` : ''; - const optionFormatted = `${option}${argsFormatted}`; - - return optionFormatted; - }) - .on('visitCommand', (ctx) => { - const cmd = ctx.node.name.toUpperCase(); - let args = ''; - let options = ''; - - for (const source of ctx.visitArguments()) { - args += (args ? ', ' : '') + source; - } - - for (const option of ctx.visitOptions()) { - options += (options ? ' ' : '') + option; - } - - const argsFormatted = args ? ` ${args}` : ''; - const optionsFormatted = options ? ` ${options}` : ''; - const cmdFormatted = `${cmd}${argsFormatted}${optionsFormatted}`; - - return cmdFormatted; - }) - .on('visitQuery', (ctx) => { - let text = ''; - - for (const cmd of ctx.visitCommands()) { - text += (text ? ' | ' : '') + cmd; - } - - return text; - }); - - return visitor.visitQuery(query); -}; diff --git a/packages/kbn-esql-ast/src/pretty_print/wrapping_pretty_printer.ts b/packages/kbn-esql-ast/src/pretty_print/wrapping_pretty_printer.ts new file mode 100644 index 0000000000000..24381fbcda1a8 --- /dev/null +++ b/packages/kbn-esql-ast/src/pretty_print/wrapping_pretty_printer.ts @@ -0,0 +1,478 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +import { BinaryExpressionGroup } from '../ast/constants'; +import { binaryExpressionGroup, isBinaryExpression } from '../ast/helpers'; +import { + CommandOptionVisitorContext, + CommandVisitorContext, + ESQLAstQueryNode, + ExpressionVisitorContext, + FunctionCallExpressionVisitorContext, + Visitor, +} from '../visitor'; +import { singleItems } from '../visitor/utils'; +import { BasicPrettyPrinter, BasicPrettyPrinterOptions } from './basic_pretty_printer'; +import { LeafPrinter } from './leaf_printer'; + +/** + * @todo + * + * 1. Implement list literal pretty printing. + */ + +interface Input { + indent: string; + remaining: number; + + /** + * Passed between adjacent binary expressions to flatten them into a single + * vertical list. + * + * For example, a list like this: + * + * ``` + * 1 + 2 + 3 + 4 + * ``` + * + * Is flatted into a single list: + * + * ``` + * 1 + + * 2 + + * 3 + + * 4 + * ``` + */ + flattenBinExpOfType?: BinaryExpressionGroup; +} + +interface Output { + txt: string; + lines?: number; +} + +export interface WrappingPrettyPrinterOptions extends BasicPrettyPrinterOptions { + /** + * Initial indentation string inserted before the whole query. Defaults to an + * empty string. + */ + indent?: string; + + /** + * Tabbing string inserted before new level of nesting. Defaults to two spaces. + */ + tab?: string; + + /** + * Tabbing string inserted before a pipe, when `multiline` is `true`. + */ + pipeTab?: string; + + /** + * Tabbing string inserted before command arguments, when they are broken into + * multiple lines. Defaults to four spaces. + */ + commandTab?: string; + + /** + * Whether to force multiline formatting. Defaults to `false`. If set to + * `false`, it will try to fit the query into a single line. + */ + multiline?: boolean; + + /** + * Expected width of the output. Defaults to 80 characters. Text will be + * wrapped to fit this width. + */ + wrap?: number; +} + +export class WrappingPrettyPrinter { + public static readonly print = ( + query: ESQLAstQueryNode, + opts?: WrappingPrettyPrinterOptions + ): string => { + const printer = new WrappingPrettyPrinter(opts); + return printer.print(query); + }; + + protected readonly opts: Required; + + constructor(opts: WrappingPrettyPrinterOptions = {}) { + this.opts = { + indent: opts.indent ?? '', + tab: opts.tab ?? ' ', + pipeTab: opts.pipeTab ?? ' ', + commandTab: opts.commandTab ?? ' ', + multiline: opts.multiline ?? false, + wrap: opts.wrap ?? 80, + lowercase: opts.lowercase ?? false, + lowercaseCommands: opts.lowercaseCommands ?? opts.lowercase ?? false, + lowercaseOptions: opts.lowercaseOptions ?? opts.lowercase ?? false, + lowercaseFunctions: opts.lowercaseFunctions ?? opts.lowercase ?? false, + lowercaseKeywords: opts.lowercaseKeywords ?? opts.lowercase ?? false, + }; + } + + protected keyword(word: string) { + return this.opts.lowercaseKeywords ?? this.opts.lowercase + ? word.toLowerCase() + : word.toUpperCase(); + } + + private visitBinaryExpression( + ctx: ExpressionVisitorContext, + operator: string, + inp: Input + ): Output { + const node = ctx.node; + const group = binaryExpressionGroup(node); + const [left, right] = ctx.arguments(); + const groupLeft = binaryExpressionGroup(left); + const groupRight = binaryExpressionGroup(right); + const continueVerticalFlattening = group && inp.flattenBinExpOfType === group; + + if (continueVerticalFlattening) { + const parent = ctx.parent?.node; + const isLeftChild = isBinaryExpression(parent) && parent.args[0] === node; + const leftInput: Input = { + indent: inp.indent, + remaining: inp.remaining, + flattenBinExpOfType: group, + }; + const rightInput: Input = { + indent: inp.indent + this.opts.tab, + remaining: inp.remaining - this.opts.tab.length, + flattenBinExpOfType: group, + }; + const leftOut = ctx.visitArgument(0, leftInput); + const rightOut = ctx.visitArgument(1, rightInput); + const rightTab = isLeftChild ? this.opts.tab : ''; + const txt = `${leftOut.txt} ${operator}\n${inp.indent}${rightTab}${rightOut.txt}`; + + return { txt }; + } + + let txt: string = ''; + let leftFormatted = BasicPrettyPrinter.expression(left, this.opts); + let rightFormatted = BasicPrettyPrinter.expression(right, this.opts); + + if (groupLeft && groupLeft < group) { + leftFormatted = `(${leftFormatted})`; + } + + if (groupRight && groupRight < group) { + rightFormatted = `(${rightFormatted})`; + } + + const length = leftFormatted.length + rightFormatted.length + operator.length + 2; + const fitsOnOneLine = length <= inp.remaining; + + if (fitsOnOneLine) { + txt = `${leftFormatted} ${operator} ${rightFormatted}`; + } else { + const flattenVertically = group === groupLeft || group === groupRight; + const flattenBinExpOfType = flattenVertically ? group : undefined; + const leftInput: Input = { + indent: inp.indent, + remaining: inp.remaining, + flattenBinExpOfType, + }; + const rightInput: Input = { + indent: inp.indent + this.opts.tab, + remaining: inp.remaining - this.opts.tab.length, + flattenBinExpOfType, + }; + const leftOut = ctx.visitArgument(0, leftInput); + const rightOut = ctx.visitArgument(1, rightInput); + + txt = `${leftOut.txt} ${operator}\n${inp.indent}${this.opts.tab}${rightOut.txt}`; + } + + return { txt }; + } + + private printArguments( + ctx: CommandVisitorContext | CommandOptionVisitorContext | FunctionCallExpressionVisitorContext, + inp: Input + ) { + let txt = ''; + let lines = 1; + let largestArg = 0; + let argsPerLine = 0; + let minArgsPerLine = 1e6; + let maxArgsPerLine = 0; + let remainingCurrentLine = inp.remaining; + let oneArgumentPerLine = false; + + ARGS: for (const arg of singleItems(ctx.arguments())) { + if (arg.type === 'option') { + continue; + } + + const formattedArg = BasicPrettyPrinter.expression(arg, this.opts); + const formattedArgLength = formattedArg.length; + const needsWrap = remainingCurrentLine < formattedArgLength; + if (formattedArgLength > largestArg) { + largestArg = formattedArgLength; + } + let separator = txt ? ',' : ''; + let fragment = ''; + + if (needsWrap) { + separator += + '\n' + + inp.indent + + this.opts.tab + + (ctx instanceof CommandVisitorContext ? this.opts.commandTab : ''); + fragment = separator + formattedArg; + lines++; + if (argsPerLine > maxArgsPerLine) { + maxArgsPerLine = argsPerLine; + } + if (argsPerLine < minArgsPerLine) { + minArgsPerLine = argsPerLine; + if (minArgsPerLine < 2) { + oneArgumentPerLine = true; + break ARGS; + } + } + remainingCurrentLine = + inp.remaining - formattedArgLength - this.opts.tab.length - this.opts.commandTab.length; + argsPerLine = 1; + } else { + argsPerLine++; + fragment = separator + (separator ? ' ' : '') + formattedArg; + remainingCurrentLine -= fragment.length; + } + txt += fragment; + } + + let indent = inp.indent + this.opts.tab; + + if (ctx instanceof CommandVisitorContext) { + const isFirstCommand = (ctx.parent?.node as ESQLAstQueryNode)?.[0] === ctx.node; + if (!isFirstCommand) { + indent += this.opts.commandTab; + } + } + + if (oneArgumentPerLine) { + lines = 1; + txt = ctx instanceof CommandVisitorContext ? indent : '\n' + indent; + let i = 0; + for (const arg of ctx.visitArguments({ + indent, + remaining: this.opts.wrap - indent.length, + })) { + const isFirstArg = i === 0; + const separator = isFirstArg ? '' : ',\n' + indent; + txt += separator + arg.txt; + lines++; + i++; + } + } + + return { txt, lines, indent, oneArgumentPerLine }; + } + + protected readonly visitor = new Visitor() + .on('visitExpression', (ctx, inp: Input): Output => { + const txt = ctx.node.text ?? ''; + return { txt }; + }) + + .on( + 'visitSourceExpression', + (ctx, inp: Input): Output => ({ txt: LeafPrinter.source(ctx.node) }) + ) + + .on( + 'visitColumnExpression', + (ctx, inp: Input): Output => ({ txt: LeafPrinter.column(ctx.node) }) + ) + + .on( + 'visitLiteralExpression', + (ctx, inp: Input): Output => ({ txt: LeafPrinter.literal(ctx.node) }) + ) + + .on( + 'visitTimeIntervalLiteralExpression', + (ctx, inp: Input): Output => ({ txt: LeafPrinter.timeInterval(ctx.node) }) + ) + + .on('visitInlineCastExpression', (ctx, inp: Input): Output => { + const value = ctx.value(); + const wrapInBrackets = + value.type !== 'literal' && + value.type !== 'column' && + !(value.type === 'function' && value.subtype === 'variadic-call'); + const castType = ctx.node.castType; + + let valueFormatted = ctx.visitValue({ + indent: inp.indent, + remaining: inp.remaining - castType.length - 2, + }).txt; + + if (wrapInBrackets) { + valueFormatted = `(${valueFormatted})`; + } + + const txt = `${valueFormatted}::${ctx.node.castType}`; + + return { txt }; + }) + + .on('visitRenameExpression', (ctx, inp: Input): Output => { + const operator = this.keyword('AS'); + return this.visitBinaryExpression(ctx, operator, inp); + }) + + .on('visitListLiteralExpression', (ctx, inp: Input): Output => { + let elements = ''; + + for (const out of ctx.visitElements()) { + elements += (elements ? ', ' : '') + out.txt; + } + + const txt = `[${elements}]`; + return { txt }; + }) + + .on('visitFunctionCallExpression', (ctx, inp: Input): Output => { + const node = ctx.node; + let operator = ctx.operator(); + let txt: string = ''; + + if (this.opts.lowercaseFunctions ?? this.opts.lowercase) { + operator = operator.toLowerCase(); + } + + switch (node.subtype) { + case 'unary-expression': { + txt = `${operator} ${ctx.visitArgument(0, inp).txt}`; + break; + } + case 'postfix-unary-expression': { + txt = `${ctx.visitArgument(0, inp).txt} ${operator}`; + break; + } + case 'binary-expression': { + return this.visitBinaryExpression(ctx, operator, inp); + } + default: { + const args = this.printArguments(ctx, { + indent: inp.indent, + remaining: inp.remaining - operator.length - 1, + }); + + txt = `${operator}(${args.txt})`; + } + } + + return { txt }; + }) + + .on('visitCommandOption', (ctx, inp: Input): Output => { + const option = this.opts.lowercaseOptions ? ctx.node.name : ctx.node.name.toUpperCase(); + const args = this.printArguments(ctx, { + indent: inp.indent, + remaining: inp.remaining - option.length - 1, + }); + const argsFormatted = args.txt ? ` ${args.txt}` : ''; + const txt = `${option}${argsFormatted}`; + + return { txt, lines: args.lines }; + }) + + .on('visitCommand', (ctx, inp: Input): Output => { + const opts = this.opts; + const cmd = opts.lowercaseCommands ? ctx.node.name : ctx.node.name.toUpperCase(); + const args = this.printArguments(ctx, { + indent: inp.indent, + remaining: inp.remaining - cmd.length - 1, + }); + const optionIndent = args.indent + opts.pipeTab; + const optionsTxt: string[] = []; + + let options = ''; + let optionsLines = 0; + let breakOptions = false; + + for (const out of ctx.visitOptions({ + indent: optionIndent, + remaining: opts.wrap - optionIndent.length, + })) { + optionsLines += out.lines ?? 1; + optionsTxt.push(out.txt); + options += (options ? ' ' : '') + out.txt; + } + + breakOptions = + breakOptions || + args.lines > 1 || + optionsLines > 1 || + options.length > opts.wrap - inp.remaining - cmd.length - 1 - args.txt.length; + + if (breakOptions) { + options = optionsTxt.join('\n' + optionIndent); + } + + const argsWithWhitespace = args.txt + ? `${args.oneArgumentPerLine ? '\n' : ' '}${args.txt}` + : ''; + const optionsWithWhitespace = options + ? `${breakOptions ? '\n' + optionIndent : ' '}${options}` + : ''; + const txt = `${cmd}${argsWithWhitespace}${optionsWithWhitespace}`; + + return { txt, lines: args.lines /* add options lines count */ }; + }) + + .on('visitQuery', (ctx) => { + const opts = this.opts; + const indent = opts.indent ?? ''; + const commandCount = ctx.node.length; + let multiline = opts.multiline ?? commandCount > 3; + + if (!multiline) { + const oneLine = indent + BasicPrettyPrinter.print(ctx.node, opts); + if (oneLine.length <= opts.wrap) { + return oneLine; + } else { + multiline = true; + } + } + + let text = indent; + const cmdSeparator = multiline ? `\n${indent}${opts.pipeTab ?? ' '}| ` : ' | '; + let i = 0; + let prevOut: Output | undefined; + + for (const out of ctx.visitCommands({ indent, remaining: opts.wrap - indent.length })) { + const isSecondCommand = i === 1; + if (isSecondCommand) { + const firstCommandIsMultiline = prevOut?.lines && prevOut.lines > 1; + if (firstCommandIsMultiline) text += '\n' + indent; + } + const isFirstCommand = i === 0; + if (!isFirstCommand) text += cmdSeparator; + text += out.txt; + i++; + prevOut = out; + } + + return text; + }); + + public print(query: ESQLAstQueryNode) { + return this.visitor.visitQuery(query); + } +} diff --git a/packages/kbn-esql-ast/src/types.ts b/packages/kbn-esql-ast/src/types.ts index 12496835ea12b..b256556a58062 100644 --- a/packages/kbn-esql-ast/src/types.ts +++ b/packages/kbn-esql-ast/src/types.ts @@ -72,6 +72,14 @@ export interface ESQLCommandOption extends ESQLAstBaseItem { args: ESQLAstItem[]; } +/** + * Right now rename expressions ("clauses") are parsed as options in the + * RENAME command. + */ +export interface ESQLAstRenameExpression extends ESQLCommandOption { + name: 'as'; +} + export interface ESQLCommandMode extends ESQLAstBaseItem { type: 'mode'; } diff --git a/packages/kbn-esql-ast/src/visitor/__tests__/scenarios.test.ts b/packages/kbn-esql-ast/src/visitor/__tests__/scenarios.test.ts index ce338e8bd72ba..d0e597ea553de 100644 --- a/packages/kbn-esql-ast/src/visitor/__tests__/scenarios.test.ts +++ b/packages/kbn-esql-ast/src/visitor/__tests__/scenarios.test.ts @@ -65,12 +65,12 @@ test('can remove a specific WHERE command', () => { const print = () => new Visitor() + .on('visitExpression', (ctx) => '') .on('visitColumnExpression', (ctx) => ctx.node.name) .on( 'visitFunctionCallExpression', (ctx) => `${ctx.node.name}(${[...ctx.visitArguments()].join(', ')})` ) - .on('visitExpression', (ctx) => '') .on('visitCommand', (ctx) => { if (ctx.node.name === 'where') { const args = [...ctx.visitArguments()].join(', '); @@ -84,12 +84,12 @@ test('can remove a specific WHERE command', () => { const removeFilter = (field: string) => { query.ast = new Visitor() + .on('visitExpression', (ctx) => ctx.node) .on('visitColumnExpression', (ctx) => (ctx.node.name === field ? null : ctx.node)) .on('visitFunctionCallExpression', (ctx) => { const args = [...ctx.visitArguments()]; return args.some((arg) => arg === null) ? null : ctx.node; }) - .on('visitExpression', (ctx) => ctx.node) .on('visitCommand', (ctx) => { if (ctx.node.name === 'where') { ctx.node.args = [...ctx.visitArguments()].filter(Boolean); @@ -116,6 +116,9 @@ test('can remove a specific WHERE command', () => { export const prettyPrint = (ast: ESQLAstQueryNode) => new Visitor() + .on('visitExpression', (ctx) => { + return ''; + }) .on('visitSourceExpression', (ctx) => { return ctx.node.name; }) @@ -141,9 +144,6 @@ export const prettyPrint = (ast: ESQLAstQueryNode) => .on('visitInlineCastExpression', (ctx) => { return ''; }) - .on('visitExpression', (ctx) => { - return ''; - }) .on('visitCommandOption', (ctx) => { let args = ''; for (const arg of ctx.visitArguments()) { diff --git a/packages/kbn-esql-ast/src/visitor/contexts.ts b/packages/kbn-esql-ast/src/visitor/contexts.ts index a9e690b6067d2..376825f88577f 100644 --- a/packages/kbn-esql-ast/src/visitor/contexts.ts +++ b/packages/kbn-esql-ast/src/visitor/contexts.ts @@ -16,6 +16,7 @@ import type { ESQLAstCommand, ESQLAstItem, ESQLAstNodeWithArgs, + ESQLAstRenameExpression, ESQLColumn, ESQLCommandOption, ESQLDecimalLiteral, @@ -24,6 +25,7 @@ import type { ESQLIntegerLiteral, ESQLList, ESQLLiteral, + ESQLSingleAstItem, ESQLSource, ESQLTimeInterval, } from '../types'; @@ -35,7 +37,9 @@ import type { ExpressionVisitorOutput, UndefinedToVoid, VisitorAstNode, + VisitorInput, VisitorMethods, + VisitorOutput, } from './types'; import { Builder } from '../builder'; @@ -66,8 +70,8 @@ export class VisitorContext< ) {} public *visitArguments( - input: ExpressionVisitorInput - ): Iterable> { + input: VisitorInput + ): Iterable> { this.ctx.assertMethodExists('visitExpression'); const node = this.node; @@ -77,14 +81,33 @@ export class VisitorContext< } for (const arg of singleItems(node.args)) { + if (arg.type === 'option' && arg.name !== 'as') { + continue; + } yield this.visitExpression(arg, input as any); } } + public arguments(): ESQLAstExpressionNode[] { + const node = this.node; + + if (!isNodeWithArgs(node)) { + throw new Error('Node does not have arguments'); + } + + const args: ESQLAstExpressionNode[] = []; + + for (const arg of singleItems(node.args)) { + args.push(arg); + } + + return args; + } + public visitArgument( index: number, - input: ExpressionVisitorInput - ): ExpressionVisitorOutput { + input: VisitorInput + ): VisitorOutput { this.ctx.assertMethodExists('visitExpression'); const node = this.node; @@ -106,8 +129,8 @@ export class VisitorContext< public visitExpression( expressionNode: ESQLAstExpressionNode, - input: ExpressionVisitorInput - ): ExpressionVisitorOutput { + input: VisitorInput + ): VisitorOutput { return this.ctx.visitExpression(this, expressionNode, input); } @@ -154,6 +177,8 @@ export class CommandVisitorContext< continue; } if (arg.type === 'option') { + // We treat "AS" options as rename expressions, not as command options. + if (arg.name === 'as') continue; yield arg; } } @@ -172,7 +197,7 @@ export class CommandVisitorContext< } } - public *arguments(option: '' | string = ''): Iterable { + public *args(option: '' | string = ''): Iterable { option = option.toLowerCase(); if (!option) { @@ -183,6 +208,9 @@ export class CommandVisitorContext< } if (arg.type !== 'option') { yield arg; + } else if (arg.name === 'as') { + // We treat "AS" options as rename expressions, not as command options. + yield arg; } } } @@ -196,20 +224,21 @@ export class CommandVisitorContext< } } - public *visitArguments( - input: ExpressionVisitorInput, + public *visitArgs( + input: + | VisitorInput + | (() => VisitorInput), option: '' | string = '' ): Iterable> { this.ctx.assertMethodExists('visitExpression'); - const node = this.node; - - if (!isNodeWithArgs(node)) { - throw new Error('Node does not have arguments'); - } - - for (const arg of singleItems(this.arguments(option))) { - yield this.visitExpression(arg, input as any); + for (const arg of singleItems(this.args(option))) { + yield this.visitExpression( + arg, + typeof input === 'function' + ? (input as () => VisitorInput)() + : (input as VisitorInput) + ); } } @@ -441,7 +470,25 @@ export class SourceExpressionVisitorContext< export class FunctionCallExpressionVisitorContext< Methods extends VisitorMethods = VisitorMethods, Data extends SharedData = SharedData -> extends VisitorContext {} +> extends VisitorContext { + /** + * @returns Returns a printable uppercase function name or operator. + */ + public operator(): string { + const operator = this.node.name; + + switch (operator) { + case 'note_like': { + return 'NOT LIKE'; + } + case 'not_rlike': { + return 'NOT RLIKE'; + } + } + + return operator.toUpperCase(); + } +} export class LiteralExpressionVisitorContext< Methods extends VisitorMethods = VisitorMethods, @@ -468,23 +515,30 @@ export class ListLiteralExpressionVisitorContext< export class TimeIntervalLiteralExpressionVisitorContext< Methods extends VisitorMethods = VisitorMethods, Data extends SharedData = SharedData -> extends ExpressionVisitorContext { - format(): string { - const node = this.node; - - return `${node.quantity}${node.unit}`; - } -} +> extends ExpressionVisitorContext {} export class InlineCastExpressionVisitorContext< Methods extends VisitorMethods = VisitorMethods, Data extends SharedData = SharedData > extends ExpressionVisitorContext { - public visitValue(input: ExpressionVisitorInput): ExpressionVisitorOutput { + public value(): ESQLSingleAstItem { this.ctx.assertMethodExists('visitExpression'); const value = firstItem([this.node.value])!; - return this.visitExpression(value, input as any); + return value; + } + + public visitValue( + input: VisitorInput + ): VisitorOutput { + this.ctx.assertMethodExists('visitExpression'); + + return this.visitExpression(this.value(), input as any); } } + +export class RenameExpressionVisitorContext< + 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 d05a4ce326eb7..e1ca7f6677d24 100644 --- a/packages/kbn-esql-ast/src/visitor/global_visitor_context.ts +++ b/packages/kbn-esql-ast/src/visitor/global_visitor_context.ts @@ -9,6 +9,7 @@ import * as contexts from './contexts'; import type { ESQLAstCommand, + ESQLAstRenameExpression, ESQLColumn, ESQLFunction, ESQLInlineCast, @@ -398,6 +399,18 @@ export class GlobalVisitorContext< if (!this.methods.visitInlineCastExpression) break; return this.visitInlineCastExpression(parent, expressionNode, input as any); } + case 'option': { + switch (expressionNode.name) { + case 'as': { + if (!this.methods.visitRenameExpression) break; + return this.visitRenameExpression( + parent, + expressionNode as ESQLAstRenameExpression, + input as any + ); + } + } + } } return this.visitExpressionGeneric(parent, expressionNode, input as any); } @@ -464,4 +477,13 @@ export class GlobalVisitorContext< const context = new contexts.InlineCastExpressionVisitorContext(this, node, parent); return this.visitWithSpecificContext('visitInlineCastExpression', context, input); } + + public visitRenameExpression( + parent: contexts.VisitorContext | null, + node: ESQLAstRenameExpression, + input: types.VisitorInput + ): types.VisitorOutput { + const context = new contexts.RenameExpressionVisitorContext(this, node, parent); + return this.visitWithSpecificContext('visitRenameExpression', context, input); + } } diff --git a/packages/kbn-esql-ast/src/visitor/types.ts b/packages/kbn-esql-ast/src/visitor/types.ts index a8ec5e9bd1785..beb0aed3570b2 100644 --- a/packages/kbn-esql-ast/src/visitor/types.ts +++ b/packages/kbn-esql-ast/src/visitor/types.ts @@ -59,7 +59,8 @@ export type ExpressionVisitorInput = AnyToVoid< VisitorInput & VisitorInput & VisitorInput & - VisitorInput + VisitorInput & + VisitorInput >; /** @@ -73,7 +74,8 @@ export type ExpressionVisitorOutput = | VisitorOutput | VisitorOutput | VisitorOutput - | VisitorOutput; + | VisitorOutput + | VisitorOutput; /** * Input that satisfies any command visitor input constraints. @@ -195,6 +197,11 @@ export interface VisitorMethods< any, any >; + visitRenameExpression?: Visitor< + contexts.RenameExpressionVisitorContext, + any, + any + >; } /** @@ -222,22 +229,6 @@ export type AstNodeToVisitorName = Node extends ESQ ? 'visitInlineCastExpression' : never; -/** - * Maps any AST node to the corresponding visitor context. - */ -export type AstNodeToVisitor< - Node extends VisitorAstNode, - Methods extends VisitorMethods = VisitorMethods -> = Methods[AstNodeToVisitorName]; - -/** - * Maps any AST node to its corresponding visitor context. - */ -export type AstNodeToContext< - Node extends VisitorAstNode, - Methods extends VisitorMethods = VisitorMethods -> = Parameters>>[0]; - /** * Asserts that a type is a function. */ diff --git a/packages/kbn-esql-ast/src/visitor/visitor.ts b/packages/kbn-esql-ast/src/visitor/visitor.ts index 3956fe126723e..1a4554f1f2cf4 100644 --- a/packages/kbn-esql-ast/src/visitor/visitor.ts +++ b/packages/kbn-esql-ast/src/visitor/visitor.ts @@ -12,10 +12,12 @@ import { VisitorContext } from './contexts'; import type { AstNodeToVisitorName, EnsureFunction, + ESQLAstExpressionNode, ESQLAstQueryNode, UndefinedToVoid, VisitorMethods, } from './types'; +import { ESQLCommand } from '../types'; export interface VisitorOptions< Methods extends VisitorMethods = VisitorMethods, @@ -86,6 +88,7 @@ export class Visitor< * Traverse the root node of ES|QL query with default context. * * @param node Query node to traverse. + * @param input Input to pass to the first visitor. * @returns The result of the query visitor. */ public visitQuery( @@ -95,4 +98,34 @@ export class Visitor< const queryContext = new QueryVisitorContext(this.ctx, node, null); return this.visit(queryContext, input); } + + /** + * Traverse starting from known command node with default context. + * + * @param node Command node to traverse. + * @param input Input to pass to the first visitor. + * @returns The output of the visitor. + */ + public visitCommand( + node: ESQLCommand, + input: UndefinedToVoid>[1]> + ) { + this.ctx.assertMethodExists('visitCommand'); + return this.ctx.visitCommand(null, node, input); + } + + /** + * Traverse starting from known expression node with default context. + * + * @param node Expression node to traverse. + * @param input Input to pass to the first visitor. + * @returns The output of the visitor. + */ + public visitExpression( + node: ESQLAstExpressionNode, + input: UndefinedToVoid>[1]> + ) { + this.ctx.assertMethodExists('visitExpression'); + return this.ctx.visitExpression(null, node, input); + } }