Skip to content

Commit 966d682

Browse files
[Lens] Formula: add validation for multiple field/metrics (#104092) (#105395)
Co-authored-by: Kibana Machine <[email protected]> Co-authored-by: Marco Liberati <[email protected]>
1 parent dc4c4c9 commit 966d682

File tree

3 files changed

+265
-12
lines changed

3 files changed

+265
-12
lines changed

x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/formula.test.tsx

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ const operationDefinitionMap: Record<string, GenericOperationDefinition> = {
3535
}),
3636
} as unknown) as GenericOperationDefinition,
3737
terms: { input: 'field' } as GenericOperationDefinition,
38-
sum: { input: 'field' } as GenericOperationDefinition,
38+
sum: { input: 'field', filterable: true } as GenericOperationDefinition,
3939
last_value: { input: 'field' } as GenericOperationDefinition,
4040
max: { input: 'field' } as GenericOperationDefinition,
4141
count: ({
@@ -928,6 +928,63 @@ invalid: "
928928
).toEqual(['The operation average does not accept any parameter']);
929929
});
930930

931+
it('returns an error if first argument type is passed multiple times', () => {
932+
const formulas = [
933+
'average(bytes, bytes)',
934+
"sum(bytes, kql='category.keyword: *', bytes)",
935+
'moving_average(average(bytes), average(bytes))',
936+
"moving_average(average(bytes), kql='category.keyword: *', average(bytes))",
937+
'moving_average(average(bytes, bytes), count())',
938+
'moving_average(moving_average(average(bytes, bytes), count(), count()))',
939+
];
940+
for (const formula of formulas) {
941+
expect(
942+
formulaOperation.getErrorMessage!(
943+
getNewLayerWithFormula(formula),
944+
'col1',
945+
indexPattern,
946+
operationDefinitionMap
947+
)
948+
).toEqual(
949+
expect.arrayContaining([
950+
expect.stringMatching(
951+
/The operation (moving_average|average|sum) in the Formula requires a single (field|metric), found:/
952+
),
953+
])
954+
);
955+
}
956+
});
957+
958+
it('returns an error if a function received an argument of the wrong argument type in any position', () => {
959+
const formulas = [
960+
'average(bytes, count())',
961+
"sum(bytes, kql='category.keyword: *', count(), count())",
962+
'average(bytes, bytes + 1)',
963+
'average(count(), bytes)',
964+
'moving_average(average(bytes), bytes)',
965+
'moving_average(bytes, bytes)',
966+
'moving_average(average(bytes), window=7, bytes)',
967+
'moving_average(window=7, bytes)',
968+
"moving_average(kql='category.keyword: *', bytes)",
969+
];
970+
for (const formula of formulas) {
971+
expect(
972+
formulaOperation.getErrorMessage!(
973+
getNewLayerWithFormula(formula),
974+
'col1',
975+
indexPattern,
976+
operationDefinitionMap
977+
)
978+
).toEqual(
979+
expect.arrayContaining([
980+
expect.stringMatching(
981+
/The operation (moving_average|average|sum) in the Formula does not support (metric|field) parameters, found:/
982+
),
983+
])
984+
);
985+
}
986+
});
987+
931988
it('returns an error if the parameter passed to an operation is of the wrong type', () => {
932989
expect(
933990
formulaOperation.getErrorMessage!(
@@ -1087,6 +1144,14 @@ invalid: "
10871144
)
10881145
).toEqual([`The first argument for ${fn} should be a field name. Found no field`]);
10891146
}
1147+
expect(
1148+
formulaOperation.getErrorMessage!(
1149+
getNewLayerWithFormula(`sum(kql='category.keyword: *')`),
1150+
'col1',
1151+
indexPattern,
1152+
operationDefinitionMap
1153+
)
1154+
).toEqual([`The first argument for sum should be a field name. Found category.keyword: *`]);
10901155
});
10911156

