[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:
Vadim Kibana 2024-08-20 11:33:08 +02:00 committed by GitHub
parent 7027b0b4b3
commit 8316cbf019
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 2245 additions and 550 deletions

View file

@ -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',
},
],
},
],
});
});
});

View file

@ -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));
});
});

View 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,
}

View 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;
};

View 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&mdash;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()` &mdash; prints query to a single line.
- `BasicPrettyPrinter.multiline()` &mdash; prints a query to multiple lines.
- `BasicPrettyPrinter.command()` &mdash; prints a command to a single line.
- `BasicPrettyPrinter.expression()` &mdash; prints an expression to a single line.

View file

@ -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)');
});
});

View 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\`
`;

View file

@ -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`);
});
});
});

View 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);
}
}

View 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}`;
}
},
};

View file

@ -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');
});
});
});

View file

@ -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);
};

View file

@ -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);
}
}

View file

@ -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';
}

View file

@ -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()) {

View file

@ -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> {}

View file

@ -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);
}
}

View file

@ -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.
*/

View file

@ -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);
}
}