Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[expressions] AST Builder #64395

Merged
merged 27 commits into from
Jul 14, 2020
Merged

Conversation

lukeelmers
Copy link
Member

@lukeelmers lukeelmers commented Apr 24, 2020

Closes #56748

Prior POCs & discussions: #62109, #62334, #34318

cc @elastic/kibana-canvas, @elastic/kibana-app, @elastic/kibana-app-arch

Summary

Adds two utilities for managing Expression ASTs: buildExpression and buildExpressionFunction. They provide a type-safe mechanism for manipulating & migrating expressions without doing the work of manually traversing the AST.

If you work on an app that uses Expressions, and you find yourself updating an Expression AST directly or -- gasp 😱 -- concatenating Expression strings -- this will make your life easier.

Usage

// App that works with expressions can now build an AST using
// `buildExpressionFunction`, which takes a function name, and object of args:
const fn = buildExpressionFunction('hello', { world: ['whee'] });
fn.addArgument('first', true)
  .addArgument('second', 0)
  .replaceArgument('second', [2])
  .removeArgument('first');

fn.toAst(); // returns function AST

// `buildExpression` takes an expression string, AST,
// or array of `buildExpressionFunction`
const exp = buildExpression([
  buildExpressionFunction('a', {});
  buildExpressionFunction('b', {});
]); // or buildExpression(ast); or buildExpression('a | b');

// add a new function
exp.functions.push(
  buildExpressionFunction('c', { hi: ['friends'] })
);

exp.toAst(); // returns expression AST
exp.toString(); // returns expression string

// You can pass another `buildExpression` to any argument as a subexpression
const exp = buildExpression('foo | bar');
exp.functions.push(
  buildExpressionFunction('baz', {
    subexpressionArg: buildExpression('subexp a=true'),
  })
);

// Type safety provided by generic type params:
const exp = buildExpression([
  buildExpressionFunction<MyFnExpressionFunctionDefinition>('myFn', { hello: [true] });
]);
const fns = exp.findFunction<MyFnExpressionFunctionDefinition>('myFn');

// Eventually the goal is for plugins registering functions to individually export
// their own function builders that return `ExpressionAstFunctionBuilder`, so
// that dependencies stay explicit and you automatically get type safety:
// pluginA
export const myFnBuilder: (args: MyArgs) => ExpressionAstFunctionBuilder<MyFnDef> = (args) => {
  return buildExpressionFunction('myFnName', args);
};
// pluginB
import { myFnBuilder } from '../plugin_a';
const exp = buildExpression([myFnBuilder(args)]);

There's also a buildExpression.findFunction method which aims to make migrations easier by recursively searching for all functions in an expression matching a given name (including subexpressions). It returns references to the buildExpressionFunctions for each of the found functions, which allows you to iterate over each of them to do your migration. There's a unit test showing an example of this, but basically:

// Migrates all `b` functions in the expression to change the `version` arg to 2
const exp = buildExpression('a | b version=1 | c subexp={b version=1}');
exp.findFunction('b').forEach(fn => {
  const arg = fn.getArgument('version');
  if (arg) {
    fn.replaceArgument('version', [2]);
  }
});
exp.toString(); // `a | b version=2 | c subexp={b version=2}`

Checklist

Dev Docs

The expressions plugin has introduced a set of helpers which make it easier to manipulate expression ASTs. Please refer to the PR for more detailed examples.

// also available on `expressions/public/server`
import {
  buildExpression,
  buildExpressionFunction
} from '../../src/plugins/expressions/public';

// `buildExpression` takes an expression string, AST, or array of `buildExpressionFunction`
const exp = buildExpression([
  // `buildExpressionFunction` takes an expression function name, and object of args
  buildExpressionFunction('myFn', { hello: [true] });
]);

const anotherFn = buildExpressionFunction('anotherFn', { world: [false] });
exp.functions.push(anotherFn);
fn.replaceArgument('world', [true]);

exp.toAst(); // prints the latest AST

// you can get added type-safety by providing a generic type argument:
const exp = buildExpression([
  buildExpressionFunction<MyFnExpressionFunctionDefinition>('myFn', { hello: [true] });
]);
const fns = exp.findFunction<MyFnExpressionFunctionDefinition>('myFn');

@lukeelmers lukeelmers changed the title [WIP] Expressions AST Builder [expressions] AST Builder Apr 28, 2020
Comment on lines +28 to +30
export function formatExpression(ast: ExpressionAstExpression): string {
return format(ast, 'expression');
}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

split this into a separate file for consistency with how we split parse_expression into a separate file

Comment on lines +25 to +28
export function parse<E extends string, S extends 'expression' | 'argument'>(
expression: E,
startRule: S
): S extends 'expression' ? ExpressionAstExpression : ExpressionAstArgument {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I ran into some issues with the typings for parse and format which were requiring unnecessary casting -- so I updated these types to use conditionals which is more accurate.


// Fallback for if a function cannot be found in the mapping,
// or someone forgets to `declare module`.
[key: string]: AnyExpressionFunctionDefinition;
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding this felt like the safest thing to do, however AnyExpressionFunctionDefinition is extremely permissive -- it basically uses any for everything. This could probably be made more strict.

@lukeelmers lukeelmers self-assigned this Apr 28, 2020
@lukeelmers lukeelmers added Feature:ExpressionLanguage Interpreter expression language (aka canvas pipeline) release_note:plugin_api_changes Contains a Plugin API changes section for the breaking plugin API changes section. v7.8.0 v8.0.0 labels Apr 28, 2020
@lukeelmers lukeelmers marked this pull request as ready for review April 28, 2020 03:37
@lukeelmers lukeelmers requested a review from a team as a code owner April 28, 2020 03:37
Copy link
Member

@ppisljar ppisljar left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

code LGTM

@lukeelmers
Copy link
Member Author

@elasticmachine merge upstream

@crob611
Copy link
Contributor

crob611 commented Apr 29, 2020

This looks really nice. I know there are a lot of places in Canvas where we manually traverse through an expression to update it (I think maybe all of the "sidebar" stuff that updates the expression does that). I also do a lot of manual building of expressions when dealing with an embeddable's input changing through user interaction. (Like when a user pans a map, the expression needs to be updated to reflect the new center coordinates) and it would be much nicer to build it using this that through the string manipulation I currently do.

Off the top of my head, I can't think of any use cases we might have for it that this wouldn't cover.

Really nice work. Anxious to try it out.

@lukeelmers lukeelmers requested a review from a team May 1, 2020 01:49
@lukeelmers
Copy link
Member Author

@elasticmachine merge upstream

Comment on lines 144 to 151
/**
* To support subexpressions, we override all args to also accept an
* ExpressionBuilder. This isn't perfectly typesafe since we don't
* know with certainty that the builder's output matches the required
* argument input, so we trust that folks using subexpressions in the
* builder know what they're doing.
*/
initialArgs: { [K in keyof FunctionArgs<F>]: FunctionArgs<F>[K] | ExpressionAstExpressionBuilder }
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Highlighting this, as it's probably the main type-safety caveat:

The return value of buildExpression is added as an option for any expression function argument, so if someone is creating a subexpression they must take care to ensure that the expected return value of that whole expression actually satisfies the requirements of the argument it is passed to.

This is unfortunately not something we can determine for them in TS, as the output of an expression is equivalent to the output of the last function in that expression, which is something we don't know at compile time.

Comment on lines 24 to 25
font?: Style;
openLinksInNewTab?: boolean;
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These should be optional as default values are provided in the function definition

@lukeelmers
Copy link
Member Author

@elasticmachine merge upstream

@lukeelmers
Copy link
Member Author

@elasticmachine merge upstream

1 similar comment
@lukeelmers
Copy link
Member Author

@elasticmachine merge upstream

@wylieconlon
Copy link
Contributor

As discussed offline, the findFunction doesn't seem useful to me unless it wraps sub-expressions in the builder as well, because the literal sub-expressions can't be passed back into the builder in their current state.

@lukeelmers
Copy link
Member Author

findFunction doesn't seem useful to me unless it wraps sub-expressions in the builder as well,

Thanks @wylieconlon -- I've updated this based on our discussion. Any subexpression arguments to a function are now wrapped in the builder, rather than as raw AST. That means calling expressionBuilder.functions, expressionBuilder.findFunction, and expressionFunctionBuilder.arguments will all return builder instances for subexpressions. (And calling toAst on either will still unwrap them as you'd expect)

@lukeelmers lukeelmers removed the v7.8.0 label May 7, 2020
@lukeelmers

This comment has been minimized.

@lukeelmers
Copy link
Member Author

@elasticmachine merge upstream

@lukeelmers lukeelmers removed request for wylieconlon and a team July 14, 2020 19:14
@lukeelmers
Copy link
Member Author

Discussed this PR when doing some planning for the expressions service today, and @timroes & I agreed to revert the changes to Lens, which were only added to prove that this builder works.

We felt that touching migrations which have shipped already introduces unnecessary risk to a PR which is otherwise simply adding a new feature.

@kibanamachine
Copy link
Contributor

💚 Build Succeeded

Build metrics

@kbn/optimizer bundle module count

id value diff baseline
data 205 +2 203
expressions 102 +3 99
total - +5 -

History

To update your PR or re-run it, just comment with:
@elasticmachine merge upstream

@lukeelmers lukeelmers merged commit 8ecbb25 into elastic:master Jul 14, 2020
@lukeelmers lukeelmers deleted the feat/ast-builder branch July 14, 2020 21:57
@kibanamachine
Copy link
Contributor

Looks like this PR has a backport PR but it still hasn't been merged. Please merge it ASAP to keep the branches relatively in sync.

@kibanamachine kibanamachine added the backport missing Added to PRs automatically when the are determined to be missing a backport. label Jul 16, 2020
@kibanamachine
Copy link
Contributor

Looks like this PR has a backport PR but it still hasn't been merged. Please merge it ASAP to keep the branches relatively in sync.

lukeelmers added a commit to lukeelmers/kibana that referenced this pull request Jul 18, 2020
lukeelmers added a commit that referenced this pull request Jul 18, 2020
Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
lukeelmers added a commit that referenced this pull request Jul 18, 2020
@kibanamachine kibanamachine removed the backport missing Added to PRs automatically when the are determined to be missing a backport. label Jul 18, 2020
@lukeelmers lukeelmers mentioned this pull request Sep 28, 2020
3 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Feature:ExpressionLanguage Interpreter expression language (aka canvas pipeline) release_note:plugin_api_changes Contains a Plugin API changes section for the breaking plugin API changes section. v7.9.0 v7.10.0 v8.0.0
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Expression AST builder
7 participants