10921157
it("returns a clear error when there's a missing function for a fullReference operation", () => {

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -431,7 +431,7 @@ ${'`square(last_value(length))`'}
431431
},
432432
};
433433

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

x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/formula/validation.ts

Lines changed: 198 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
import { isObject, partition } from 'lodash';
99
import { i18n } from '@kbn/i18n';
10-
import { parse, TinymathLocation } from '@kbn/tinymath';
10+
import { parse, TinymathLocation, TinymathVariable } from '@kbn/tinymath';
1111
import type { TinymathAST, TinymathFunction, TinymathNamedArgument } from '@kbn/tinymath';
1212
import { esKuery, esQuery } from '../../../../../../../../src/plugins/data/public';
1313
import {
@@ -63,6 +63,19 @@ interface ValidationErrors {
6363
message: string;
6464
type: {};
6565
};
66+
tooManyFirstArguments: {
67+
message: string;
68+
type: {
69+
operation: string;
70+
type: string;
71+
text: string;
72+
supported?: number;
73+
};
74+
};
75+
wrongArgument: {
76+
message: string;
77+
type: { operation: string; text: string; type: string };
78+
};
6679
}
6780

6881
type ErrorTypes = keyof ValidationErrors;
@@ -276,6 +289,25 @@ function getMessageFromId<K extends ErrorTypes>({
276289
defaultMessage: 'Use only one of kql= or lucene=, not both',
277290
});
278291
break;
292+
case 'tooManyFirstArguments':
293+
message = i18n.translate('xpack.lens.indexPattern.formulaOperationTooManyFirstArguments', {
294+
defaultMessage:
295+
'The operation {operation} in the Formula requires a {supported, plural, one {single} other {supported}} {type}, found: {text}',
296+
values: {
297+
operation: out.operation,
298+
text: out.text,
299+
type: out.type,
300+
supported: out.supported || 1,
301+
},
302+
});
303+
break;
304+
case 'wrongArgument':
305+
message = i18n.translate('xpack.lens.indexPattern.formulaOperationwrongArgument', {
306+
defaultMessage:
307+
'The operation {operation} in the Formula does not support {type} parameters, found: {text}',
308+
values: { operation: out.operation, text: out.text, type: out.type },
309+
});
310+
break;
279311
// case 'mathRequiresFunction':
280312
// message = i18n.translate('xpack.lens.indexPattern.formulaMathRequiresFunctionLabel', {
281313
// defaultMessage; 'The function {name} requires an Elasticsearch function',
@@ -531,14 +563,16 @@ function runFullASTValidation(
531563
} else {
532564
if (nodeOperation.input === 'field') {
533565
if (shouldHaveFieldArgument(node)) {
534-
if (!isFirstArgumentValidType(firstArg, 'variable')) {
566+
if (!isArgumentValidType(firstArg, 'variable')) {
535567
if (isMathNode(firstArg)) {
536568
errors.push(
537569
getMessageFromId({
538570
messageId: 'wrongFirstArgument',
539571
values: {
540572
operation: node.name,
541-
type: 'field',
573+
type: i18n.translate('xpack.lens.indexPattern.formulaFieldValue', {
574+
defaultMessage: 'field',
575+
}),
542576
argument: `math operation`,
543577
},
544578
locations: node.location ? [node.location] : [],
@@ -550,7 +584,9 @@ function runFullASTValidation(
550584
messageId: 'wrongFirstArgument',
551585
values: {
552586
operation: node.name,
553-
type: 'field',
587+
type: i18n.translate('xpack.lens.indexPattern.formulaFieldValue', {
588+
defaultMessage: 'field',
589+
}),
554590
argument:
555591
getValueOrName(firstArg) ||
556592
i18n.translate('xpack.lens.indexPattern.formulaNoFieldForOperation', {
@@ -561,6 +597,25 @@ function runFullASTValidation(
561597
})
562598
);
563599
}
600+
} else {
601+
// If the first argument is valid proceed with the other arguments validation
602+
const fieldErrors = validateFieldArguments(node, variables, {
603+
isFieldOperation: true,
604+
firstArg,
605+
});
606+
if (fieldErrors.length) {
607+
errors.push(...fieldErrors);
608+
}
609+
}
610+
const functionErrors = validateFunctionArguments(node, functions, 0, {
611+
isFieldOperation: true,
612+
type: i18n.translate('xpack.lens.indexPattern.formulaFieldValue', {
613+
defaultMessage: 'field',
614+
}),
615+
firstArgValidation: false,
616+
});
617+
if (functionErrors.length) {
618+
errors.push(...functionErrors);
564619
}
565620
} else {
566621
// Named arguments only
@@ -602,16 +657,20 @@ function runFullASTValidation(
602657
if (nodeOperation.input === 'fullReference') {
603658
// What about fn(7 + 1)? We may want to allow that
604659
// In general this should be handled down the Esaggs route rather than here
605-
if (
606-
!isFirstArgumentValidType(firstArg, 'function') ||
607-
(isMathNode(firstArg) && validateMathNodes(firstArg, missingVariablesSet).length)
608-
) {
660+
const isFirstArgumentNotValid = Boolean(
661+
!isArgumentValidType(firstArg, 'function') ||
662+
(isMathNode(firstArg) && validateMathNodes(firstArg, missingVariablesSet).length)
663+
);
664+
// First field has a special handling
665+
if (isFirstArgumentNotValid) {
609666
errors.push(
610667
getMessageFromId({
611668
messageId: 'wrongFirstArgument',
612669
values: {
613670
operation: node.name,
614-
type: 'operation',
671+
type: i18n.translate('xpack.lens.indexPattern.formulaOperationValue', {
672+
defaultMessage: 'operation',
673+
}),
615674
argument:
616675
getValueOrName(firstArg) ||
617676
i18n.translate('xpack.lens.indexPattern.formulaNoOperation', {
@@ -622,6 +681,21 @@ function runFullASTValidation(
622681
})
623682
);
624683
}
684+
// Check for multiple function passed
685+
const requiredFunctions = nodeOperation.requiredReferences
686+
? nodeOperation.requiredReferences.length
687+
: 1;
688+
const functionErrors = validateFunctionArguments(node, functions, requiredFunctions, {
689+
isFieldOperation: false,
690+
firstArgValidation: isFirstArgumentNotValid,
691+
type: i18n.translate('xpack.lens.indexPattern.formulaMetricValue', {
692+
defaultMessage: 'metric',
693+
}),
694+
});
695+
if (functionErrors.length) {
696+
errors.push(...functionErrors);
697+
}
698+
625699
if (!canHaveParams(nodeOperation) && namedArguments.length) {
626700
errors.push(
627701
getMessageFromId({
@@ -633,6 +707,14 @@ function runFullASTValidation(
633707
})
634708
);
635709
} else {
710+
// check for fields passed at any position
711+
const fieldErrors = validateFieldArguments(node, variables, {
712+
isFieldOperation: false,
713+
firstArg,
714+
});
715+
if (fieldErrors.length) {
716+
errors.push(...fieldErrors);
717+
}
636718
const argumentsErrors = validateNameArguments(
637719
node,
638720
nodeOperation,
@@ -736,7 +818,7 @@ export function hasFunctionFieldArgument(type: string) {
736818
return !['count'].includes(type);
737819
}
738820

739-
export function isFirstArgumentValidType(arg: TinymathAST, type: TinymathNodeTypes['type']) {
821+
export function isArgumentValidType(arg: TinymathAST | string, type: TinymathNodeTypes['type']) {
740822
return isObject(arg) && arg.type === type;
741823
}
742824

@@ -812,3 +894,109 @@ export function validateMathNodes(root: TinymathAST, missingVariableSet: Set<str
812894
});
813895
return errors;
814896
}
897+
898+
function validateFieldArguments(
899+
node: TinymathFunction,
900+
variables: Array<string | number | TinymathVariable>,
901+
{ isFieldOperation, firstArg }: { isFieldOperation: boolean; firstArg: TinymathAST }
902+
) {
903+
const fields = variables.filter(
904+
(arg) => isArgumentValidType(arg, 'variable') && !isMathNode(arg)
905+
);
906+
const errors = [];
907+
if (isFieldOperation && (fields.length > 1 || (fields.length === 1 && fields[0] !== firstArg))) {
908+
errors.push(
909+
getMessageFromId({
910+
messageId: 'tooManyFirstArguments',
911+
values: {
912+
operation: node.name,
913+
type: i18n.translate('xpack.lens.indexPattern.formulaFieldValue', {
914+
defaultMessage: 'field',
915+
}),
916+
supported: 1,
917+
text: (fields as TinymathVariable[]).map(({ text }) => text).join(', '),
918+
},
919+
locations: node.location ? [node.location] : [],
920+
})
921+
);
922+
}
923+
if (!isFieldOperation && fields.length) {
924+
errors.push(
925+
getMessageFromId({
926+
messageId: 'wrongArgument',
927+
values: {
928+
operation: node.name,
929+
text: (fields as TinymathVariable[]).map(({ text }) => text).join(', '),
930+
type: i18n.translate('xpack.lens.indexPattern.formulaFieldValue', {
931+
defaultMessage: 'field',
932+
}),
933+
},
934+
locations: node.location ? [node.location] : [],
935+
})
936+
);
937+
}
938+
return errors;
939+
}
940+
941+
function validateFunctionArguments(
942+
node: TinymathFunction,
943+
functions: TinymathFunction[],
944+
requiredFunctions: number = 0,
945+
{
946+
isFieldOperation,
947+
firstArgValidation,
948+
type,
949+
}: { isFieldOperation: boolean; firstArgValidation: boolean; type: string }
950+
) {
951+
const errors = [];
952+
// For math operation let the native operation run its own validation
953+
const [esOperations, mathOperations] = partition(functions, (arg) => !isMathNode(arg));
954+
if (esOperations.length > requiredFunctions) {
955+
if (isFieldOperation) {
956+
errors.push(
957+
getMessageFromId({
958+
messageId: 'wrongArgument',
959+
values: {
960+
operation: node.name,
961+
text: (esOperations as TinymathFunction[]).map(({ text }) => text).join(', '),
962+
type: i18n.translate('xpack.lens.indexPattern.formulaMetricValue', {
963+
defaultMessage: 'metric',
964+
}),
965+
},
966+
locations: node.location ? [node.location] : [],
967+
})
968+
);
969+
} else {
970+
errors.push(
971+
getMessageFromId({
972+
messageId: 'tooManyFirstArguments',
973+
values: {
974+
operation: node.name,
975+
type,
976+
supported: requiredFunctions,
977+
text: (esOperations as TinymathFunction[]).map(({ text }) => text).join(', '),
978+
},
979+
locations: node.location ? [node.location] : [],
980+
})
981+
);
982+
}
983+
}
984+
// full reference operation have another way to handle math operations
985+
if (
986+
isFieldOperation &&
987+
((!firstArgValidation && mathOperations.length) || mathOperations.length > 1)
988+
) {
989+
errors.push(
990+
getMessageFromId({
991+
messageId: 'wrongArgument',
992+
values: {
993+
operation: node.name,
994+
type,
995+
text: (mathOperations as TinymathFunction[]).map(({ text }) => text).join(', '),
996+
},
997+
locations: node.location ? [node.location] : [],
998+
})
999+
);
1000+
}
1001+
return errors;
1002+
}

0 commit comments

Comments
 (0)