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

[Lens] Add conditional operations in Formula #142325

Merged
merged 38 commits into from
Oct 12, 2022
Merged

Conversation

dej611
Copy link
Contributor

@dej611 dej611 commented Sep 30, 2022

Summary

Fixes #94603

  • Tinymath package
    • Add new comparison operation: lt, gt, eq, lte, gte
    • Add new functions documentation
      • Generate new functions.md page
    • Add new ifelse operation
      • The ifelse name is a placeholder. Possible alternatives are: ifElse, when, ifThen, or make your proposal. :)
    • Add new operation documentation
    • Add tests for new functions
    • Make grammar accept comparison symbols: >, <, ==, >=, <=
    • Add tests for backward compatibility and new features in grammar
  • Lens side
    • Enable new functions in Formula (comparison + ifelse)
    • Validation
      • Make validation work for different returning types
      • Make validation fail for comparison only expressions
      • Make validation fail if non-comparison condition passed to ifelse function
      • Add new validation tests
    • Add documentation for new operations
      • New section for comparison?

Extra fixes

  • 🐛 Fixed also a bug in the divide operation with a divide by 0 scenario.
  • ✏️ Improved documentation about testing to avoid the Jest esm error message

New grammar changes examples

// Not ok: math operations cannot be performed with a comparison on any end of the operation
1 + (1 > 1)
(1 > 1) + 1
((1 > 1) + 1)
5 > 1 > 2
(5 > 1) > 2
(5 > 1) + (5 > 1)

// Ok: math operations can be on either side of the comparison, and be considered as grouped
1 > 1
(1 > 1)
((1 > 1))
((1 + 1) > 1)
1 + 1 > 1 * 1 // => becomes (1 + 1) > (1 * 1) => 1 > 1

Tinymath documentation

Command I've used to generate the new functions.md file:

npx -p jsdoc-to-markdown -c 'jsdoc2md --template packages/kbn-tinymath/docs/template/functions.hbs --private --files "packages/kbn-tinymath/src/functions/**/*.js"' > packages/kbn-tinymath/docs/functions.md

I couldn't make it work properly the previous command used when tinymath was in its own repo: the produced json for the documentation had the wrong function name, always set to module.exports.
In the jsdoc-to-markdown documentation it mentions that module.exports had to be defined after the actual function implementation (which is apparently a jsdoc limitation], and after that everything worked properly. That is why some many files changed with only the exports.

Demo

Working example:

Screenshot 2022-09-30 at 12 44 44

Comparison symbols and functions working as condition:
Screenshot 2022-09-30 at 12 58 37
Screenshot 2022-09-30 at 12 58 50
Screenshot 2022-09-30 at 12 59 20
Screenshot 2022-09-30 at 12 59 28

Errors:
Screenshot 2022-09-30 at 12 55 33
Screenshot 2022-09-30 at 12 57 44
Screenshot 2022-09-30 at 12 58 04
Screenshot 2022-09-30 at 12 58 24
Screenshot 2022-09-30 at 17 38 29

Documentation:
Screenshot 2022-09-30 at 12 45 01

Checklist

Delete any items that are not applicable to this PR.

Risk Matrix

Delete this section if it is not applicable to this PR.

Before closing this PR, invite QA, stakeholders, and other developers to identify risks that should be tested prior to the change/feature release.

When forming the risk matrix, consider some of the following examples and how they may potentially impact the change:

Risk Probability Severity Mitigation/Notes
Multiple Spaces—unexpected behavior in non-default Kibana Space. Low High Integration tests will verify that all features are still supported in non-default Kibana Space and when user switches between spaces.
Multiple nodes—Elasticsearch polling might have race conditions when multiple Kibana nodes are polling for the same tasks. High Low Tasks are idempotent, so executing them multiple times will not result in logical error, but will degrade performance. To test for this case we add plenty of unit tests around this logic and document manual testing procedure.
Code should gracefully handle cases when feature X or plugin Y are disabled. Medium High Unit tests will verify that any feature flag or plugin combination still results in our service operational.
See more potential risk examples

For maintainers

