mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[ES|QL] Implements wrapping pretty-printer (#190589)
## Summary Partially addresses https://github.com/elastic/kibana/issues/182257 - Improves the basic "one-line" printer `BasicPrettyPrinter`, notable changes: - It is now possible better specify if query keywords should be uppercased - Better formatting columns names, adds backquotes when escaping needed: `` `name👍` `` - Wraps cast expressions into brackets, where needed: `(1 + 2)::string` instead of `1 + 2::string` - Adds initial implementations of the more complex `WrappingPrettyPrinter`. - "Initial implementation" because it probably covers 80-90% of the cases, some follow up will be needed. - The `WrappingPrettyPrinter` formats the query like `Prettier`, it tries to format AST nodes horizontally as lists, but based on various conditions breaks the lines and indents them. #### Cases handled by the `WrappingPrettyPrinter` Below are examples of some of the cases handled by the `WrappingPrettyPrinter`. (See test files for many more cases.) ##### Short queries Queries with less than 4 commands and if they do not require wrapping are formatted to a single line. Source: ``` FROM index | WHERE a == 123 ``` Result: ``` FROM index | WHERE a == 123 ``` ##### Argument wrapping Command arguments are wrapped (at wrapping threshold, defaults to 80). Source: ``` FROM index, another_index, yet_another_index, on-more-index, last_index, very_last_index, ok_this_is_the_last_index ``` Result: ``` FROM index, another_index, yet_another_index, on-more-index, last_index, very_last_index, ok_this_is_the_last_index ``` ##### Argument breaking Command argument combinations which result into a single argument occupying a whole line (due to that argument being long, or because the surrounding argument combination results into such a case), except the last argument, results into the argument list being broken by line. Source: ``` FROM xxxxxxxxxx, yyyyyyyyyyy, zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz, aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa, // <------------ this one bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb,ccccccc
, ggggggggg ``` Result: ``` FROM xxxxxxxxxx, yyyyyyyyyyy, zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz, aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa, bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb,ccccccc
, ggggggggg ``` ##### Binary expression chain vertical flattening Binary expressions of the same precedence are vertically flattened, if wrapping is required. Same as it is done by `Prettier`, where there is an indentation after the first line to allow for different precedence expressions. ###### All expressions have the same precedence Source: ``` FROM index | STATS super_function_name(11111111111111.111 + 11111111111111.111 + 11111111111111.111 + 11111111111111.111 + 11111111111111.111)) ``` Result: ``` FROM index | STATS SUPER_FUNCTION_NAME( 11111111111111.111 + 11111111111111.111 + 11111111111111.111 + 11111111111111.111 + 11111111111111.111) ``` ###### Expressions with `additive` and `multiplicative` precedence mixed Source: ``` FROM index | STATS super_function_name(11111111111111.111 + 3333333333333.3333 * 3333333333333.3333 * 3333333333333.3333 * 3333333333333.3333 + 11111111111111.111 + 11111111111111.111 + 11111111111111.111 + 11111111111111.111)) ``` Result: ``` FROM index | STATS SUPER_FUNCTION_NAME( 11111111111111.111 + 3333333333333.3335 * 3333333333333.3335 * 3333333333333.3335 * 3333333333333.3335 + 11111111111111.111 + 11111111111111.111 + 11111111111111.111 + 11111111111111.111) ``` ### Checklist Delete any items that are not applicable to this PR. - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios ### For maintainers - [x] This was checked for breaking API changes and was [labeled appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) --------- Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
parent
7027b0b4b3
commit
8316cbf019
19 changed files with 2245 additions and 550 deletions
|
@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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));
|
||||
});
|
||||
});
|
19
packages/kbn-esql-ast/src/ast/constants.ts
Normal file
19
packages/kbn-esql-ast/src/ast/constants.ts
Normal file
|
@ -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,
|
||||
}
|
69
packages/kbn-esql-ast/src/ast/helpers.ts
Normal file
69
packages/kbn-esql-ast/src/ast/helpers.ts
Normal file
|
@ -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;
|
||||
};
|
23
packages/kbn-esql-ast/src/pretty_print/README.md
Normal file
23
packages/kbn-esql-ast/src/pretty_print/README.md
Normal file
|
@ -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.
|
|
@ -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)');
|
||||
});
|
||||
});
|
66
packages/kbn-esql-ast/src/pretty_print/__tests__/fixtures.ts
Normal file
66
packages/kbn-esql-ast/src/pretty_print/__tests__/fixtures.ts
Normal file
|
@ -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\`
|
||||
`;
|
|
@ -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`);
|
||||
});
|
||||
});
|
||||
});
|
266
packages/kbn-esql-ast/src/pretty_print/basic_pretty_printer.ts
Normal file
266
packages/kbn-esql-ast/src/pretty_print/basic_pretty_printer.ts
Normal file
|
@ -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<BasicPrettyPrinterOptions, 'multiline'>;
|
||||
|
||||
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<BasicPrettyPrinterOptions>;
|
||||
|
||||
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 '<EXPRESSION>';
|
||||
})
|
||||
.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);
|
||||
}
|
||||
}
|
93
packages/kbn-esql-ast/src/pretty_print/leaf_printer.ts
Normal file
93
packages/kbn-esql-ast/src/pretty_print/leaf_printer.ts
Normal file
|
@ -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}`;
|
||||
}
|
||||
},
|
||||
};
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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 ?? '<EXPRESSION>';
|
||||
})
|
||||
.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);
|
||||
};
|
|
@ -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<WrappingPrettyPrinterOptions>;
|
||||
|
||||
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 ?? '<EXPRESSION>';
|
||||
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);
|
||||
}
|
||||
}
|
|
@ -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';
|
||||
}
|
||||
|
|
|
@ -65,12 +65,12 @@ test('can remove a specific WHERE command', () => {
|
|||
|
||||
const print = () =>
|
||||
new Visitor()
|
||||
.on('visitExpression', (ctx) => '<expr>')
|
||||
.on('visitColumnExpression', (ctx) => ctx.node.name)
|
||||
.on(
|
||||
'visitFunctionCallExpression',
|
||||
(ctx) => `${ctx.node.name}(${[...ctx.visitArguments()].join(', ')})`
|
||||
)
|
||||
.on('visitExpression', (ctx) => '<expr>')
|
||||
.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 '<EXPRESSION>';
|
||||
})
|
||||
.on('visitSourceExpression', (ctx) => {
|
||||
return ctx.node.name;
|
||||
})
|
||||
|
@ -141,9 +144,6 @@ export const prettyPrint = (ast: ESQLAstQueryNode) =>
|
|||
.on('visitInlineCastExpression', (ctx) => {
|
||||
return '<CAST>';
|
||||
})
|
||||
.on('visitExpression', (ctx) => {
|
||||
return '<EXPRESSION>';
|
||||
})
|
||||
.on('visitCommandOption', (ctx) => {
|
||||
let args = '';
|
||||
for (const arg of ctx.visitArguments()) {
|
||||
|
|
|
@ -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<Methods>
|
||||
): Iterable<ExpressionVisitorOutput<Methods>> {
|
||||
input: VisitorInput<Methods, 'visitExpression'>
|
||||
): Iterable<VisitorOutput<Methods, 'visitExpression'>> {
|
||||
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<Methods>
|
||||
): ExpressionVisitorOutput<Methods> {
|
||||
input: VisitorInput<Methods, 'visitExpression'>
|
||||
): VisitorOutput<Methods, 'visitExpression'> {
|
||||
this.ctx.assertMethodExists('visitExpression');
|
||||
|
||||
const node = this.node;
|
||||
|
@ -106,8 +129,8 @@ export class VisitorContext<
|
|||
|
||||
public visitExpression(
|
||||
expressionNode: ESQLAstExpressionNode,
|
||||
input: ExpressionVisitorInput<Methods>
|
||||
): ExpressionVisitorOutput<Methods> {
|
||||
input: VisitorInput<Methods, 'visitExpression'>
|
||||
): VisitorOutput<Methods, 'visitExpression'> {
|
||||
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<ESQLAstItem> {
|
||||
public *args(option: '' | string = ''): Iterable<ESQLAstItem> {
|
||||
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<Methods>,
|
||||
public *visitArgs(
|
||||
input:
|
||||
| VisitorInput<Methods, 'visitExpression'>
|
||||
| (() => VisitorInput<Methods, 'visitExpression'>),
|
||||
option: '' | string = ''
|
||||
): Iterable<ExpressionVisitorOutput<Methods>> {
|
||||
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<Methods, 'visitExpression'>)()
|
||||
: (input as VisitorInput<Methods, 'visitExpression'>)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -441,7 +470,25 @@ export class SourceExpressionVisitorContext<
|
|||
export class FunctionCallExpressionVisitorContext<
|
||||
Methods extends VisitorMethods = VisitorMethods,
|
||||
Data extends SharedData = SharedData
|
||||
> extends VisitorContext<Methods, Data, ESQLFunction> {}
|
||||
> extends VisitorContext<Methods, Data, ESQLFunction> {
|
||||
/**
|
||||
* @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<Methods, Data, ESQLTimeInterval> {
|
||||
format(): string {
|
||||
const node = this.node;
|
||||
|
||||
return `${node.quantity}${node.unit}`;
|
||||
}
|
||||
}
|
||||
> extends ExpressionVisitorContext<Methods, Data, ESQLTimeInterval> {}
|
||||
|
||||
export class InlineCastExpressionVisitorContext<
|
||||
Methods extends VisitorMethods = VisitorMethods,
|
||||
Data extends SharedData = SharedData
|
||||
> extends ExpressionVisitorContext<Methods, Data, ESQLInlineCast> {
|
||||
public visitValue(input: ExpressionVisitorInput<Methods>): ExpressionVisitorOutput<Methods> {
|
||||
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<Methods, 'visitExpression'>
|
||||
): VisitorOutput<Methods, 'visitExpression'> {
|
||||
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<Methods, Data, ESQLAstRenameExpression> {}
|
||||
|
|
|
@ -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<Methods, 'visitRenameExpression'>
|
||||
): types.VisitorOutput<Methods, 'visitRenameExpression'> {
|
||||
const context = new contexts.RenameExpressionVisitorContext(this, node, parent);
|
||||
return this.visitWithSpecificContext('visitRenameExpression', context, input);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -59,7 +59,8 @@ export type ExpressionVisitorInput<Methods extends VisitorMethods> = AnyToVoid<
|
|||
VisitorInput<Methods, 'visitLiteralExpression'> &
|
||||
VisitorInput<Methods, 'visitListLiteralExpression'> &
|
||||
VisitorInput<Methods, 'visitTimeIntervalLiteralExpression'> &
|
||||
VisitorInput<Methods, 'visitInlineCastExpression'>
|
||||
VisitorInput<Methods, 'visitInlineCastExpression'> &
|
||||
VisitorInput<Methods, 'visitRenameExpression'>
|
||||
>;
|
||||
|
||||
/**
|
||||
|
@ -73,7 +74,8 @@ export type ExpressionVisitorOutput<Methods extends VisitorMethods> =
|
|||
| VisitorOutput<Methods, 'visitLiteralExpression'>
|
||||
| VisitorOutput<Methods, 'visitListLiteralExpression'>
|
||||
| VisitorOutput<Methods, 'visitTimeIntervalLiteralExpression'>
|
||||
| VisitorOutput<Methods, 'visitInlineCastExpression'>;
|
||||
| VisitorOutput<Methods, 'visitInlineCastExpression'>
|
||||
| VisitorOutput<Methods, 'visitRenameExpression'>;
|
||||
|
||||
/**
|
||||
* Input that satisfies any command visitor input constraints.
|
||||
|
@ -195,6 +197,11 @@ export interface VisitorMethods<
|
|||
any,
|
||||
any
|
||||
>;
|
||||
visitRenameExpression?: Visitor<
|
||||
contexts.RenameExpressionVisitorContext<Visitors, Data>,
|
||||
any,
|
||||
any
|
||||
>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -222,22 +229,6 @@ export type AstNodeToVisitorName<Node extends VisitorAstNode> = 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<Node>];
|
||||
|
||||
/**
|
||||
* Maps any AST node to its corresponding visitor context.
|
||||
*/
|
||||
export type AstNodeToContext<
|
||||
Node extends VisitorAstNode,
|
||||
Methods extends VisitorMethods = VisitorMethods
|
||||
> = Parameters<EnsureFunction<AstNodeToVisitor<Node, Methods>>>[0];
|
||||
|
||||
/**
|
||||
* Asserts that a type is a function.
|
||||
*/
|
||||
|
|
|
@ -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<Parameters<NonNullable<Methods['visitCommand']>>[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<Parameters<NonNullable<Methods['visitExpression']>>[1]>
|
||||
) {
|
||||
this.ctx.assertMethodExists('visitExpression');
|
||||
return this.ctx.visitExpression(null, node, input);
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue