[Lens] Add conditional operations in Formula (#142325)

*  Introduce new comparison functions

*  Introduce new comparison symbols into grammar

* 🔧 Introduce new tinymath functions

*  Add comparison fn validation to formula

* ♻️ Some type refactoring

* ✏️ Fix wrong error message

*  Add more formula unit tests

*  Add more tests

*  Fix tsvb test

* 🐛 Fix issue with divide by 0

* ✏️ Update testing command

* ✏️ Add some more testing info

*  Improved grammar to handle edge cases

*  Improve comparison code + unit tests

*  Fix test

* ✏️ Update documentation with latest functions

* 👌 Integrate feedback

* 👌 Integrate more feedback

* 👌 Update doc

* 🐛 Fix bug with function return type check

* 🔥 remove duplicate test

* [CI] Auto-commit changed files from 'node scripts/build_plugin_list_docs'

* Update x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/util.ts

* ✏️ Fixes formula

* [CI] Auto-commit changed files from 'node scripts/precommit_hook.js --ref HEAD~1..HEAD --fix'

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Joe Reuter <johannes.reuter@elastic.co>
This commit is contained in:
Marco Liberati 2022-10-12 12:16:58 +02:00 committed by GitHub
parent e5cebd80b1
commit 757ab767eb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
67 changed files with 1401 additions and 224 deletions

View file

@ -66,7 +66,10 @@ parse('1 + random()')
This package is rebuilt when running `yarn kbn bootstrap`, but can also be build directly
using `yarn build` from the `packages/kbn-tinymath` directory.
### Running tests
To test `@kbn/tinymath` from Kibana, run `yarn run jest --watch packages/kbn-tinymath` from
To test `@kbn/tinymath` from Kibana, run `node scripts/jest --config packages/kbn-tinymath/jest.config.js` from
the top level of Kibana.
To test grammar changes it is required to run a build task before the test suite.

View file

@ -96,6 +96,143 @@ clamp(35, 10, [20, 30, 40, 50]) // returns [20, 30, 35, 35]
clamp([1, 9], 3, [4, 5]) // returns [clamp([1, 3, 4]), clamp([9, 3, 5])] = [3, 5]
```
***
## _eq(_ _a_, _b_ _)_
Performs an equality comparison between two values.
| Param | Type | Description |
| --- | --- | --- |
| a | <code>number</code> \| <code>Array.&lt;number&gt;</code> | a number or an array of numbers |
| b | <code>number</code> \| <code>Array.&lt;number&gt;</code> | a number or an array of numbers |
**Returns**: <code>boolean</code> - Returns true if `a` and `b` are equal, false otherwise. Returns an array with the equality comparison of each element if `a` is an array.
**Throws**:
- `'Missing b value'` if `b` is not provided
- `'Array length mismatch'` if `args` contains arrays of different lengths
**Example**
```js
eq(1, 1) // returns true
eq(1, 2) // returns false
eq([1, 2], 1) // returns [true, false]
eq([1, 2], [1, 2]) // returns [true, true]
```
***
## _gt(_ _a_, _b_ _)_
Performs a greater than comparison between two values.
| Param | Type | Description |
| --- | --- | --- |
| a | <code>number</code> \| <code>Array.&lt;number&gt;</code> | a number or an array of numbers |
| b | <code>number</code> \| <code>Array.&lt;number&gt;</code> | a number or an array of numbers |
**Returns**: <code>boolean</code> - Returns true if `a` is greater than `b`, false otherwise. Returns an array with the greater than comparison of each element if `a` is an array.
**Throws**:
- `'Missing b value'` if `b` is not provided
- `'Array length mismatch'` if `args` contains arrays of different lengths
**Example**
```js
gt(1, 1) // returns false
gt(2, 1) // returns true
gt([1, 2], 1) // returns [true, false]
gt([1, 2], [2, 1]) // returns [false, true]
```
***
## _gte(_ _a_, _b_ _)_
Performs a greater than or equal comparison between two values.
| Param | Type | Description |
| --- | --- | --- |
| a | <code>number</code> \| <code>Array.&lt;number&gt;</code> | a number or an array of numbers |
| b | <code>number</code> \| <code>Array.&lt;number&gt;</code> | a number or an array of numbers |
**Returns**: <code>boolean</code> - Returns true if `a` is greater than or equal to `b`, false otherwise. Returns an array with the greater than or equal comparison of each element if `a` is an array.
**Throws**:
- `'Array length mismatch'` if `args` contains arrays of different lengths
**Example**
```js
gte(1, 1) // returns true
gte(1, 2) // returns false
gte([1, 2], 2) // returns [false, true]
gte([1, 2], [1, 1]) // returns [true, true]
```
***
## _ifelse(_ _cond_, _a_, _b_ _)_
Evaluates the a conditional argument and returns one of the two values based on that.
| Param | Type | Description |
| --- | --- | --- |
| cond | <code>boolean</code> | a boolean value |
| a | <code>any</code> \| <code>Array.&lt;any&gt;</code> | a value or an array of any values |
| b | <code>any</code> \| <code>Array.&lt;any&gt;</code> | a value or an array of any values |
**Returns**: <code>any</code> \| <code>Array.&lt;any&gt;</code> - if the value of cond is truthy, return `a`, otherwise return `b`.
**Throws**:
- `'Condition clause is of the wrong type'` if the `cond` provided is not of boolean type
- `'Missing a value'` if `a` is not provided
- `'Missing b value'` if `b` is not provided
**Example**
```js
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]
```
***
## _lt(_ _a_, _b_ _)_
Performs a lower than comparison between two values.
| Param | Type | Description |
| --- | --- | --- |
| a | <code>number</code> \| <code>Array.&lt;number&gt;</code> | a number or an array of numbers |
| b | <code>number</code> \| <code>Array.&lt;number&gt;</code> | a number or an array of numbers |
**Returns**: <code>boolean</code> - Returns true if `a` is lower than `b`, false otherwise. Returns an array with the lower than comparison of each element if `a` is an array.
**Throws**:
- `'Missing b value'` if `b` is not provided
- `'Array length mismatch'` if `args` contains arrays of different lengths
**Example**
```js
lt(1, 1) // returns false
lt(1, 2) // returns true
lt([1, 2], 2) // returns [true, false]
lt([1, 2], [1, 2]) // returns [false, false]
```
***
## _lte(_ _a_, _b_ _)_
Performs a lower than or equal comparison between two values.
| Param | Type | Description |
| --- | --- | --- |
| a | <code>number</code> \| <code>Array.&lt;number&gt;</code> | a number or an array of numbers |
| b | <code>number</code> \| <code>Array.&lt;number&gt;</code> | a number or an array of numbers |
**Returns**: <code>boolean</code> - Returns true if `a` is lower than or equal to `b`, false otherwise. Returns an array with the lower than or equal comparison of each element if `a` is an array.
**Throws**:
- `'Array length mismatch'` if `args` contains arrays of different lengths
**Example**
```js
lte(1, 1) // returns true
lte(1, 2) // returns true
lte([1, 2], 2) // returns [true, true]
lte([1, 2], [1, 1]) // returns [true, false]
```
***
## _cos(_ _a_ _)_
Calculates the the cosine of a number. For arrays, the function will be applied index-wise to each element.

View file

@ -11,6 +11,32 @@
max: location.end.offset
}
}
const symbolsToFn = {
'+': 'add', '-': 'subtract',
'*': 'multiply', '/': 'divide',
'<': 'lt', '>': 'gt', '==': 'eq',
'<=': 'lte', '>=': 'gte',
}
// Shared function for AST operations
function parseSymbol(left, rest){
const topLevel = rest.reduce((acc, [name, right]) => ({
type: 'function',
name: symbolsToFn[name],
args: [acc, right],
}), left);
if (typeof topLevel === 'object') {
topLevel.location = simpleLocation(location());
topLevel.text = text();
}
return topLevel;
}
// op is always defined, while eq can be null for gt and lt cases
function getComparisonSymbol([op, eq]){
return symbolsToFn[op+(eq || '')];
}
}
start
@ -70,45 +96,55 @@ Variable
// expressions
// An Expression can be of 3 different types:
// * a Comparison operation, which can contain recursive MathOperations inside
// * a MathOperation, which can contain other MathOperations, but not Comparison types
// * an ExpressionGroup, which is a generic Grouping that contains also Comparison operations (i.e. ( 5 > 1))
Expression
= Comparison
/ MathOperation
/ ExpressionGroup
Comparison
= _ left:MathOperation op:(('>' / '<')('=')? / '=''=') right:MathOperation _ {
return {
type: 'function',
name: getComparisonSymbol(op),
args: [left, right],
location: simpleLocation(location()),
text: text()
};
}
MathOperation
= AddSubtract
/ MultiplyDivide
/ Factor
AddSubtract
= _ left:MultiplyDivide rest:(('+' / '-') MultiplyDivide)+ _ {
const topLevel = rest.reduce((acc, curr) => ({
type: 'function',
name: curr[0] === '+' ? 'add' : 'subtract',
args: [acc, curr[1]],
}), left);
if (typeof topLevel === 'object') {
topLevel.location = simpleLocation(location());
topLevel.text = text();
}
return topLevel;
return parseSymbol(left, rest, {'+': 'add', '-': 'subtract'});
}
/ MultiplyDivide
MultiplyDivide
= _ left:Factor rest:(('*' / '/') Factor)* _ {
const topLevel = rest.reduce((acc, curr) => ({
type: 'function',
name: curr[0] === '*' ? 'multiply' : 'divide',
args: [acc, curr[1]],
}), left);
if (typeof topLevel === 'object') {
topLevel.location = simpleLocation(location());
topLevel.text = text();
}
return topLevel;
return parseSymbol(left, rest, {'*': 'multiply', '/': 'divide'});
}
/ Factor
Factor
= Group
/ Function
/ Literal
// Because of the new Comparison syntax it is required a new Group type
// the previous Group has been renamed into ExpressionGroup while
// a new Group type has been defined to exclude the Comparison type from it
Group
= _ '(' _ expr:MathOperation _ ')' _ {
return expr
}
ExpressionGroup
= _ '(' _ expr:Expression _ ')' _ {
return expr
}

View file

@ -17,11 +17,11 @@
* abs([-1 , -2, 3, -4]) // returns [1, 2, 3, 4]
*/
module.exports = { abs };
function abs(a) {
if (Array.isArray(a)) {
return a.map((a) => Math.abs(a));
}
return Math.abs(a);
}
module.exports = { abs };

View file

@ -17,8 +17,6 @@
* add([1, 2], 3, [4, 5], 6) // returns [(1 + 3 + 4 + 6), (2 + 3 + 5 + 6)] = [14, 16]
*/
module.exports = { add };
function add(...args) {
if (args.length === 1) {
if (Array.isArray(args[0])) return args[0].reduce((result, current) => result + current);
@ -35,3 +33,4 @@ function add(...args) {
return result + current;
});
}
module.exports = { add };

View file

@ -17,11 +17,11 @@
* cbrt([27, 64, 125]) // returns [3, 4, 5]
*/
module.exports = { cbrt };
function cbrt(a) {
if (Array.isArray(a)) {
return a.map((a) => Math.cbrt(a));
}
return Math.cbrt(a);
}
module.exports = { cbrt };

View file

@ -17,11 +17,11 @@
* ceil([1.1, 2.2, 3.3]) // returns [2, 3, 4]
*/
module.exports = { ceil };
function ceil(a) {
if (Array.isArray(a)) {
return a.map((a) => Math.ceil(a));
}
return Math.ceil(a);
}
module.exports = { ceil };

View file

@ -30,8 +30,6 @@ const findClamp = (a, min, max) => {
* clamp([1, 9], 3, [4, 5]) // returns [clamp([1, 3, 4]), clamp([9, 3, 5])] = [3, 5]
*/
module.exports = { clamp };
function clamp(a, min, max) {
if (max === null)
throw new Error("Missing maximum value. You may want to use the 'min' function instead");
@ -73,3 +71,5 @@ function clamp(a, min, max) {
return findClamp(a, min, max);
}
module.exports = { clamp };

View file

@ -0,0 +1,39 @@
/*
* 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.
*/
/**
* Performs an equality comparison between two values.
* @param {number|number[]} a a number or an array of numbers
* @param {number|number[]} b a number or an array of numbers
* @return {boolean} Returns true if `a` and `b` are equal, false otherwise. Returns an array with the equality comparison of each element if `a` is an array.
* @throws `'Missing b value'` if `b` is not provided
* @throws `'Array length mismatch'` if `args` contains arrays of different lengths
* @example
* eq(1, 1) // returns true
* eq(1, 2) // returns false
* eq([1, 2], 1) // returns [true, false]
* eq([1, 2], [1, 2]) // returns [true, true]
*/
function eq(a, b) {
if (b == null) {
throw new Error('Missing b value');
}
if (Array.isArray(a)) {
if (!Array.isArray(b)) {
return a.every((v) => v === b);
}
if (a.length !== b.length) {
throw new Error('Array length mismatch');
}
return a.every((v, i) => v === b[i]);
}
return a === b;
}
module.exports = { eq };

View file

@ -0,0 +1,39 @@
/*
* 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.
*/
/**
* Performs a greater than comparison between two values.
* @param {number|number[]} a a number or an array of numbers
* @param {number|number[]} b a number or an array of numbers
* @return {boolean} Returns true if `a` is greater than `b`, false otherwise. Returns an array with the greater than comparison of each element if `a` is an array.
* @throws `'Missing b value'` if `b` is not provided
* @throws `'Array length mismatch'` if `args` contains arrays of different lengths
* @example
* gt(1, 1) // returns false
* gt(2, 1) // returns true
* gt([1, 2], 1) // returns [true, false]
* gt([1, 2], [2, 1]) // returns [false, true]
*/
function gt(a, b) {
if (b == null) {
throw new Error('Missing b value');
}
if (Array.isArray(a)) {
if (!Array.isArray(b)) {
return a.every((v) => v > b);
}
if (a.length !== b.length) {
throw new Error('Array length mismatch');
}
return a.every((v, i) => v > b[i]);
}
return a > b;
}
module.exports = { gt };

View file

@ -0,0 +1,28 @@
/*
* 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.
*/
const { eq } = require('./eq');
const { gt } = require('./gt');
/**
* Performs a greater than or equal comparison between two values.
* @param {number|number[]} a a number or an array of numbers
* @param {number|number[]} b a number or an array of numbers
* @return {boolean} Returns true if `a` is greater than or equal to `b`, false otherwise. Returns an array with the greater than or equal comparison of each element if `a` is an array.
* @throws `'Array length mismatch'` if `args` contains arrays of different lengths
* @example
* gte(1, 1) // returns true
* gte(1, 2) // returns false
* gte([1, 2], 2) // returns [false, true]
* gte([1, 2], [1, 1]) // returns [true, true]
*/
function gte(a, b) {
return eq(a, b) || gt(a, b);
}
module.exports = { gte };

View file

@ -0,0 +1,38 @@
/*
* 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.
*/
/**
* Evaluates the a conditional argument and returns one of the two values based on that.
* @param {(boolean)} cond a boolean value
* @param {(any|any[])} a a value or an array of any values
* @param {(any|any[])} b a value or an array of any values
* @return {(any|any[])} if the value of cond is truthy, return `a`, otherwise return `b`.
* @throws `'Condition clause is of the wrong type'` if the `cond` provided is not of boolean type
* @throws `'Missing a value'` if `a` is not provided
* @throws `'Missing b value'` if `b` is not provided
* @example
* 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]
*/
function ifelse(cond, a, b) {
if (typeof cond !== 'boolean') {
throw Error('Condition clause is of the wrong type');
}
if (a == null) {
throw new Error('Missing a value');
}
if (b == null) {
throw new Error('Missing b value');
}
return cond ? a : b;
}
ifelse.skipNumberValidation = true;
module.exports = { ifelse };

View file

@ -0,0 +1,16 @@
/*
* 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.
*/
const { eq } = require('./eq');
const { lt } = require('./lt');
const { gt } = require('./gt');
const { lte } = require('./lte');
const { gte } = require('./gte');
const { ifelse } = require('./ifelse');
module.exports = { eq, lt, gt, lte, gte, ifelse };

View file

@ -0,0 +1,39 @@
/*
* 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.
*/
/**
* Performs a lower than comparison between two values.
* @param {number|number[]} a a number or an array of numbers
* @param {number|number[]} b a number or an array of numbers
* @return {boolean} Returns true if `a` is lower than `b`, false otherwise. Returns an array with the lower than comparison of each element if `a` is an array.
* @throws `'Missing b value'` if `b` is not provided
* @throws `'Array length mismatch'` if `args` contains arrays of different lengths
* @example
* lt(1, 1) // returns false
* lt(1, 2) // returns true
* lt([1, 2], 2) // returns [true, false]
* lt([1, 2], [1, 2]) // returns [false, false]
*/
function lt(a, b) {
if (b == null) {
throw new Error('Missing b value');
}
if (Array.isArray(a)) {
if (!Array.isArray(b)) {
return a.every((v) => v < b);
}
if (a.length !== b.length) {
throw new Error('Array length mismatch');
}
return a.every((v, i) => v < b[i]);
}
return a < b;
}
module.exports = { lt };

View file

@ -0,0 +1,28 @@
/*
* 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.
*/
const { eq } = require('./eq');
const { lt } = require('./lt');
/**
* Performs a lower than or equal comparison between two values.
* @param {number|number[]} a a number or an array of numbers
* @param {number|number[]} b a number or an array of numbers
* @return {boolean} Returns true if `a` is lower than or equal to `b`, false otherwise. Returns an array with the lower than or equal comparison of each element if `a` is an array.
* @throws `'Array length mismatch'` if `args` contains arrays of different lengths
* @example
* lte(1, 1) // returns true
* lte(1, 2) // returns true
* lte([1, 2], 2) // returns [true, true]
* lte([1, 2], [1, 1]) // returns [true, false]
*/
function lte(a, b) {
return eq(a, b) || lt(a, b);
}
module.exports = { lte };

View file

@ -16,11 +16,10 @@
* cos([0, 1.5707963267948966]) // returns [1, 6.123233995736766e-17]
*/
module.exports = { cos };
function cos(a) {
if (Array.isArray(a)) {
return a.map((a) => Math.cos(a));
}
return Math.cos(a);
}
module.exports = { cos };

View file

@ -19,10 +19,10 @@ const { size } = require('./size');
* count(100) // returns 1
*/
module.exports = { count };
function count(a) {
return size(a);
}
count.skipNumberValidation = true;
module.exports = { count };

View file

@ -18,8 +18,7 @@ const { pow } = require('./pow');
* cube([3, 4, 5]) // returns [27, 64, 125]
*/
module.exports = { cube };
function cube(a) {
return pow(a, 3);
}
module.exports = { cube };

View file

@ -16,11 +16,10 @@
* degtorad([0, 90, 180, 360]) // returns [0, 1.5707963267948966, 3.141592653589793, 6.283185307179586]
*/
module.exports = { degtorad };
function degtorad(a) {
if (Array.isArray(a)) {
return a.map((a) => (a * Math.PI) / 180);
}
return (a * Math.PI) / 180;
}
module.exports = { degtorad };

View file

@ -20,8 +20,6 @@
* divide([14, 42, 65, 108], [2, 7, 5, 12]) // returns [7, 6, 13, 9]
*/
module.exports = { divide };
function divide(a, b) {
if (Array.isArray(a) && Array.isArray(b)) {
if (a.length !== b.length) throw new Error('Array length mismatch');
@ -30,8 +28,14 @@ function divide(a, b) {
return val / b[i];
});
}
if (Array.isArray(b)) return b.map((b) => a / b);
if (Array.isArray(b)) {
return b.map((bi) => {
if (bi === 0) throw new Error('Cannot divide by 0');
return a / bi;
});
}
if (b === 0) throw new Error('Cannot divide by 0');
if (Array.isArray(a)) return a.map((a) => a / b);
return a / b;
}
module.exports = { divide };

View file

@ -16,11 +16,10 @@
* exp([1, 2, 3]) // returns [e^1, e^2, e^3] = [2.718281828459045, 7.3890560989306495, 20.085536923187668]
*/
module.exports = { exp };
function exp(a) {
if (Array.isArray(a)) {
return a.map((a) => Math.exp(a));
}
return Math.exp(a);
}
module.exports = { exp };

View file

@ -16,8 +16,6 @@
* first([1, 2, 3]) // returns 1
*/
module.exports = { first };
function first(a) {
if (Array.isArray(a)) {
return a[0];
@ -26,3 +24,5 @@ function first(a) {
}
first.skipNumberValidation = true;
module.exports = { first };

View file

@ -24,11 +24,11 @@ const fixer = (a) => {
* fix([1.8, 2.9, -3.7, -4.6]) // returns [1, 2, -3, -4]
*/
module.exports = { fix };
function fix(a) {
if (Array.isArray(a)) {
return a.map((a) => fixer(a));
}
return fixer(a);
}
module.exports = { fix };

View file

@ -17,11 +17,11 @@
* floor([1.7, 2.8, 3.9]) // returns [1, 2, 3]
*/
module.exports = { floor };
function floor(a) {
if (Array.isArray(a)) {
return a.map((a) => Math.floor(a));
}
return Math.floor(a);
}
module.exports = { floor };

View file

@ -45,6 +45,7 @@ const { subtract } = require('./subtract');
const { sum } = require('./sum');
const { tan } = require('./tan');
const { unique } = require('./unique');
const { eq, lt, gt, lte, gte, ifelse } = require('./comparison');
module.exports = {
functions: {
@ -63,6 +64,7 @@ module.exports = {
first,
fix,
floor,
ifelse,
last,
log,
log10,
@ -87,5 +89,10 @@ module.exports = {
sum,
tan,
unique,
eq,
lt,
gt,
lte,
gte,
},
};

View file

@ -16,8 +16,6 @@
* last([1, 2, 3]) // returns 3
*/
module.exports = { last };
function last(a) {
if (Array.isArray(a)) {
return a[a.length - 1];
@ -26,3 +24,4 @@ function last(a) {
}
last.skipNumberValidation = true;
module.exports = { last };

View file

@ -8,6 +8,7 @@
/**
* Transposes a 2D array, i.e. turns the rows into columns and vice versa. Scalar values are also included in the transpose.
* @private
* @param {any[][]} args an array or an array that contains arrays
* @param {number} index index of the first array element in args
* @return {any[][]} transpose of args

View file

@ -22,8 +22,6 @@ const changeOfBase = (a, b) => Math.log(a) / Math.log(b);
* log([2, 4, 8, 16, 32], 2) // returns [1, 2, 3, 4, 5]
*/
module.exports = { log };
function log(a, b = Math.E) {
if (b <= 0) throw new Error('Base out of range');
@ -36,3 +34,4 @@ function log(a, b = Math.E) {
if (a < 0) throw new Error('Must be greater than 0');
return changeOfBase(a, b);
}
module.exports = { log };

View file

@ -20,8 +20,7 @@ const { log } = require('./log');
* log([10, 100, 1000, 10000, 100000]) // returns [1, 2, 3, 4, 5]
*/
module.exports = { log10 };
function log10(a) {
return log(a, 10);
}
module.exports = { log10 };

View file

@ -17,8 +17,6 @@
* max([1, 9], 4, [3, 5]) // returns [max([1, 4, 3]), max([9, 4, 5])] = [4, 9]
*/
module.exports = { max };
function max(...args) {
if (args.length === 1) {
if (Array.isArray(args[0]))
@ -36,3 +34,4 @@ function max(...args) {
return Math.max(result, current);
});
}
module.exports = { max };

View file

@ -19,8 +19,6 @@ const { add } = require('./add');
* mean([1, 9], 5, [3, 4]) // returns [mean([1, 5, 3]), mean([9, 5, 4])] = [3, 6]
*/
module.exports = { mean };
function mean(...args) {
if (args.length === 1) {
if (Array.isArray(args[0])) return add(args[0]) / args[0].length;
@ -34,3 +32,4 @@ function mean(...args) {
return sum / args.length;
}
module.exports = { mean };

View file

@ -33,8 +33,6 @@ const findMedian = (a) => {
* median([1, 9], 2, 4, [3, 5]) // returns [median([1, 2, 4, 3]), median([9, 2, 4, 5])] = [2.5, 4.5]
*/
module.exports = { median };
function median(...args) {
if (args.length === 1) {
if (Array.isArray(args[0])) return findMedian(args[0]);
@ -48,3 +46,4 @@ function median(...args) {
}
return findMedian(args);
}
module.exports = { median };

View file

@ -17,8 +17,6 @@
* min([1, 9], 4, [3, 5]) // returns [min([1, 4, 3]), min([9, 4, 5])] = [1, 4]
*/
module.exports = { min };
function min(...args) {
if (args.length === 1) {
if (Array.isArray(args[0]))
@ -36,3 +34,4 @@ function min(...args) {
return Math.min(result, current);
});
}
module.exports = { min };

View file

@ -20,8 +20,6 @@
* mod([14, 42, 65, 108], [5, 4, 14, 2]) // returns [5, 2, 9, 0]
*/
module.exports = { mod };
function mod(a, b) {
if (Array.isArray(a) && Array.isArray(b)) {
if (a.length !== b.length) throw new Error('Array length mismatch');
@ -35,3 +33,4 @@ function mod(a, b) {
if (Array.isArray(a)) return a.map((a) => a % b);
return a % b;
}
module.exports = { mod };

View file

@ -40,8 +40,6 @@ const findMode = (a) => {
* mode([1, 9], 1, 4, [3, 5]) // returns [mode([1, 1, 4, 3]), mode([9, 1, 4, 5])] = [[1], [4, 5, 9]]
*/
module.exports = { mode };
function mode(...args) {
if (args.length === 1) {
if (Array.isArray(args[0])) return findMode(args[0]);
@ -55,3 +53,4 @@ function mode(...args) {
}
return findMode(args);
}
module.exports = { mode };

View file

@ -19,8 +19,6 @@
* multiply([1, 2, 3, 4], [2, 7, 5, 12]) // returns [2, 14, 15, 48]
*/
module.exports = { multiply };
function multiply(...args) {
return args.reduce((result, current) => {
if (Array.isArray(result) && Array.isArray(current)) {
@ -32,3 +30,4 @@ function multiply(...args) {
return result * current;
});
}
module.exports = { multiply };

View file

@ -14,8 +14,7 @@
* pi() // 3.141592653589793
*/
module.exports = { pi };
function pi() {
return Math.PI;
}
module.exports = { pi };

View file

@ -17,8 +17,6 @@
* pow([1, 2, 3], 4) // returns [1, 16, 81]
*/
module.exports = { pow };
function pow(a, b) {
if (b == null) throw new Error('Missing exponent');
if (Array.isArray(a)) {
@ -26,3 +24,4 @@ function pow(a, b) {
}
return Math.pow(a, b);
}
module.exports = { pow };

View file

@ -16,11 +16,10 @@
* radtodeg([0, 1.5707963267948966, 3.141592653589793, 6.283185307179586]) // returns [0, 90, 180, 360]
*/
module.exports = { radtodeg };
function radtodeg(a) {
if (Array.isArray(a)) {
return a.map((a) => (a * 180) / Math.PI);
}
return (a * 180) / Math.PI;
}
module.exports = { radtodeg };

View file

@ -18,8 +18,6 @@
* random(-10,10) // returns a random number between -10 (inclusive) and 10 (exclusive)
*/
module.exports = { random };
function random(a, b) {
if (a == null) return Math.random();
@ -33,3 +31,5 @@ function random(a, b) {
if (a > b) throw new Error(`Min is greater than max`);
return Math.random() * (b - a) + a;
}
module.exports = { random };

View file

@ -21,8 +21,7 @@ const { subtract } = require('./subtract');
* range([1, 9], 4, [3, 5]) // returns [range([1, 4, 3]), range([9, 4, 5])] = [3, 5]
*/
module.exports = { range };
function range(...args) {
return subtract(max(...args), min(...args));
}
module.exports = { range };

View file

@ -6,7 +6,7 @@
* Side Public License, v 1.
*/
const rounder = (a, b) => Math.round(a * Math.pow(10, b)) / Math.pow(10, b);
const rounder = (a, b = 0) => Math.round(a * Math.pow(10, b)) / Math.pow(10, b);
/**
* Rounds a number towards the nearest integer by default or decimal place if specified. For arrays, the function will be applied index-wise to each element.
@ -22,11 +22,10 @@ const rounder = (a, b) => Math.round(a * Math.pow(10, b)) / Math.pow(10, b);
* round([2.9234, 5.1234, 3.5234, 4.49234324], 2) // returns [2.92, 5.12, 3.52, 4.49]
*/
module.exports = { round };
function round(a, b = 0) {
function round(a, b) {
if (Array.isArray(a)) {
return a.map((a) => rounder(a, b));
}
return rounder(a, b);
}
module.exports = { round };

View file

@ -16,11 +16,10 @@
* sin([0, 1.5707963267948966]) // returns [0, 1]
*/
module.exports = { sin };
function sin(a) {
if (Array.isArray(a)) {
return a.map((a) => Math.sin(a));
}
return Math.sin(a);
}
module.exports = { sin };

View file

@ -17,11 +17,10 @@
* size(100) // returns 1
*/
module.exports = { size };
function size(a) {
if (Array.isArray(a)) return a.length;
throw new Error('Must pass an array');
}
size.skipNumberValidation = true;
module.exports = { size };

View file

@ -17,8 +17,6 @@
* sqrt([9, 16, 25]) // returns [3, 4, 5]
*/
module.exports = { sqrt };
function sqrt(a) {
if (Array.isArray(a)) {
return a.map((a) => {
@ -30,3 +28,4 @@ function sqrt(a) {
if (a < 0) throw new Error('Unable find the square root of a negative number');
return Math.sqrt(a);
}
module.exports = { sqrt };

View file

@ -18,8 +18,7 @@ const { pow } = require('./pow');
* square([3, 4, 5]) // returns [9, 16, 25]
*/
module.exports = { square };
function square(a) {
return pow(a, 2);
}
module.exports = { square };

View file

@ -19,8 +19,6 @@
* subtract([14, 42, 65, 108], [2, 7, 5, 12]) // returns [12, 35, 52, 96]
*/
module.exports = { subtract };
function subtract(a, b) {
if (Array.isArray(a) && Array.isArray(b)) {
if (a.length !== b.length) throw new Error('Array length mismatch');
@ -30,3 +28,4 @@ function subtract(a, b) {
if (Array.isArray(b)) return b.map((b) => a - b);
return a - b;
}
module.exports = { subtract };

View file

@ -20,8 +20,6 @@ const findSum = (total, current) => total + current;
* sum([10, 20, 30, 40], 10, [1, 2, 3], 22) // returns sum(10, 20, 30, 40, 10, 1, 2, 3, 22) = 138
*/
module.exports = { sum };
function sum(...args) {
return args.reduce((total, current) => {
if (Array.isArray(current)) {
@ -30,3 +28,4 @@ function sum(...args) {
return total + current;
}, 0);
}
module.exports = { sum };

View file

@ -16,11 +16,10 @@
* tan([0, 1, -1]) // returns [0, 1.5574077246549023, -1.5574077246549023]
*/
module.exports = { tan };
function tan(a) {
if (Array.isArray(a)) {
return a.map((a) => Math.tan(a));
}
return Math.tan(a);
}
module.exports = { tan };

View file

@ -18,8 +18,6 @@
* unique([1, 2, 3, 4, 2, 2, 2, 3, 4, 2, 4, 5, 2, 1, 4, 2]) // returns 5
*/
module.exports = { unique };
function unique(a) {
if (Array.isArray(a)) {
return a.filter((val, i) => a.indexOf(val) === i).length;
@ -28,3 +26,4 @@ function unique(a) {
}
unique.skipNumberValidation = true;
module.exports = { unique };

View file

@ -0,0 +1,42 @@
/*
* 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.
*/
const { eq } = require('../../../src/functions/comparison/eq');
describe('Eq', () => {
it('numbers', () => {
expect(eq(-10, -10)).toBeTruthy();
expect(eq(10, 10)).toBeTruthy();
expect(eq(0, 0)).toBeTruthy();
});
it('arrays', () => {
// Should pass
expect(eq([-1], -1)).toBeTruthy();
expect(eq([-1], [-1])).toBeTruthy();
expect(eq([-1, -1], -1)).toBeTruthy();
expect(eq([-1, -1], [-1, -1])).toBeTruthy();
// Should not pass
expect(eq([-1], 0)).toBeFalsy();
expect(eq([-1], [0])).toBeFalsy();
expect(eq([-1, -1], 0)).toBeFalsy();
expect(eq([-1, -1], [0, 0])).toBeFalsy();
expect(eq([-1, -1], [-1, 0])).toBeFalsy();
});
it('missing args', () => {
expect(() => eq()).toThrow();
expect(() => eq(-10)).toThrow();
expect(() => eq([])).toThrow();
});
it('empty arrays', () => {
expect(eq([], [])).toBeTruthy();
});
});

View file

@ -0,0 +1,42 @@
/*
* 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.
*/
const { gt } = require('../../../src/functions/comparison/gt');
describe('Gt', () => {
it('missing args', () => {
expect(() => gt()).toThrow();
expect(() => gt(-10)).toThrow();
expect(() => gt([])).toThrow();
});
it('empty arrays', () => {
expect(gt([], [])).toBeTruthy();
});
it('numbers', () => {
expect(gt(-10, -20)).toBeTruthy();
expect(gt(10, 0)).toBeTruthy();
expect(gt(0, -1)).toBeTruthy();
});
it('arrays', () => {
// Should pass
expect(gt([-1], -2)).toBeTruthy();
expect(gt([-1], [-2])).toBeTruthy();
expect(gt([-1, -1], -2)).toBeTruthy();
expect(gt([-1, -1], [-2, -2])).toBeTruthy();
// Should not pass
expect(gt([-1], 2)).toBeFalsy();
expect(gt([-1], [2])).toBeFalsy();
expect(gt([-1, -1], 2)).toBeFalsy();
expect(gt([-1, -1], [2, 2])).toBeFalsy();
expect(gt([-1, -1], [-2, 2])).toBeFalsy();
});
});

View file

@ -0,0 +1,59 @@
/*
* 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.
*/
const { gte } = require('../../../src/functions/comparison/gte');
describe('Gte', () => {
it('missing args', () => {
expect(() => gte()).toThrow();
expect(() => gte(-10)).toThrow();
expect(() => gte([])).toThrow();
});
it('empty arrays', () => {
expect(gte([], [])).toBeTruthy();
});
describe('eq values', () => {
it('numbers', () => {
expect(gte(-10, -10)).toBeTruthy();
expect(gte(10, 10)).toBeTruthy();
expect(gte(0, 0)).toBeTruthy();
});
it('arrays', () => {
expect(gte([-1], -1)).toBeTruthy();
expect(gte([-1], [-1])).toBeTruthy();
expect(gte([-1, -1], -1)).toBeTruthy();
expect(gte([-1, -1], [-1, -1])).toBeTruthy();
});
});
describe('gt values', () => {
it('numbers', () => {
expect(gte(-10, -20)).toBeTruthy();
expect(gte(10, 0)).toBeTruthy();
expect(gte(0, -1)).toBeTruthy();
});
it('arrays', () => {
// Should pass
expect(gte([-1], -2)).toBeTruthy();
expect(gte([-1], [-2])).toBeTruthy();
expect(gte([-1, -1], -2)).toBeTruthy();
expect(gte([-1, -1], [-2, -2])).toBeTruthy();
// Should not pass
expect(gte([-1], 2)).toBeFalsy();
expect(gte([-1], [2])).toBeFalsy();
expect(gte([-1, -1], 2)).toBeFalsy();
expect(gte([-1, -1], [2, 2])).toBeFalsy();
expect(gte([-1, -1], [-2, 2])).toBeFalsy();
});
});
});

View file

@ -0,0 +1,33 @@
/*
* 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.
*/
const { ifelse } = require('../../../src/functions/comparison/ifelse');
describe('Ifelse', () => {
it('should basically work', () => {
expect(ifelse(true, 1, 0)).toEqual(1);
expect(ifelse(false, 1, 0)).toEqual(0);
expect(ifelse(1 > 0, 1, 0)).toEqual(1);
expect(ifelse(1 < 0, 1, 0)).toEqual(0);
});
it('should throw if cond is not of boolean type', () => {
expect(() => ifelse(5, 1, 0)).toThrow('Condition clause is of the wrong type');
expect(() => ifelse(null, 1, 0)).toThrow('Condition clause is of the wrong type');
expect(() => ifelse(undefined, 1, 0)).toThrow('Condition clause is of the wrong type');
expect(() => ifelse(0, 1, 0)).toThrow('Condition clause is of the wrong type');
});
it('missing args', () => {
expect(() => ifelse()).toThrow();
expect(() => ifelse(-10)).toThrow();
expect(() => ifelse([])).toThrow();
expect(() => ifelse(true)).toThrow();
expect(() => ifelse(true, 1)).toThrow();
});
});

View file

@ -0,0 +1,42 @@
/*
* 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.
*/
const { lt } = require('../../../src/functions/comparison/lt');
describe('Lt', () => {
it('missing args', () => {
expect(() => lt()).toThrow();
expect(() => lt(-10)).toThrow();
expect(() => lt([])).toThrow();
});
it('empty arrays', () => {
expect(lt([], [])).toBeTruthy();
});
it('numbers', () => {
expect(lt(-10, -2)).toBeTruthy();
expect(lt(10, 20)).toBeTruthy();
expect(lt(0, 1)).toBeTruthy();
});
it('arrays', () => {
// Should pass
expect(lt([-1], 0)).toBeTruthy();
expect(lt([-1], [0])).toBeTruthy();
expect(lt([-1, -1], 0)).toBeTruthy();
expect(lt([-1, -1], [0, 0])).toBeTruthy();
// Should not pass
expect(lt([-1], -2)).toBeFalsy();
expect(lt([-1], [-2])).toBeFalsy();
expect(lt([-1, -1], -2)).toBeFalsy();
expect(lt([-1, -1], [-2, -2])).toBeFalsy();
expect(lt([-1, -1], [-2, 2])).toBeFalsy();
});
});

View file

@ -0,0 +1,59 @@
/*
* 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.
*/
const { lte } = require('../../../src/functions/comparison/lte');
describe('Lte', () => {
it('missing args', () => {
expect(() => lte()).toThrow();
expect(() => lte(-10)).toThrow();
expect(() => lte([])).toThrow();
});
it('empty arrays', () => {
expect(lte([], [])).toBeTruthy();
});
describe('eq values', () => {
it('numbers', () => {
expect(lte(-10, -10)).toBeTruthy();
expect(lte(10, 10)).toBeTruthy();
expect(lte(0, 0)).toBeTruthy();
});
it('arrays', () => {
expect(lte([-1], -1)).toBeTruthy();
expect(lte([-1], [-1])).toBeTruthy();
expect(lte([-1, -1], -1)).toBeTruthy();
expect(lte([-1, -1], [-1, -1])).toBeTruthy();
});
});
describe('lt values', () => {
it('numbers', () => {
expect(lte(-10, -2)).toBeTruthy();
expect(lte(10, 20)).toBeTruthy();
expect(lte(0, 1)).toBeTruthy();
});
it('arrays', () => {
// Should pass
expect(lte([-1], 0)).toBeTruthy();
expect(lte([-1], [0])).toBeTruthy();
expect(lte([-1, -1], 0)).toBeTruthy();
expect(lte([-1, -1], [0, 0])).toBeTruthy();
// Should not pass
expect(lte([-1], -2)).toBeFalsy();
expect(lte([-1], [-2])).toBeFalsy();
expect(lte([-1, -1], -2)).toBeFalsy();
expect(lte([-1, -1], [-2, -2])).toBeFalsy();
expect(lte([-1, -1], [-2, 2])).toBeFalsy();
});
});
});

View file

@ -29,4 +29,11 @@ describe('Divide', () => {
it('array length mismatch', () => {
expect(() => divide([1, 2], [3])).toThrow('Array length mismatch');
});
it('divide by 0', () => {
expect(() => divide([1, 2], 0)).toThrow('Cannot divide by 0');
expect(() => divide(1, 0)).toThrow('Cannot divide by 0');
expect(() => divide([1, 2], [0, 0])).toThrow('Cannot divide by 0');
expect(() => divide(1, [1, 0])).toThrow('Cannot divide by 0');
});
});

View file

@ -68,6 +68,91 @@ describe('Parser', () => {
location: { min: 0, max: 13 },
});
});
describe('Comparison', () => {
it('should throw for non valid comparison symbols', () => {
const symbols = ['<>', '><', '===', '>>', '<<'];
for (const symbol of symbols) {
expect(() => parse(`5 ${symbol} 1`)).toThrow();
}
});
describe.each`
symbol | fn
${'<'} | ${'lt'}
${'>'} | ${'gt'}
${'=='} | ${'eq'}
${'>='} | ${'gte'}
${'<='} | ${'lte'}
`('Symbol "$symbol" ( $fn )', ({ symbol, fn }) => {
it(`should parse comparison symbol: "$symbol"`, () => {
expect(parse(`5 ${symbol} 1`)).toEqual({
name: fn,
type: 'function',
args: [5, 1],
text: `5 ${symbol} 1`,
location: { min: 0, max: 4 + symbol.length },
});
expect(parse(`a ${symbol} b`)).toEqual({
name: fn,
type: 'function',
args: [variableEqual('a'), variableEqual('b')],
text: `a ${symbol} b`,
location: { min: 0, max: 4 + symbol.length },
});
});
it.each`
expression
${`1 + (1 ${symbol} 1)`}
${`(1 ${symbol} 1) + 1`}
${`((1 ${symbol} 1) + 1)`}
${`((1 ${symbol} 1) + (1 ${symbol} 1))`}
${`((1 ${symbol} 1) + ( ${symbol} 1))`}
${` ${symbol} 1`}
${`1 ${symbol} `}
${`a + (b ${symbol} c)`}
${`(a ${symbol} b) + c`}
${`((a ${symbol} b) + c)`}
${`((a ${symbol} b) + (c ${symbol} d))`}
${`((a ${symbol} b) + ( ${symbol} c))`}
${` ${symbol} a`}
${`a ${symbol} `}
`(
'should throw for invalid expression with comparison arguments: $expression',
({ expression }) => {
expect(() => parse(expression)).toThrow();
}
);
it.each`
expression
${`1 ${symbol} 1 ${symbol} 1`}
${`(1 ${symbol} 1) ${symbol} 1`}
${`1 ${symbol} (1 ${symbol} 1)`}
${`a ${symbol} b ${symbol} c`}
${`(a ${symbol} b) ${symbol} c`}
${`a ${symbol} (b ${symbol} c)`}
`('should throw for cascading comparison operators: $expression', ({ expression }) => {
expect(() => parse(expression)).toThrow();
});
it.each`
expression
${`1 ${symbol} 1`}
${`(1 ${symbol} 1)`}
${`((1 ${symbol} 1))`}
${`((1 + 1) ${symbol} 1)`}
${`1 + 1 ${symbol} 1 * 1`}
${`a ${symbol} b`}
${`(a ${symbol} b)`}
${`((a ${symbol} b))`}
${`((a + b) ${symbol} c)`}
${`a + b ${symbol} c * d`}
`('should parse comparison expressions: $expression', ({ expression }) => {
expect(() => parse(expression)).not.toThrow();
});
});
});
});
describe('Variables', () => {

View file

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

View file

@ -48,7 +48,7 @@ import { MemoizedFormulaHelp } from './formula_help';
import './formula.scss';
import { FormulaIndexPatternColumn } from '../formula';
import { insertOrReplaceFormulaColumn } from '../parse';
import { filterByVisibleOperation } from '../util';
import { filterByVisibleOperation, nonNullable } from '../util';
import { getColumnTimeShiftWarnings, getDateHistogramInterval } from '../../../../time_shift_utils';
function tableHasData(
@ -363,7 +363,7 @@ export function FormulaEditor({
}
return newWarnings;
})
.filter((marker) => marker);
.filter(nonNullable);
setWarnings(markers.map(({ severity, message }) => ({ severity, message })));
monaco.editor.setModelMarkers(editorModel.current, 'LENS', markers);
}

View file

@ -21,6 +21,7 @@ import {
EuiSpacer,
} from '@elastic/eui';
import { Markdown } from '@kbn/kibana-react-plugin/public';
import { groupBy } from 'lodash';
import type { IndexPattern } from '../../../../../../types';
import { tinymathFunctions } from '../util';
import { getPossibleFunctions } from './math_completion';
@ -193,31 +194,40 @@ max(system.network.in.bytes, reducedTimeRange="30m")
items: [],
});
const availableFunctions = getPossibleFunctions(indexPattern);
const {
elasticsearch: esFunction,
calculation: calculationFunction,
math: mathOperations,
comparison: comparisonOperations,
} = useMemo(
() =>
groupBy(getPossibleFunctions(indexPattern), (key) => {
if (key in operationDefinitionMap) {
return operationDefinitionMap[key].documentation?.section;
}
if (key in tinymathFunctions) {
return tinymathFunctions[key].section;
}
}),
[operationDefinitionMap, indexPattern]
);
// Es aggs
helpGroups[2].items.push(
...availableFunctions
.filter(
(key) =>
key in operationDefinitionMap &&
operationDefinitionMap[key].documentation?.section === 'elasticsearch'
)
.sort()
.map((key) => ({
label: key,
description: (
<>
<h3>
{key}({operationDefinitionMap[key].documentation?.signature})
</h3>
...esFunction.sort().map((key) => ({
label: key,
description: (
<>
<h3>
{key}({operationDefinitionMap[key].documentation?.signature})
</h3>
{operationDefinitionMap[key].documentation?.description ? (
<Markdown markdown={operationDefinitionMap[key].documentation!.description} />
) : null}
</>
),
}))
{operationDefinitionMap[key].documentation?.description ? (
<Markdown markdown={operationDefinitionMap[key].documentation!.description} />
) : null}
</>
),
}))
);
helpGroups.push({
@ -236,31 +246,24 @@ max(system.network.in.bytes, reducedTimeRange="30m")
// Calculations aggs
helpGroups[3].items.push(
...availableFunctions
.filter(
(key) =>
key in operationDefinitionMap &&
operationDefinitionMap[key].documentation?.section === 'calculation'
)
.sort()
.map((key) => ({
label: key,
description: (
<>
<h3>
{key}({operationDefinitionMap[key].documentation?.signature})
</h3>
...calculationFunction.sort().map((key) => ({
label: key,
description: (
<>
<h3>
{key}({operationDefinitionMap[key].documentation?.signature})
</h3>
{operationDefinitionMap[key].documentation?.description ? (
<Markdown markdown={operationDefinitionMap[key].documentation!.description} />
) : null}
</>
),
checked:
selectedFunction === `${key}: ${operationDefinitionMap[key].displayName}`
? ('on' as const)
: undefined,
}))
{operationDefinitionMap[key].documentation?.description ? (
<Markdown markdown={operationDefinitionMap[key].documentation!.description} />
) : null}
</>
),
checked:
selectedFunction === `${key}: ${operationDefinitionMap[key].displayName}`
? ('on' as const)
: undefined,
}))
);
helpGroups.push({
@ -274,22 +277,55 @@ max(system.network.in.bytes, reducedTimeRange="30m")
items: [],
});
const tinymathFns = useMemo(() => {
return getPossibleFunctions(indexPattern)
.filter((key) => key in tinymathFunctions)
.sort()
.map((key) => {
const [description, examples] = tinymathFunctions[key].help.split(`\`\`\``);
return {
label: key,
description: description.replace(/\n/g, '\n\n'),
examples: examples ? `\`\`\`${examples}\`\`\`` : '',
};
});
}, [indexPattern]);
const mathFns = useMemo(() => {
return mathOperations.sort().map((key) => {
const [description, examples] = tinymathFunctions[key].help.split(`\`\`\``);
return {
label: key,
description: description.replace(/\n/g, '\n\n'),
examples: examples ? `\`\`\`${examples}\`\`\`` : '',
};
});
}, [mathOperations]);
helpGroups[4].items.push(
...tinymathFns.map(({ label, description, examples }) => {
...mathFns.map(({ label, description, examples }) => {
return {
label,
description: (
<>
<h3>{getFunctionSignatureLabel(label, operationDefinitionMap)}</h3>
<Markdown markdown={`${description}${examples}`} />
</>
),
};
})
);
helpGroups.push({
label: i18n.translate('xpack.lens.formulaDocumentation.comparisonSection', {
defaultMessage: 'Comparison',
}),
description: i18n.translate('xpack.lens.formulaDocumentation.comparisonSectionDescription', {
defaultMessage: 'These functions are used to perform value comparison.',
}),
items: [],
});
const comparisonFns = useMemo(() => {
return comparisonOperations.sort().map((key) => {
const [description, examples] = tinymathFunctions[key].help.split(`\`\`\``);
return {
label: key,
description: description.replace(/\n/g, '\n\n'),
examples: examples ? `\`\`\`${examples}\`\`\`` : '',
};
});
}, [comparisonOperations]);
helpGroups[5].items.push(
...comparisonFns.map(({ label, description, examples }) => {
return {
label,
description: (

View file

@ -24,7 +24,7 @@ import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public';
import { parseTimeShift } from '@kbn/data-plugin/common';
import type { IndexPattern } from '../../../../../../types';
import { memoizedGetAvailableOperationsByMetadata } from '../../../operations';
import { tinymathFunctions, groupArgsByType, unquotedStringRegex } from '../util';
import { tinymathFunctions, groupArgsByType, unquotedStringRegex, nonNullable } from '../util';
import type { GenericOperationDefinition } from '../..';
import { getFunctionSignatureLabel, getHelpTextContent } from './formula_help';
import { hasFunctionFieldArgument } from '../validation';
@ -78,7 +78,7 @@ export function getInfoAtZeroIndexedPosition(
if (ast.type === 'function') {
const [match] = ast.args
.map((arg) => getInfoAtZeroIndexedPosition(arg, zeroIndexedPosition, ast))
.filter((a) => a);
.filter(nonNullable);
if (match) {
return match;
} else if (ast.location) {
@ -297,7 +297,7 @@ function getArgumentSuggestions(
const fields = validOperation.operations
.filter((op) => op.operationType === operation.type)
.map((op) => ('field' in op ? op.field : undefined))
.filter((field) => field);
.filter(nonNullable);
const fieldArg = ast.args[0];
const location = typeof fieldArg !== 'string' && (fieldArg as TinymathVariable).location;
let range: monaco.IRange | undefined;

View file

@ -1491,6 +1491,23 @@ invalid: "
).toEqual(undefined);
});
it('returns no error if the formula contains comparison operator within the ifelse operation', () => {
const formulas = [
...['lt', 'gt', 'lte', 'gte', 'eq'].map((op) => `${op}(5, 1)`),
...['<', '>', '==', '>=', '<='].map((symbol) => `5 ${symbol} 1`),
];
for (const formula of formulas) {
expect(
formulaOperation.getErrorMessage!(
getNewLayerWithFormula(`ifelse(${formula}, 1, 5)`),
'col1',
indexPattern,
operationDefinitionMap
)
).toEqual(undefined);
}
});
it('returns no error if a math operation is passed to fullReference operations', () => {
const formulas = [
'derivative(7+1)',
@ -1534,6 +1551,8 @@ invalid: "
{ formula: 'last_value(dest)' },
{ formula: 'terms(dest)' },
{ formula: 'moving_average(last_value(dest), window=7)', errorFormula: 'last_value(dest)' },
...['lt', 'gt', 'lte', 'gte', 'eq'].map((op) => ({ formula: `${op}(5, 1)` })),
...['<', '>', '==', '>=', '<='].map((symbol) => ({ formula: `5 ${symbol} 1` })),
];
for (const { formula, errorFormula } of formulas) {
expect(
@ -1546,7 +1565,7 @@ invalid: "
).toEqual([
`The return value type of the operation ${
errorFormula ?? formula
} is not supported in Formula.`,
} is not supported in Formula`,
]);
}
});
@ -1557,8 +1576,14 @@ invalid: "
// * field passed
// * missing argument
const errors = [
(operation: string) =>
`The first argument for ${operation} should be a operation name. Found ()`,
(operation: string) => {
const required = tinymathFunctions[operation].positionalArguments.filter(
({ optional }) => !optional
);
return `The operation ${operation} in the Formula is missing ${
required.length
} arguments: ${required.map(({ name }) => name).join(', ')}`;
},
(operation: string) => `The operation ${operation} has too many arguments`,
(operation: string) => `The operation ${operation} does not accept any field as argument`,
(operation: string) => {
@ -1573,9 +1598,11 @@ invalid: "
.join(', ')}`;
},
];
const mathFns = Object.keys(tinymathFunctions);
// we'll try to map all of these here in this test
for (const fn of Object.keys(tinymathFunctions)) {
it(`returns an error for the math functions available: ${fn}`, () => {
for (const fn of mathFns) {
it(`[${fn}] returns an error for the math functions available`, () => {
const nArgs = tinymathFunctions[fn].positionalArguments;
// start with the first 3 types
const formulas = [
@ -1585,14 +1612,22 @@ invalid: "
`${fn}(${Array(nArgs.length).fill('bytes').join(', ')})`,
];
// add the fourth check only for those functions with more than 1 arg required
// and check that this first argument is of type number
const enableFourthCheck =
nArgs.filter(
({ optional, alternativeWhenMissing }) => !optional && !alternativeWhenMissing
).length > 1;
).length > 1 && nArgs[0]?.type === 'number';
if (enableFourthCheck) {
formulas.push(`${fn}(1)`);
}
formulas.forEach((formula, i) => {
const finalFormulas = formulas.map((text) => {
if (tinymathFunctions[fn].outputType !== 'boolean') {
return text;
}
// for comparison functions wrap the existing formula within the ifelse function
return `ifelse(${text}, 1, 0)`;
});
finalFormulas.forEach((formula, i) => {
expect(
formulaOperation.getErrorMessage!(
getNewLayerWithFormula(formula),
@ -1600,16 +1635,87 @@ invalid: "
indexPattern,
operationDefinitionMap
)
).toEqual([errors[i](fn)]);
).toContain(errors[i](fn));
});
});
}
// comparison tests
for (const fn of mathFns.filter((name) => tinymathFunctions[name].section === 'comparison')) {
if (tinymathFunctions[fn].outputType === 'boolean') {
it(`[${fn}] returns an error about unsupported return type and when partial arguments are passed`, () => {
const formulas = [`${fn}()`, `${fn}(1)`];
formulas.forEach((formula, nArg) => {
const expectedCount = tinymathFunctions[fn].positionalArguments.length - nArg;
const expectedArgs = ['left', 'right'].slice(nArg).join(', ');
expect(
formulaOperation.getErrorMessage!(
getNewLayerWithFormula(formula),
'col1',
indexPattern,
operationDefinitionMap
)
).toEqual([
`The return value type of the operation ${formula} is not supported in Formula`,
`The operation ${fn} in the Formula is missing ${expectedCount} arguments: ${expectedArgs}`,
]);
});
});
} else {
const indexReverseMap = {
cond: [0],
left: [1],
right: [2],
all: [0, 1, 2],
};
it.each`
cond | left | right | expectedFail
${'1'} | ${'2'} | ${'3'} | ${'cond'}
${'1 > 1'} | ${'2 > 2'} | ${'3'} | ${'left'}
${'1 > 1'} | ${'2'} | ${'3 > 3'} | ${'right'}
${'1'} | ${'2 > 2'} | ${'3 > 3'} | ${'all'}
${'count()'} | ${'average(bytes)'} | ${'average(bytes)'} | ${'cond'}
${'count() > 1'} | ${'average(bytes) > 2'} | ${'average(bytes)'} | ${'left'}
${'count() > 1'} | ${'average(bytes)'} | ${'average(bytes) > 3'} | ${'right'}
${'count()'} | ${'average(bytes) > 2'} | ${'average(bytes) > 3'} | ${'all'}
`(
`[${fn}] returns an error if $expectedFail argument is/are of the wrong type: ${fn}($cond, $left, $right)`,
({
cond,
left,
right,
expectedFail,
}: {
cond: string;
left: string;
right: string;
expectedFail: keyof typeof indexReverseMap;
}) => {
const argsSorted = [cond, left, right];
expect(
formulaOperation.getErrorMessage!(
getNewLayerWithFormula(`${fn}(${cond}, ${left}, ${right})`),
'col1',
indexPattern,
operationDefinitionMap
)
).toEqual(
indexReverseMap[expectedFail].map((i) => {
const arg = tinymathFunctions[fn].positionalArguments[i];
const passedValue = />/.test(argsSorted[i]) ? 'boolean' : 'number';
return `The ${arg.name} argument for the operation ${fn} in the Formula is of the wrong type: ${passedValue} instead of ${arg.type}`;
})
);
}
);
}
}
it('returns an error suggesting to use an alternative function', () => {
const formulas = [`clamp(1)`, 'clamp(1, 5)'];
const errorsWithSuggestions = [
'The operation clamp in the Formula is missing the min argument: use the pick_max operation instead.',
'The operation clamp in the Formula is missing the max argument: use the pick_min operation instead.',
'The operation clamp in the Formula is missing the min argument: use the pick_max operation instead',
'The operation clamp in the Formula is missing the max argument: use the pick_min operation instead',
];
formulas.forEach((formula, i) => {
expect(
@ -1648,7 +1754,7 @@ invalid: "
operationDefinitionMap
)
).toEqual([
`The Formula filter of type "lucene" is not compatible with the inner filter of type "kql" from the ${operation} operation.`,
`The Formula filter of type "lucene" is not compatible with the inner filter of type "kql" from the ${operation} operation`,
]);
}
});
@ -1668,8 +1774,8 @@ invalid: "
operationDefinitionMap
)
).toEqual([
`The Formula filter of type "lucene" is not compatible with the inner filter of type "kql" from the count operation.`,
`The Formula filter of type "lucene" is not compatible with the inner filter of type "kql" from the sum operation.`,
`The Formula filter of type "lucene" is not compatible with the inner filter of type "kql" from the count operation`,
`The Formula filter of type "lucene" is not compatible with the inner filter of type "kql" from the sum operation`,
]);
});

View file

@ -13,7 +13,7 @@ import { runASTValidation, tryToParse } from './validation';
import { WrappedFormulaEditor } from './editor';
import { insertOrReplaceFormulaColumn } from './parse';
import { generateFormula } from './generate';
import { filterByVisibleOperation } from './util';
import { filterByVisibleOperation, nonNullable } from './util';
import { getManagedColumnsFrom } from '../../layer_helpers';
import { getFilter, isColumnFormatted } from '../helpers';
@ -77,7 +77,8 @@ export const formulaOperation: OperationDefinition<FormulaIndexPatternColumn, 'm
const errors = runASTValidation(root, layer, indexPattern, visibleOperationsMap, column);
if (errors.length) {
return errors.map(({ message }) => message);
// remove duplicates
return Array.from(new Set(errors.map(({ message }) => message)));
}
const managedColumns = getManagedColumnsFrom(columnId, layer.columns);
@ -90,7 +91,7 @@ export const formulaOperation: OperationDefinition<FormulaIndexPatternColumn, 'm
}
return [];
})
.filter((marker) => marker);
.filter(nonNullable);
const hasBuckets = layer.columnOrder.some((colId) => layer.columns[colId].isBucketed);
const hasOtherMetrics = layer.columnOrder.some((colId) => {
const col = layer.columns[colId];

View file

@ -25,6 +25,7 @@ import {
getOperationParams,
groupArgsByType,
mergeWithGlobalFilters,
nonNullable,
} from './util';
import { FormulaIndexPatternColumn, isFormulaIndexPatternColumn } from './formula';
import { getColumnOrder } from '../../layer_helpers';
@ -89,9 +90,9 @@ function extractColumns(
const nodeOperation = operations[node.name];
if (!nodeOperation) {
// it's a regular math node
const consumedArgs = node.args
.map(parseNode)
.filter((n) => typeof n !== 'undefined' && n !== null) as Array<number | TinymathVariable>;
const consumedArgs = node.args.map(parseNode).filter(nonNullable) as Array<
number | TinymathVariable
>;
return {
...node,
args: consumedArgs,

View file

@ -114,12 +114,16 @@ function getTypeI18n(type: string) {
if (type === 'string') {
return i18n.translate('xpack.lens.formula.string', { defaultMessage: 'string' });
}
if (type === 'boolean') {
return i18n.translate('xpack.lens.formula.boolean', { defaultMessage: 'boolean' });
}
return '';
}
export const tinymathFunctions: Record<
string,
{
section: 'math' | 'comparison';
positionalArguments: Array<{
name: string;
optional?: boolean;
@ -129,9 +133,13 @@ export const tinymathFunctions: Record<
}>;
// Help is in Markdown format
help: string;
// When omitted defaults to "number".
// Used for comparison functions return type
outputType?: string;
}
> = {
add: {
section: 'math',
positionalArguments: [
{
name: i18n.translate('xpack.lens.formula.left', { defaultMessage: 'left' }),
@ -158,6 +166,7 @@ Example: Offset count by a static value
}),
},
subtract: {
section: 'math',
positionalArguments: [
{
name: i18n.translate('xpack.lens.formula.left', { defaultMessage: 'left' }),
@ -179,6 +188,7 @@ Example: Calculate the range of a field
}),
},
multiply: {
section: 'math',
positionalArguments: [
{
name: i18n.translate('xpack.lens.formula.left', { defaultMessage: 'left' }),
@ -203,6 +213,7 @@ Example: Calculate price after constant tax rate
}),
},
divide: {
section: 'math',
positionalArguments: [
{
name: i18n.translate('xpack.lens.formula.left', { defaultMessage: 'left' }),
@ -226,6 +237,7 @@ Example: \`divide(sum(bytes), 2)\`
}),
},
abs: {
section: 'math',
positionalArguments: [
{
name: i18n.translate('xpack.lens.formula.value', { defaultMessage: 'value' }),
@ -241,6 +253,7 @@ Example: Calculate average distance to sea level \`abs(average(altitude))\`
}),
},
cbrt: {
section: 'math',
positionalArguments: [
{
name: i18n.translate('xpack.lens.formula.value', { defaultMessage: 'value' }),
@ -257,6 +270,7 @@ Example: Calculate side length from volume
}),
},
ceil: {
section: 'math',
positionalArguments: [
{
name: i18n.translate('xpack.lens.formula.value', { defaultMessage: 'value' }),
@ -273,6 +287,7 @@ Example: Round up price to the next dollar
}),
},
clamp: {
section: 'math',
positionalArguments: [
{
name: i18n.translate('xpack.lens.formula.value', { defaultMessage: 'value' }),
@ -305,6 +320,7 @@ clamp(
}),
},
cube: {
section: 'math',
positionalArguments: [
{
name: i18n.translate('xpack.lens.formula.value', { defaultMessage: 'value' }),
@ -321,6 +337,7 @@ Example: Calculate volume from side length
}),
},
exp: {
section: 'math',
positionalArguments: [
{
name: i18n.translate('xpack.lens.formula.value', { defaultMessage: 'value' }),
@ -338,6 +355,7 @@ Example: Calculate the natural exponential function
}),
},
fix: {
section: 'math',
positionalArguments: [
{
name: i18n.translate('xpack.lens.formula.value', { defaultMessage: 'value' }),
@ -354,6 +372,7 @@ Example: Rounding towards zero
}),
},
floor: {
section: 'math',
positionalArguments: [
{
name: i18n.translate('xpack.lens.formula.value', { defaultMessage: 'value' }),
@ -370,6 +389,7 @@ Example: Round down a price
}),
},
log: {
section: 'math',
positionalArguments: [
{
name: i18n.translate('xpack.lens.formula.value', { defaultMessage: 'value' }),
@ -395,6 +415,7 @@ log(sum(bytes), 2)
}),
},
mod: {
section: 'math',
positionalArguments: [
{
name: i18n.translate('xpack.lens.formula.value', { defaultMessage: 'value' }),
@ -415,6 +436,7 @@ Example: Calculate last three digits of a value
}),
},
pow: {
section: 'math',
positionalArguments: [
{
name: i18n.translate('xpack.lens.formula.value', { defaultMessage: 'value' }),
@ -435,6 +457,7 @@ Example: Calculate volume based on side length
}),
},
round: {
section: 'math',
positionalArguments: [
{
name: i18n.translate('xpack.lens.formula.value', { defaultMessage: 'value' }),
@ -460,6 +483,7 @@ round(sum(bytes), 2)
}),
},
sqrt: {
section: 'math',
positionalArguments: [
{
name: i18n.translate('xpack.lens.formula.value', { defaultMessage: 'value' }),
@ -476,6 +500,7 @@ Example: Calculate side length based on area
}),
},
square: {
section: 'math',
positionalArguments: [
{
name: i18n.translate('xpack.lens.formula.value', { defaultMessage: 'value' }),
@ -492,6 +517,7 @@ Example: Calculate area based on side length
}),
},
pick_max: {
section: 'math',
positionalArguments: [
{
name: i18n.translate('xpack.lens.formula.left', { defaultMessage: 'left' }),
@ -512,6 +538,7 @@ Example: Find the maximum between two fields averages
}),
},
pick_min: {
section: 'math',
positionalArguments: [
{
name: i18n.translate('xpack.lens.formula.left', { defaultMessage: 'left' }),
@ -532,6 +559,7 @@ Example: Find the minimum between two fields averages
}),
},
defaults: {
section: 'math',
positionalArguments: [
{
name: i18n.translate('xpack.lens.formula.value', { defaultMessage: 'value' }),
@ -548,11 +576,170 @@ Returns a default numeric value when value is null.
Example: Return -1 when a field has no data
\`defaults(average(bytes), -1)\`
`,
}),
},
lt: {
section: 'comparison',
positionalArguments: [
{
name: i18n.translate('xpack.lens.formula.left', { defaultMessage: 'left' }),
type: getTypeI18n('number'),
},
{
name: i18n.translate('xpack.lens.formula.right', { defaultMessage: 'right' }),
type: getTypeI18n('number'),
},
],
outputType: getTypeI18n('boolean'),
help: i18n.translate('xpack.lens.formula.ltFunction.markdown', {
defaultMessage: `
Performs a lower than comparison between two values.
To be used as condition for \`ifelse\` comparison function.
Also works with \`<\` symbol.
Example: Returns true if the average of bytes is lower than the average amount of memory
\`average(bytes) <= average(memory)\`
Example: \`lt(average(bytes), 1000)\`
`,
}),
},
gt: {
section: 'comparison',
positionalArguments: [
{
name: i18n.translate('xpack.lens.formula.left', { defaultMessage: 'left' }),
type: getTypeI18n('number'),
},
{
name: i18n.translate('xpack.lens.formula.right', { defaultMessage: 'right' }),
type: getTypeI18n('number'),
},
],
outputType: getTypeI18n('boolean'),
help: i18n.translate('xpack.lens.formula.gtFunction.markdown', {
defaultMessage: `
Performs a greater than comparison between two values.
To be used as condition for \`ifelse\` comparison function.
Also works with \`>\` symbol.
Example: Returns true if the average of bytes is greater than the average amount of memory
\`average(bytes) > average(memory)\`
Example: \`gt(average(bytes), 1000)\`
`,
}),
},
eq: {
section: 'comparison',
positionalArguments: [
{
name: i18n.translate('xpack.lens.formula.left', { defaultMessage: 'left' }),
type: getTypeI18n('number'),
},
{
name: i18n.translate('xpack.lens.formula.right', { defaultMessage: 'right' }),
type: getTypeI18n('number'),
},
],
outputType: getTypeI18n('boolean'),
help: i18n.translate('xpack.lens.formula.eqFunction.markdown', {
defaultMessage: `
Performs an equality comparison between two values.
To be used as condition for \`ifelse\` comparison function.
Also works with \`==\` symbol.
Example: Returns true if the average of bytes is exactly the same amount of average memory
\`average(bytes) == average(memory)\`
Example: \`eq(sum(bytes), 1000000)\`
`,
}),
},
lte: {
section: 'comparison',
positionalArguments: [
{
name: i18n.translate('xpack.lens.formula.left', { defaultMessage: 'left' }),
type: getTypeI18n('number'),
},
{
name: i18n.translate('xpack.lens.formula.right', { defaultMessage: 'right' }),
type: getTypeI18n('number'),
},
],
outputType: getTypeI18n('boolean'),
help: i18n.translate('xpack.lens.formula.lteFunction.markdown', {
defaultMessage: `
Performs a lower than or equal comparison between two values.
To be used as condition for \`ifelse\` comparison function.
Also works with \`<=\` symbol.
Example: Returns true if the average of bytes is lower than or equal to the average amount of memory
\`average(bytes) <= average(memory)\`
Example: \`lte(average(bytes), 1000)\`
`,
}),
},
gte: {
section: 'comparison',
positionalArguments: [
{
name: i18n.translate('xpack.lens.formula.left', { defaultMessage: 'left' }),
type: getTypeI18n('number'),
},
{
name: i18n.translate('xpack.lens.formula.right', { defaultMessage: 'right' }),
type: getTypeI18n('number'),
},
],
outputType: getTypeI18n('boolean'),
help: i18n.translate('xpack.lens.formula.gteFunction.markdown', {
defaultMessage: `
Performs a greater than comparison between two values.
To be used as condition for \`ifelse\` comparison function.
Also works with \`>=\` symbol.
Example: Returns true if the average of bytes is greater than or equal to the average amount of memory
\`average(bytes) >= average(memory)\`
Example: \`gte(average(bytes), 1000)\`
`,
}),
},
ifelse: {
section: 'comparison',
positionalArguments: [
{
name: i18n.translate('xpack.lens.formula.condition', { defaultMessage: 'condition' }),
type: getTypeI18n('boolean'),
},
{
name: i18n.translate('xpack.lens.formula.left', { defaultMessage: 'left' }),
type: getTypeI18n('number'),
},
{
name: i18n.translate('xpack.lens.formula.right', { defaultMessage: 'right' }),
type: getTypeI18n('number'),
},
],
help: i18n.translate('xpack.lens.formula.ifElseFunction.markdown', {
defaultMessage: `
Returns a value depending on whether the element of condition is true or false.
Example: Average revenue per customer but in some cases customer id is not provided which counts as additional customer
\`sum(total)/(unique_count(customer_id) + ifelse( count() > count(kql='customer_id:*'), 1, 0))\`
`,
}),
},
};
export function nonNullable<T>(v: T): v is NonNullable<T> {
return v != null;
}
export function isMathNode(node: TinymathAST | string) {
return isObject(node) && node.type === 'function' && tinymathFunctions[node.name];
}
@ -562,7 +749,7 @@ export function findMathNodes(root: TinymathAST | string): TinymathFunction[] {
if (!isObject(node) || node.type !== 'function' || !isMathNode(node)) {
return [];
}
return [node, ...node.args.flatMap(flattenMathNodes)].filter(Boolean);
return [node, ...node.args.flatMap(flattenMathNodes)].filter(nonNullable);
}
return flattenMathNodes(root);

View file

@ -19,6 +19,7 @@ import {
getValueOrName,
groupArgsByType,
isMathNode,
nonNullable,
tinymathFunctions,
} from './util';
@ -45,6 +46,10 @@ interface ValidationErrors {
message: string;
type: { operation: string; params: string };
};
wrongTypeArgument: {
message: string;
type: { operation: string; name: string; type: string; expectedType: string };
};
wrongFirstArgument: {
message: string;
type: { operation: string; type: string; argument: string | number };
@ -109,6 +114,26 @@ export interface ErrorWrapper {
severity?: 'error' | 'warning';
}
function getNodeLocation(node: TinymathFunction): TinymathLocation[] {
return [node.location].filter(nonNullable);
}
function getArgumentType(arg: TinymathAST, operations: Record<string, GenericOperationDefinition>) {
if (!isObject(arg)) {
return typeof arg;
}
if (arg.type === 'function') {
if (tinymathFunctions[arg.name]) {
return tinymathFunctions[arg.name].outputType ?? 'number';
}
// Assume it's a number for now
if (operations[arg.name]) {
return 'number';
}
}
// leave for now other argument types
}
export function isParsingError(message: string) {
return message.includes('Failed to parse expression');
}
@ -118,7 +143,7 @@ function findFunctionNodes(root: TinymathAST | string): TinymathFunction[] {
if (!isObject(node) || node.type !== 'function') {
return [];
}
return [node, ...node.args.flatMap(flattenFunctionNodes)].filter(Boolean);
return [node, ...node.args.flatMap(flattenFunctionNodes)].filter(nonNullable);
}
return flattenFunctionNodes(root);
@ -132,14 +157,15 @@ export function hasInvalidOperations(
return {
// avoid duplicates
names: Array.from(new Set(nodes.map(({ name }) => name))),
locations: nodes.map(({ location }) => location).filter((a) => a) as TinymathLocation[],
locations: nodes.map(({ location }) => location).filter(nonNullable),
};
}
export const getRawQueryValidationError = (text: string, operations: Record<string, unknown>) => {
// try to extract the query context here
const singleLine = text.split('\n').join('');
const allArgs = singleLine.split(',').filter((args) => /(kql|lucene)/.test(args));
const languagesRegexp = /(kql|lucene)/;
const allArgs = singleLine.split(',').filter((args) => languagesRegexp.test(args));
// check for the presence of a valid ES operation
const containsOneValidOperation = Object.keys(operations).some((operation) =>
singleLine.includes(operation)
@ -153,7 +179,7 @@ export const getRawQueryValidationError = (text: string, operations: Record<stri
// For instance: count(kql=...) + count(lucene=...) - count(kql=...)
// therefore before partition them, split them by "count" keywork and filter only string with a length
const flattenArgs = allArgs.flatMap((arg) =>
arg.split('count').filter((subArg) => /(kql|lucene)/.test(subArg))
arg.split('count').filter((subArg) => languagesRegexp.test(subArg))
);
const [kqlQueries, luceneQueries] = partition(flattenArgs, (arg) => /kql/.test(arg));
const errors = [];
@ -260,6 +286,18 @@ function getMessageFromId<K extends ErrorTypes>({
values: { operation: out.operation, params: out.params },
});
break;
case 'wrongTypeArgument':
message = i18n.translate('xpack.lens.indexPattern.formulaExpressionWrongTypeArgument', {
defaultMessage:
'The {name} argument for the operation {operation} in the Formula is of the wrong type: {type} instead of {expectedType}',
values: {
operation: out.operation,
name: out.name,
type: out.type,
expectedType: out.expectedType,
},
});
break;
case 'duplicateArgument':
message = i18n.translate('xpack.lens.indexPattern.formulaOperationDuplicateParams', {
defaultMessage:
@ -332,21 +370,20 @@ function getMessageFromId<K extends ErrorTypes>({
break;
case 'wrongReturnedType':
message = i18n.translate('xpack.lens.indexPattern.formulaOperationWrongReturnedType', {
defaultMessage:
'The return value type of the operation {text} is not supported in Formula.',
defaultMessage: 'The return value type of the operation {text} is not supported in Formula',
values: { text: out.text },
});
break;
case 'filtersTypeConflict':
message = i18n.translate('xpack.lens.indexPattern.formulaOperationFiltersTypeConflicts', {
defaultMessage:
'The Formula filter of type "{outerType}" is not compatible with the inner filter of type "{innerType}" from the {operation} operation.',
'The Formula filter of type "{outerType}" is not compatible with the inner filter of type "{innerType}" from the {operation} operation',
values: { operation: out.operation, outerType: out.outerType, innerType: out.innerType },
});
break;
case 'useAlternativeFunction':
message = i18n.translate('xpack.lens.indexPattern.formulaUseAlternative', {
defaultMessage: `The operation {operation} in the Formula is missing the {params} argument: use the {alternativeFn} operation instead.`,
defaultMessage: `The operation {operation} in the Formula is missing the {params} argument: use the {alternativeFn} operation instead`,
values: { operation: out.operation, params: out.params, alternativeFn: out.alternativeFn },
});
break;
@ -404,6 +441,7 @@ export function runASTValidation(
) {
return [
...checkMissingVariableOrFunctions(ast, layer, indexPattern, operations),
...checkTopNodeReturnType(ast),
...runFullASTValidation(ast, layer, indexPattern, operations, currentColumn),
];
}
@ -548,7 +586,7 @@ function validateFiltersArguments(
innerType,
outerType,
},
locations: node.location ? [node.location] : [],
locations: getNodeLocation(node),
})
);
}
@ -574,7 +612,7 @@ function validateNameArguments(
operation: node.name,
params: missingParams.map(({ name }) => name).join(', '),
},
locations: node.location ? [node.location] : [],
locations: getNodeLocation(node),
})
);
}
@ -587,7 +625,7 @@ function validateNameArguments(
operation: node.name,
params: wrongTypeParams.map(({ name }) => name).join(', '),
},
locations: node.location ? [node.location] : [],
locations: getNodeLocation(node),
})
);
}
@ -600,7 +638,7 @@ function validateNameArguments(
operation: node.name,
params: duplicateParams.join(', '),
},
locations: node.location ? [node.location] : [],
locations: getNodeLocation(node),
})
);
}
@ -614,13 +652,34 @@ function validateNameArguments(
getMessageFromId({
messageId: 'tooManyQueries',
values: {},
locations: node.location ? [node.location] : [],
locations: getNodeLocation(node),
})
);
}
return errors;
}
const DEFAULT_RETURN_TYPE = 'number';
function checkTopNodeReturnType(ast: TinymathAST): ErrorWrapper[] {
if (
isObject(ast) &&
ast.type === 'function' &&
ast.text &&
(tinymathFunctions[ast.name]?.outputType || DEFAULT_RETURN_TYPE) !== DEFAULT_RETURN_TYPE
) {
return [
getMessageFromId({
messageId: 'wrongReturnedType',
values: {
text: ast.text,
},
locations: getNodeLocation(ast),
}),
];
}
return [];
}
function runFullASTValidation(
ast: TinymathAST,
layer: FormBasedLayer,
@ -645,7 +704,7 @@ function runFullASTValidation(
const [firstArg] = node?.args || [];
if (!nodeOperation) {
errors.push(...validateMathNodes(node, missingVariablesSet));
errors.push(...validateMathNodes(node, missingVariablesSet, operations));
// carry on with the validation for all the functions within the math operation
if (functions?.length) {
return errors.concat(functions.flatMap((fn) => validateNode(fn)));
@ -664,7 +723,7 @@ function runFullASTValidation(
}),
argument: `math operation`,
},
locations: node.location ? [node.location] : [],
locations: getNodeLocation(node),
})
);
} else {
@ -683,7 +742,7 @@ function runFullASTValidation(
defaultMessage: 'no field',
}),
},
locations: node.location ? [node.location] : [],
locations: getNodeLocation(node),
})
);
}
@ -716,7 +775,7 @@ function runFullASTValidation(
values: {
operation: node.name,
},
locations: node.location ? [node.location] : [],
locations: getNodeLocation(node),
})
);
} else {
@ -742,7 +801,8 @@ function runFullASTValidation(
// In general this should be handled down the Esaggs route rather than here
const isFirstArgumentNotValid = Boolean(
!isArgumentValidType(firstArg, 'function') ||
(isMathNode(firstArg) && validateMathNodes(firstArg, missingVariablesSet).length)
(isMathNode(firstArg) &&
validateMathNodes(firstArg, missingVariablesSet, operations).length)
);
// First field has a special handling
if (isFirstArgumentNotValid) {
@ -760,7 +820,7 @@ function runFullASTValidation(
defaultMessage: 'no operation',
}),
},
locations: node.location ? [node.location] : [],
locations: getNodeLocation(node),
})
);
}
@ -786,7 +846,7 @@ function runFullASTValidation(
values: {
operation: node.name,
},
locations: node.location ? [node.location] : [],
locations: getNodeLocation(node),
})
);
} else {
@ -946,22 +1006,27 @@ export function isArgumentValidType(arg: TinymathAST | string, type: TinymathNod
return isObject(arg) && arg.type === type;
}
export function validateMathNodes(root: TinymathAST, missingVariableSet: Set<string>) {
export function validateMathNodes(
root: TinymathAST,
missingVariableSet: Set<string>,
operations: Record<string, GenericOperationDefinition>
) {
const mathNodes = findMathNodes(root);
const errors: ErrorWrapper[] = [];
mathNodes.forEach((node: TinymathFunction) => {
const { positionalArguments } = tinymathFunctions[node.name];
const mandatoryArguments = positionalArguments.filter(({ optional }) => !optional);
if (!node.args.length) {
// we can stop here
return errors.push(
getMessageFromId({
messageId: 'wrongFirstArgument',
messageId: 'missingMathArgument',
values: {
operation: node.name,
type: 'operation',
argument: `()`,
count: mandatoryArguments.length,
params: mandatoryArguments.map(({ name }) => name).join(', '),
},
locations: node.location ? [node.location] : [],
locations: getNodeLocation(node),
})
);
}
@ -973,12 +1038,12 @@ export function validateMathNodes(root: TinymathAST, missingVariableSet: Set<str
values: {
operation: node.name,
},
locations: node.location ? [node.location] : [],
locations: getNodeLocation(node),
})
);
}
// no need to iterate all the arguments, one field is anough to trigger the error
// no need to iterate all the arguments, one field is enough to trigger the error
const hasFieldAsArgument = positionalArguments.some((requirements, index) => {
const arg = node.args[index];
if (arg != null && typeof arg !== 'number') {
@ -992,12 +1057,11 @@ export function validateMathNodes(root: TinymathAST, missingVariableSet: Set<str
values: {
operation: node.name,
},
locations: node.location ? [node.location] : [],
locations: getNodeLocation(node),
})
);
}
const mandatoryArguments = positionalArguments.filter(({ optional }) => !optional);
// if there is only 1 mandatory arg, this is already handled by the wrongFirstArgument check
if (mandatoryArguments.length > 1 && node.args.length < mandatoryArguments.length) {
const missingArgs = mandatoryArguments.filter((_, i) => node.args[i] == null);
@ -1020,7 +1084,7 @@ export function validateMathNodes(root: TinymathAST, missingVariableSet: Set<str
count: mandatoryArguments.length - node.args.length,
params: missingArgsWithoutAlternative.map(({ name }) => name).join(', '),
},
locations: node.location ? [node.location] : [],
locations: getNodeLocation(node),
})
);
}
@ -1035,11 +1099,37 @@ export function validateMathNodes(root: TinymathAST, missingVariableSet: Set<str
params: firstArg.name,
alternativeFn: firstArg.alternativeWhenMissing,
},
locations: node.location ? [node.location] : [],
locations: getNodeLocation(node),
})
);
}
}
const wrongTypeArgumentIndexes = positionalArguments
.map(({ type }, index) => {
const arg = node.args[index];
if (arg != null) {
const argType = getArgumentType(arg, operations);
if (argType && argType !== type) {
return index;
}
}
})
.filter(nonNullable);
for (const wrongTypeArgumentIndex of wrongTypeArgumentIndexes) {
const arg = node.args[wrongTypeArgumentIndex];
errors.push(
getMessageFromId({
messageId: 'wrongTypeArgument',
values: {
operation: node.name,
name: positionalArguments[wrongTypeArgumentIndex].name,
type: getArgumentType(arg, operations) || 'number',
expectedType: positionalArguments[wrongTypeArgumentIndex].type || '',
},
locations: getNodeLocation(node),
})
);
}
});
return errors;
}
@ -1073,7 +1163,7 @@ function validateFieldArguments(
supported: 1,
text: (fields as TinymathVariable[]).map(({ text }) => text).join(', '),
},
locations: node.location ? [node.location] : [],
locations: getNodeLocation(node),
})
);
}
@ -1085,7 +1175,7 @@ function validateFieldArguments(
values: {
text: node.text ?? `${node.name}(${getValueOrName(firstArg)})`,
},
locations: node.location ? [node.location] : [],
locations: getNodeLocation(node),
})
);
}
@ -1101,7 +1191,7 @@ function validateFieldArguments(
defaultMessage: 'field',
}),
},
locations: node.location ? [node.location] : [],
locations: getNodeLocation(node),
})
);
}
@ -1133,7 +1223,7 @@ function validateFunctionArguments(
defaultMessage: 'metric',
}),
},
locations: node.location ? [node.location] : [],
locations: getNodeLocation(node),
})
);
} else {
@ -1146,7 +1236,7 @@ function validateFunctionArguments(
supported: requiredFunctions,
text: (esOperations as TinymathFunction[]).map(({ text }) => text).join(', '),
},
locations: node.location ? [node.location] : [],
locations: getNodeLocation(node),
})
);
}
@ -1164,7 +1254,7 @@ function validateFunctionArguments(
type,
text: (mathOperations as TinymathFunction[]).map(({ text }) => text).join(', '),
},
locations: node.location ? [node.location] : [],
locations: getNodeLocation(node),
})
);
}