@dej611 dej611 added Team:Visualizations Visualization editors, elastic-charts and infrastructure Feature:Lens v8.6.0 labels Sep 30, 2022
@dej611 dej611 requested a review from cqliu1 September 30, 2022 11:13
Comment on lines +33 to +38
if (Array.isArray(b)) {
return b.map((bi) => {
if (bi === 0) throw new Error('Cannot divide by 0');
return a / bi;
});
}
Copy link
Contributor Author

Choose a reason for hiding this comment

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

divide(4, [1, 0]) would generate an unhandled error without this fix

@dej611 dej611 marked this pull request as ready for review October 3, 2022 14:58
@dej611 dej611 requested a review from a team as a code owner October 3, 2022 14:58
@elasticmachine
Copy link
Contributor

Pinging @elastic/kibana-vis-editors @elastic/kibana-vis-editors-external (Team:VisEditors)

module.exports = { round };

function round(a, b = 0) {
function round(a, b) {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

The refactor here was due to the documentation processor, which handles the default parameter assignment in the function generating a different documentation layout in the functions.md file for the round signature.

Copy link
Contributor

Choose a reason for hiding this comment

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

nit: could do the defaulting once—

function round(a, _b) {
  b = _b ?? 0
  ...

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Changed the code here with a cleaner solution in a deeper function.

Returns a value depending on whether the element of condition is true or false.

Example: Compare two fields average and return the highest
\`ifelse( average(memory) >= average(bytes), average(memory), average(bytes))\`
Copy link
Contributor

Choose a reason for hiding this comment

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

Not sure about this example, you can already do it via pick_max. What about the first example from the "Use cases" section here: #94603 ? Seems understandable to me.

const symbolsToFn = {
'+': 'add', '-': 'subtract',
'*': 'multiply', '/': 'divide',
'<': 'lt', '>': 'gt', '==': 'eq',
Copy link
Contributor

Choose a reason for hiding this comment

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

any reason we aren't going with = for equality? As we don't have assignment, it seems like we can go with the simpler case which is also what excel is doing.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Named parameters already use the = syntax ( i.e. count(kql="...")).
It should work already, but thought that the == operation is something quite familiar as well and different enough to not confuse the two.

Copy link
Contributor

Choose a reason for hiding this comment

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

it's familiar for developers, I don't think it's super common outside of that group. SQL also doesn't do it. What do you think @ghudgins @ninoslavmiskovic ?

Copy link
Contributor

Choose a reason for hiding this comment

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

I also believe that "==" is not known among no developers, and if SQL does not support it, then it is also an indicator that it would be the preferable choice since SQL is a broader query language than KQL. Are there any cases where it will be an issue of sticking with "=" ?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Made a quick spike on that to see whether it is possible to co-exists with the named argument.
Unfortunately there are some overlaps between the two which make it harder to solve it, at least at grammar level:

ifelse(a=1, 1, 2) // => 🆘  named argument or comparison? Grammar says it's named argument
ifelse(a=a, 1, 2) // => 🆘  named argument or comparison? Grammar says it's named argument
ifelse(1=1, 1, 2) // => ✅ comparison
ifelse(1=a, 1, 2) // => ✅ comparison
ifelse(unsupported-named-argument=1, 1, 2) // => ✅ comparison

Copy link
Contributor

Choose a reason for hiding this comment

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

It looks like it should parse correctly, but I didn't check

Copy link
Contributor Author

Choose a reason for hiding this comment

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

They all parse fine.
As long as there's no future plan to have string comparison in the future it can work.

Copy link
Contributor

Choose a reason for hiding this comment

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

OK, in that case I think we should go with the single =. Otherwise this looks good to me!

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Had some more investigation on the topic and found out some more issues on the usage for the single = symbol for comparison.
Here some examples:

  • ❌ for plain tinymath it is possible to use a variable for the comparison on exec
  • ❌ in Canvas it is possible to use a variable for the comparison: math "ifelse(total_errors = 37, 1, 0)" which will raise an error about unsupported Named parameters. This is using exec underneath. This is probably a non-issue as conditional logic can be performed elsewhere
  • ❌ If in the future an optimization like [Lens] Improve performance for large formulas #141456 is introduced for comparison, this can be a problem also for Lens in the formula rewrite step.

All of them are minor problems, but still to keep in mind if we decide to go down this single = symbol route rather than the existing ==.

Copy link
Contributor Author

@dej611 dej611 Oct 11, 2022

Choose a reason for hiding this comment

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

Discussed offline and decided to keep the == symbol.

Some follow ups have been proposed while keeping the double =:

@@ -70,45 +96,55 @@ Variable

// expressions

// An Expression can be of 3 different types:
// * a Comparison operation, which can contain recursive MathOperations inside
Copy link
Contributor

Choose a reason for hiding this comment

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

It's pretty smart to do this within the parsing step, but I worry it's too clever (hard to maintain later on) and it also doesn't create great error messages:
Screenshot 2022-10-04 at 17 58 09

Maybe it makes more sense to pull the type check logic into a separate step after the parsing? Walking the tree and keeping track of the type while recursing should work fine.

Copy link
Contributor Author

@dej611 dej611 Oct 4, 2022

Choose a reason for hiding this comment

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

The error is generated at validation time while traversing the AST. That is a validation bug 😓 .
The grammar works fine for the expression ifelse(unique_count(extension, kql='...') == 1, count() > 2) => try to paste the peggy file content in here to see it passing: https://peggyjs.org/online.html

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Latest commit fixed the validation bug:

Screenshot 2022-10-05 at 15 22 41

Copy link
Contributor

@drewdaemon drewdaemon left a comment

Choose a reason for hiding this comment

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

FWIW, I like ifelse! It doesn't look like we have any camelcase functions to this point, so ifElse would be a style break.

Comment on lines 186 to 188
ifelse( 5 > 6, 1, 0) // returns 0
ifelse( 1 == 1, [1, 2, 3], 5) // returns [1, 2, 3]
ifelse( 1 < 2, [1, 2, 3], [2, 3, 4]) // returns [1, 2, 3]
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: leading spaces

module.exports = { round };

function round(a, b = 0) {
function round(a, b) {
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: could do the defaulting once—

function round(a, _b) {
  b = _b ?? 0
  ...

@drewdaemon drewdaemon added the ui-copy Review of UI copy with docs team is recommended label Oct 4, 2022
Copy link
Contributor

@flash1293 flash1293 left a comment

Choose a reason for hiding this comment

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

Looks mostly good, left two small nits but overall great work!

@@ -253,7 +253,7 @@ describe('math(resp, panel, series)', () => {
)(await mathAgg(resp, panel, series)((results) => results))([]);
} catch (e) {
expect(e.message).toEqual(
'Failed to parse expression. Expected "*", "+", "-", "/", end of input, or whitespace but "(" found.'
'Failed to parse expression. Expected "*", "+", "-", "/", "<", "=", ">", end of input, or whitespace but "(" found.'
Copy link
Contributor

Choose a reason for hiding this comment

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

nit - should be == now

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The message is correct. The grammar picks the first possible valid char at the time.
If the expression becomes divide = params.a, params.b it will error with Expected "=" but " " found as it's the only valid route.

@kibanamachine kibanamachine requested a review from a team as a code owner October 11, 2022 14:13
Copy link
Member

@legrego legrego left a comment

Choose a reason for hiding this comment

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

Formatting changes to x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/feature_table.tsx LGTM

@kibana-ci
Copy link
Collaborator

💚 Build Succeeded

Metrics [docs]

Module Count

Fewer modules leads to a faster build time

id before after diff
canvas 1155 1162 +7
expressions 173 180 +7
lens 925 932 +7
total +21

Async chunks

Total size of all lazy-loaded chunks that will be downloaded as the user navigates the app

id before after diff
canvas 1.0MB 1.0MB +2.7KB
expressions 25.7KB 28.4KB +2.6KB
lens 1.3MB 1.3MB +8.3KB
total +13.6KB

Page load bundle

Size of the bundles that are downloaded on every page load. Target size is below 100kb

id before after diff
expressions 97.4KB 97.4KB +5.0B

History

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
backport:skip This commit does not require backporting Feature:Lens release_note:enhancement Team:Visualizations Visualization editors, elastic-charts and infrastructure ui-copy Review of UI copy with docs team is recommended v8.6.0
Projects
None yet
Development

Successfully merging this pull request may close these issues.

[Lens] Formula: Conditionals
8 participants