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] Formula: add validation for multiple field/metrics #104092

Merged
merged 14 commits into from
Jul 13, 2021
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -887,6 +887,58 @@ invalid: "
).toEqual(['The operation average does not accept any parameter']);
});

it('returns an error if first argument type is passed multiple times', () => {
const formulas = [
'average(bytes, bytes)',
'average(bytes, kql="category.keyword: *", bytes)',
'moving_average(average(bytes), average(bytes))',
'moving_average(average(bytes), kql="category.keyword: *", average(bytes))',
'moving_average(average(bytes, bytes), count())',
'moving_average(moving_average(average(bytes, bytes), count(), count()))',
];
for (const formula of formulas) {
expect(
formulaOperation.getErrorMessage!(
getNewLayerWithFormula(formula),
'col1',
indexPattern,
operationDefinitionMap
)
).toEqual(
expect.arrayContaining([
expect.stringMatching(
/The operation (moving_average|average) in the Formula requires a single (field|metric), found \d+/
),
])
);
}
});

it('returns an error if a metric is passed to a field-only operation', () => {
const formulas = [
'average(bytes, count())',
'average(bytes, kql="category.keyword: *", count())',
'average(bytes, kql="category.keyword: *", count(), count())',
'moving_average(average(bytes), bytes)',
];
for (const formula of formulas) {
expect(
formulaOperation.getErrorMessage!(
getNewLayerWithFormula(formula),
'col1',
indexPattern,
operationDefinitionMap
)
).toEqual(
expect.arrayContaining([
expect.stringMatching(
/The operation (moving_average|average) in the Formula does not support (metric|field) parameters, found:/
),
])
);
}
});

