Skip to content

Commit

Permalink
[ES|QL] AST node synthesizer (elastic#201814)
Browse files Browse the repository at this point in the history
## Summary

Closes elastic#191812

Adds ability to create ES|QL AST nodes from plain strings and compose
them.

Create an integer literal:

```js
const node = expr `42`;
```

Create any expression:

```js
const node = expr `nested.field = fn(123)`;
```

Compose AST nodes:

```js
const value = expr `123`;
const node = expr `nested.field = fn(${value})`;
```

## Usage

You can create an assignment expression AST node as simle as:

```ts
import { synth } from '@kbn/esql-ast';

const node = synth.expr `my.field = max(10, ?my_param)`;
// { type: 'function', name: '=', args: [ ... ]}
```

To construct an equivalent AST node using the `Builder` class, you would
need to
write the following code:

```ts
import { Builder } from '@kbn/esql-ast';

const node = Builder.expression.func.binary('=', [
  Builder.expression.column({
    args: [Builder.identifier({ name: 'my' }), Builder.identifier({ name: 'field' })],
  }),
  Builder.expression.func.call('max', [
    Builder.expression.literal.integer(10),
    Builder.param.named({ value: 'my_param' }),
  ]),
]);
// { type: 'function', name: '=', args: [ ... ]}
```

You can nest template strings to create more complex AST nodes:

```ts
const field = synth.expr `my.field`;
const value = synth.expr `max(10, 20)`;

const assignment = synth.expr`${field} = ${value}`;
// { type: 'function', name: '=', args: [ 
//     { type: 'column', args: [ ... ] },
//     { type: 'function', name: 'max', args: [ ... ] }
// ]}
```

Use the `synth.cmd` method to create command nodes:

```ts
const command = synth.cmd `WHERE my.field == 10`;
// { type: 'command', name: 'WHERE', args: [ ... ]}
```

AST nodes created by the synthesizer are pretty-printed when you coerce
them to
a string or call the `toString` method:

```ts
const command = synth.cmd ` WHERE my.field == 10 `; // { type: 'command', ... }
String(command); // "WHERE my.field == 10"
```


## Reference

### `synth.expr`

The `synth.expr` synthesizes an expression AST nodes. (*Expressions* are
basically any thing that can go into a `WHERE` command, like boolean
expression,
function call, literal, params, etc.)

Use it as a function:

```ts
const node = synth.expr('my.field = max(10, 20)');
```

Specify parser options:

```ts
const node = synth.expr('my.field = max(10, 20)', { withFormatting: false });
```

Use it as a template string tag:

```ts
const node = synth.expr `my.field = max(10, 20)`;
```

Specify parser options, when using as a template string tag:

```ts
const node = synth.expr({ withFormatting: false }) `my.field = max(10, 20)`;
```

Combine nodes using template strings:

```ts
const field = synth.expr `my.field`;
const node = synth.expr `${field} = max(10, 20)`;
```

Print the node as a string:

```ts
const node = synth.expr `my.field = max(10, 20)`;
String(node); // 'my.field = max(10, 20)'
```


### `synth.cmd`

The `synth.cmd` synthesizes a command AST node (such as `SELECT`,
`WHERE`,
etc.). You use it the same as the `synth.expr` function or template
string tag.
The only difference is that the `synth.cmd` function or tag creates a
command
AST node.

```ts
const node = synth.cmd `WHERE my.field == 10`;
// { type: 'command', name: 'where', args: [ ... ]}
```



### 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#_add_your_labels)
  • Loading branch information
vadimkibana authored Dec 2, 2024
1 parent 6ef4912 commit 378002f
Show file tree
Hide file tree
Showing 16 changed files with 658 additions and 12 deletions.
24 changes: 21 additions & 3 deletions packages/kbn-esql-ast/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,32 @@ building, traversal, pretty-printing, and manipulation features on top of a
custom compact AST representation, which is designed to be resilient to many
grammar changes.

Contents of this package:

- [`builder` — Contains the `Builder` class for AST node construction](./src/builder/README.md).
### Contents of the package

At the lowest level, the package provides a parser that converts ES|QL text into
an AST representation. Or, you can use the `Builder` class to construct the AST
manually:

- [`parser` — Contains text to ES|QL AST parsing code](./src/parser/README.md).
- [`builder` — Contains the `Builder` class for AST node construction](./src/builder/README.md).

The *Traversal API* lets you walk the AST. The `Walker` class is a simple
to use, but the `Visitor` class is more powerful and flexible:

- [`walker` — Contains the ES|QL AST `Walker` utility](./src/walker/README.md).
- [`visitor` — Contains the ES|QL AST `Visitor` utility](./src/visitor/README.md).

Higher-level functionality is provided by the `mutate` and `synth` modules. They
allow you to traverse and modify the AST, or to easily construct AST nodes:

- [`mutate` — Contains code for traversing and mutating the AST](./src/mutate/README.md).
- [`synth` — Ability to construct AST nodes from template strings](./src/synth/README.md).

The *Pretty-printing API* lets you format the AST to text. There are two
implementations — a basic pretty-printer and a wrapping pretty-printer:

- [`pretty_print` — Contains code for formatting AST to text](./src/pretty_print/README.md).
- [`mutate` — Contains code for traversing and mutating the AST.](./src/mutate/README.md).


## Demo
Expand Down
1 change: 1 addition & 0 deletions packages/kbn-esql-ast/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export {
} from './src/parser';

export { Walker, type WalkerOptions, walk } from './src/walker';
export * as synth from './src/synth';

export {
LeafPrinter,
Expand Down
124 changes: 124 additions & 0 deletions packages/kbn-esql-ast/src/synth/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
# ES|QL `synth` module

The `synth` module lets you easily "synthesize" AST nodes from template strings.
This is useful when you need to construct AST nodes programmatically, but don't
want to deal with the complexity of the `Builder` class.


## Usage

You can create an assignment expression AST node as simle as:

```ts
import { synth } from '@kbn/esql-ast';

const node = synth.expr `my.field = max(10, ?my_param)`;
// { type: 'function', name: '=', args: [ ... ]}
```

To construct an equivalent AST node using the `Builder` class, you would need to
write the following code:

```ts
import { Builder } from '@kbn/esql-ast';

const node = Builder.expression.func.binary('=', [
Builder.expression.column({
args: [Builder.identifier({ name: 'my' }), Builder.identifier({ name: 'field' })],
}),
Builder.expression.func.call('max', [
Builder.expression.literal.integer(10),
Builder.param.named({ value: 'my_param' }),
]),
]);
// { type: 'function', name: '=', args: [ ... ]}
```

You can nest template strings to create more complex AST nodes:

```ts
const field = synth.expr `my.field`;
const value = synth.expr `max(10, ?my_param)`;

const assignment = synth.expr`${field} = ${value}`;
// { type: 'function', name: '=', args: [
// { type: 'column', args: [ ... ] },
// { type: 'function', name: 'max', args: [ ... ] }
// ]}
```

Use the `synth.cmd` method to create command nodes:

```ts
const command = synth.cmd `WHERE my.field == 10`;
// { type: 'command', name: 'WHERE', args: [ ... ]}
```

AST nodes created by the synthesizer are pretty-printed when you coerce them to
a string or call the `toString` method:

```ts
const command = synth.cmd ` WHERE my.field == 10 `; // { type: 'command', ... }
String(command); // "WHERE my.field == 10"
```


## Reference

### `synth.expr`

The `synth.expr` synthesizes an expression AST nodes. (*Expressions* are
basically any thing that can go into a `WHERE` command, like boolean expression,
function call, literal, params, etc.)

Use it as a function:

```ts
const node = synth.expr('my.field = max(10, ?my_param)');
```

Specify parser options:

```ts
const node = synth.expr('my.field = max(10, ?my_param)',
{ withFormatting: false });
```

Use it as a template string tag:

```ts
const node = synth.expr `my.field = max(10, ?my_param)`;
```

Specify parser options, when using as a template string tag:

```ts
const node = synth.expr({ withFormatting: false }) `my.field = max(10, 20)`;
```

Combine nodes using template strings:

```ts
const field = synth.expr `my.field`;
const node = synth.expr `${field} = max(10, 20)`;
```

Print the node as a string:

```ts
const node = synth.expr `my.field = max(10, 20)`;
String(node); // 'my.field = max(10, 20)'
```


### `synth.cmd`

The `synth.cmd` synthesizes a command AST node (such as `SELECT`, `WHERE`,
etc.). You use it the same as the `synth.expr` function or template string tag.
The only difference is that the `synth.cmd` function or tag creates a command
AST node.

```ts
const node = synth.cmd `WHERE my.field == 10`;
// { type: 'command', name: 'where', args: [ ... ]}
```
50 changes: 50 additions & 0 deletions packages/kbn-esql-ast/src/synth/__tests__/cmd.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/

import { BasicPrettyPrinter } from '../../pretty_print';
import { cmd } from '../cmd';
import { expr } from '../expr';

test('can create a WHERE command', () => {
const node = cmd`WHERE coordinates.lat >= 12.123123`;
const text = BasicPrettyPrinter.command(node);

expect(text).toBe('WHERE coordinates.lat >= 12.123123');
});

test('can create a complex STATS command', () => {
const node = cmd`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`;
const text = BasicPrettyPrinter.command(node);

expect(text).toBe(
'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'
);
});

test('can create a FROM source command', () => {
const node = cmd`FROM index METADATA _id`;
const text = BasicPrettyPrinter.command(node);

expect(text).toBe('FROM index METADATA _id');
});

test('throws if specified source is not a command', () => {
expect(() => cmd`123`).toThrowError();
});

test('can compose expressions into commands', () => {
const field = expr`a.b.c`;
const cmd1 = cmd` WHERE ${field} == "asdf"`;
const cmd2 = cmd` DISSECT ${field} """%{date}"""`;
const text1 = BasicPrettyPrinter.command(cmd1);
const text2 = BasicPrettyPrinter.command(cmd2);

expect(text1).toBe('WHERE a.b.c == "asdf"');
expect(text2).toBe('DISSECT a.b.c """%{date}"""');
});
148 changes: 148 additions & 0 deletions packages/kbn-esql-ast/src/synth/__tests__/expr_function.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/

import { BasicPrettyPrinter } from '../../pretty_print';
import { ESQLProperNode } from '../../types';
import { Walker } from '../../walker/walker';
import { expr } from '../expr';

test('can generate integer literal', () => {
const node = expr('42');

expect(node).toMatchObject({
type: 'literal',
literalType: 'integer',
name: '42',
value: 42,
});
});

test('can generate integer literal and keep comment', () => {
const node = expr('42 /* my 42 */');

expect(node).toMatchObject({
type: 'literal',
literalType: 'integer',
value: 42,
formatting: {
right: [
{
type: 'comment',
subtype: 'multi-line',
text: ' my 42 ',
},
],
},
});
});

test('can generate a function call expression', () => {
const node = expr('fn(1, "test")');

expect(node).toMatchObject({
type: 'function',
name: 'fn',
args: [
{
type: 'literal',
literalType: 'integer',
value: 1,
},
{
type: 'literal',
literalType: 'keyword',
value: '"test"',
},
],
});
});

test('can generate assignment expression', () => {
const src = 'a.b.c = AGG(123)';
const node = expr(src);
const text = BasicPrettyPrinter.expression(node);

expect(text).toBe(src);
});

test('can generate comparison expression', () => {
const src = 'a.b.c >= FN(123)';
const node = expr(src);
const text = BasicPrettyPrinter.expression(node);

expect(text).toBe(src);
});

describe('can generate various expression types', () => {
const cases: Array<[name: string, src: string]> = [
['integer', '42'],
['negative integer', '-24'],
['zero', '0'],
['float', '3.14'],
['negative float', '-1.23'],
['string', '"doge"'],
['empty string', '""'],
['integer list', '[1, 2, 3]'],
['string list', '["a", "b"]'],
['boolean list', '[TRUE, FALSE]'],
['time interval', '1d'],
['cast', '"doge"::INTEGER'],
['addition', '1 + 2'],
['multiplication', '2 * 2'],
['parens', '2 * (2 + 3)'],
['star function call', 'FN(*)'],
['function call with one argument', 'FN(1)'],
['nested function calls', 'FN(1, MAX("asdf"))'],
['basic field', 'col'],
['nested field', 'a.b.c'],
['unnamed param', '?'],
['named param', '?hello'],
['positional param', '?123'],
['param in nested field', 'a.?b.c'],
['escaped field', '`😎`'],
['nested escaped field', 'emoji.`😎`'],
['simple assignment', 't = NOW()'],
['assignment expression', 'bytes_transform = ROUND(total_bytes / 1000000.0, 1)'],
[
'assignment with time intervals',
'key = CASE(timestamp < (t - 1 hour) AND timestamp > (t - 2 hour), "Last hour", "Other")',
],
[
'assignment with casts',
'total_visits = TO_DOUBLE(COALESCE(count_last_hour, 0::LONG) + COALESCE(count_rest, 0::LONG))',
],
];

for (const [name, src] of cases) {
test(name, () => {
const node = expr(src);
const text = BasicPrettyPrinter.expression(node);

expect(text).toBe(src);
});
}
});

test('parser fields are empty', () => {
const src = 'a.b.c >= FN(123)';
const ast = expr(src);

const assertParserFields = (node: ESQLProperNode) => {
expect(node.location.min).toBe(0);
expect(node.location.max).toBe(0);
expect(node.text).toBe('');
expect(node.incomplete).toBe(false);
};

Walker.walk(ast, {
visitAny: (node: ESQLProperNode) => {
assertParserFields(node);
},
});
});
Loading

0 comments on commit 378002f

Please sign in to comment.