it('returns an error if the parameter passed to an operation is of the wrong type', () => {
expect(
formulaOperation.getErrorMessage!(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -431,7 +431,7 @@ ${'`square(last_value(length))`'}
},
};

export function isMathNode(node: TinymathAST) {
export function isMathNode(node: TinymathAST | string) {
return isObject(node) && node.type === 'function' && tinymathFunctions[node.name];
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

import { isObject, partition } from 'lodash';
import { i18n } from '@kbn/i18n';
import { parse, TinymathLocation } from '@kbn/tinymath';
import { parse, TinymathLocation, TinymathVariable } from '@kbn/tinymath';
import type { TinymathAST, TinymathFunction, TinymathNamedArgument } from '@kbn/tinymath';
import { esKuery, esQuery } from '../../../../../../../../src/plugins/data/public';
import {
Expand Down Expand Up @@ -63,6 +63,14 @@ interface ValidationErrors {
message: string;
type: {};
};
tooManyFirstArguments: {
message: string;
type: { operation: string; type: string; count: number; text: string };
};
wrongArgument: {
message: string;
type: { operation: string; text: string; type: string };
};
}

type ErrorTypes = keyof ValidationErrors;
Expand Down Expand Up @@ -276,6 +284,20 @@ function getMessageFromId<K extends ErrorTypes>({
defaultMessage: 'Use only one of kql= or lucene=, not both',
});
break;
case 'tooManyFirstArguments':
message = i18n.translate('xpack.lens.indexPattern.formulaOperationTooManyFirstArguments', {
defaultMessage:
'The operation {operation} in the Formula requires a single {type}, found {count}: {text}',
values: { operation: out.operation, text: out.text, count: out.count, type: out.type },
});
break;
case 'wrongArgument':
message = i18n.translate('xpack.lens.indexPattern.formulaOperationwrongArgument', {
defaultMessage:
'The operation {operation} in the Formula does not support {type} parameters, found: {text}',
values: { operation: out.operation, text: out.text, type: out.type },
});
break;
// case 'mathRequiresFunction':
// message = i18n.translate('xpack.lens.indexPattern.formulaMathRequiresFunctionLabel', {
// defaultMessage; 'The function {name} requires an Elasticsearch function',
Expand Down Expand Up @@ -531,7 +553,7 @@ function runFullASTValidation(
} else {
if (nodeOperation.input === 'field') {
if (shouldHaveFieldArgument(node)) {
if (!isFirstArgumentValidType(firstArg, 'variable')) {
if (!isArgumentValidType(firstArg, 'variable')) {
if (isMathNode(firstArg)) {
errors.push(
getMessageFromId({
Expand Down Expand Up @@ -561,6 +583,37 @@ function runFullASTValidation(
})
);
}
} else {
const fields = variables.filter(
(arg) => isArgumentValidType(arg, 'variable') && !isMathNode(arg)
);
if (fields.length > 1) {
errors.push(
getMessageFromId({
messageId: 'tooManyFirstArguments',
values: {
operation: node.name,
type: 'field',
text: (fields as TinymathVariable[]).map(({ text }) => text).join(', '),
count: fields.length,
},
locations: node.location ? [node.location] : [],
})
);
}
}
if (functions.length) {
errors.push(
getMessageFromId({
messageId: 'wrongArgument',
values: {
operation: node.name,
text: (functions as TinymathFunction[]).map(({ text }) => text).join(', '),
type: 'metric',
},
locations: node.location ? [node.location] : [],
})
);
}
} else {
// Named arguments only
Expand Down Expand Up @@ -603,7 +656,7 @@ function runFullASTValidation(
// What about fn(7 + 1)? We may want to allow that
// In general this should be handled down the Esaggs route rather than here
if (
!isFirstArgumentValidType(firstArg, 'function') ||
!isArgumentValidType(firstArg, 'function') ||
(isMathNode(firstArg) && validateMathNodes(firstArg, missingVariablesSet).length)
) {
errors.push(
Expand All @@ -621,6 +674,21 @@ function runFullASTValidation(
locations: node.location ? [node.location] : [],
})
);
} else {
if (functions.length > 1) {
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm not sure whether this is a future-proof way to check - the way our full reference operations are set up, they could theoretically accept multiple parameters. Should we check the requiredReferences here?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This code is generic enough to check functions in different position in the node, as moving_average( median( ...), median( ... ) ) as well as moving_average( median( ...), window = 7, shift="7d", median( ... ) ).

I could parametrize the threshold to be requiredReferences.filter( /* check for functions */).length here, but in general the formula code assumes only one argument to be a reference (to a field or operation) and it may be required to change in the future if we introduce operations with different assumptions.
The parametric change here is pretty easy and I'll push a fix on that.

errors.push(
getMessageFromId({
messageId: 'tooManyFirstArguments',
Copy link
Contributor

Choose a reason for hiding this comment

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

The message still says a single ..., but I think it's fine for now - as you mentioned the parsing logic can't handle this either at the moment.

values: {
operation: node.name,
type: 'metric',
text: (functions as TinymathFunction[]).map(({ text }) => text).join(', '),
count: functions.length,
},
locations: node.location ? [node.location] : [],
})
);
}
}
if (!canHaveParams(nodeOperation) && namedArguments.length) {
errors.push(
Expand All @@ -633,6 +701,22 @@ function runFullASTValidation(
})
);
} else {
const fields = variables.filter(
Copy link
Contributor

Choose a reason for hiding this comment

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

Which case is caught here?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Probably need to add some more comment context on all these branches.
In this specific branch it is checking that only a single field is passed to field-only operations.

Copy link
Contributor

Choose a reason for hiding this comment

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

I don't think so, this branch is in the if (nodeOperation.input === 'fullReference') block.

I don't see in which case it would become necessary - the only one is this, but it shows two error for the same problem:
Screenshot 2021-07-06 at 11 55 05

Copy link
Contributor Author

Choose a reason for hiding this comment

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

You're right sorry. The check has to exclude the first argument.
This check is here to validate this scenario:

moving_average(average(bytes), bytes)

I'll refactor the logic to group both first argument check and all arguents check for field values.

(arg) => isArgumentValidType(arg, 'variable') && !isMathNode(arg)
);
if (fields.length) {
errors.push(
getMessageFromId({
messageId: 'wrongArgument',
values: {
operation: node.name,
text: (fields as TinymathVariable[]).map(({ text }) => text).join(', '),
type: 'field',
},
locations: node.location ? [node.location] : [],
})
);
}
const argumentsErrors = validateNameArguments(
node,
nodeOperation,
Expand Down Expand Up @@ -736,7 +820,7 @@ export function hasFunctionFieldArgument(type: string) {
return !['count'].includes(type);
}

export function isFirstArgumentValidType(arg: TinymathAST, type: TinymathNodeTypes['type']) {
export function isArgumentValidType(arg: TinymathAST | string, type: TinymathNodeTypes['type']) {
return isObject(arg) && arg.type === type;
}

Expand Down