NIFI-2791 Adding 'math' expression language function

This closes #1157.
This commit is contained in:
jpercivall 2016-10-20 10:58:53 -04:00 committed by Pierre Villard
parent 368ea7a2d2
commit 1d74b5d3ce
7 changed files with 273 additions and 6 deletions

View File

@ -166,6 +166,7 @@ PLUS : 'plus';
MINUS : 'minus';
MULTIPLY : 'multiply';
DIVIDE : 'divide';
MATH : 'math';
TO_RADIX : 'toRadix';
OR : 'or';
AND : 'and';

View File

@ -95,10 +95,11 @@ zeroArgNum : (LENGTH | TO_NUMBER | TO_DECIMAL | COUNT) LPAREN! RPAREN!;
oneArgNum : ((INDEX_OF | LAST_INDEX_OF) LPAREN! anyArg RPAREN!) |
(TO_DATE LPAREN! anyArg? RPAREN!) |
((MOD | PLUS | MINUS | MULTIPLY | DIVIDE) LPAREN! anyArg RPAREN!);
oneOrTwoArgNum : MATH LPAREN! anyArg (COMMA! anyArg)? RPAREN!;
stringFunctionRef : zeroArgString | oneArgString | twoArgString | fiveArgString;
booleanFunctionRef : zeroArgBool | oneArgBool | multiArgBool;
numberFunctionRef : zeroArgNum | oneArgNum;
numberFunctionRef : zeroArgNum | oneArgNum | oneOrTwoArgNum;
anyArg : WHOLE_NUMBER | DECIMAL | numberFunctionRef | STRING_LITERAL | zeroArgString | oneArgString | twoArgString | fiveArgString | booleanLiteral | zeroArgBool | oneArgBool | multiArgBool | expression;
stringArg : STRING_LITERAL | zeroArgString | oneArgString | twoArgString | expression;
@ -128,7 +129,7 @@ functionCall : functionRef ->
booleanLiteral : TRUE | FALSE;
zeroArgStandaloneFunction : (IP | UUID | NOW | NEXT_INT | HOSTNAME | RANDOM) LPAREN! RPAREN!;
oneArgStandaloneFunction : (TO_LITERAL^ LPAREN! anyArg RPAREN!) |
oneArgStandaloneFunction : ((TO_LITERAL | MATH)^ LPAREN! anyArg RPAREN!) |
(HOSTNAME^ LPAREN! booleanLiteral RPAREN!);
standaloneFunction : zeroArgStandaloneFunction | oneArgStandaloneFunction;

View File

@ -62,6 +62,7 @@ import org.apache.nifi.attribute.expression.language.evaluation.functions.Length
import org.apache.nifi.attribute.expression.language.evaluation.functions.LessThanEvaluator;
import org.apache.nifi.attribute.expression.language.evaluation.functions.LessThanOrEqualEvaluator;
import org.apache.nifi.attribute.expression.language.evaluation.functions.MatchesEvaluator;
import org.apache.nifi.attribute.expression.language.evaluation.functions.MathEvaluator;
import org.apache.nifi.attribute.expression.language.evaluation.functions.MinusEvaluator;
import org.apache.nifi.attribute.expression.language.evaluation.functions.ModEvaluator;
import org.apache.nifi.attribute.expression.language.evaluation.functions.MultiplyEvaluator;
@ -122,6 +123,7 @@ import org.antlr.runtime.CharStream;
import org.antlr.runtime.CommonTokenStream;
import org.antlr.runtime.tree.Tree;
import static org.apache.nifi.attribute.expression.language.antlr.AttributeExpressionParser.MATH;
import static org.apache.nifi.attribute.expression.language.antlr.AttributeExpressionParser.ALL_ATTRIBUTES;
import static org.apache.nifi.attribute.expression.language.antlr.AttributeExpressionParser.ALL_DELINEATED_VALUES;
import static org.apache.nifi.attribute.expression.language.antlr.AttributeExpressionParser.ALL_MATCHING_ATTRIBUTES;
@ -736,6 +738,13 @@ public class Query {
case RANDOM: {
return new RandomNumberGeneratorEvaluator();
}
case MATH: {
if (tree.getChildCount() == 1) {
return addToken(new MathEvaluator(null, toStringEvaluator(buildEvaluator(tree.getChild(0))), null), "math");
} else {
throw new AttributeExpressionLanguageParsingException("Call to math() as the subject must take exactly 1 parameter");
}
}
default:
throw new AttributeExpressionLanguageParsingException("Unexpected token: " + tree.toString());
}
@ -1247,6 +1256,15 @@ public class Query {
case DIVIDE: {
return addToken(new DivideEvaluator(toNumberEvaluator(subjectEvaluator), toNumberEvaluator(argEvaluators.get(0))), "divide");
}
case MATH: {
if (argEvaluators.size() == 1) {
return addToken(new MathEvaluator(toNumberEvaluator(subjectEvaluator), toStringEvaluator(argEvaluators.get(0)), null), "math");
} else if (argEvaluators.size() == 2){
return addToken(new MathEvaluator(toNumberEvaluator(subjectEvaluator), toStringEvaluator(argEvaluators.get(0)), toNumberEvaluator(argEvaluators.get(1))), "math");
} else {
throw new AttributeExpressionLanguageParsingException("math() function takes 1 or 2 arguments");
}
}
case RANDOM : {
return addToken(new RandomNumberGeneratorEvaluator(), "random");
}

View File

@ -0,0 +1,161 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.nifi.attribute.expression.language.evaluation.functions;
import org.apache.nifi.attribute.expression.language.evaluation.Evaluator;
import org.apache.nifi.attribute.expression.language.evaluation.NumberEvaluator;
import org.apache.nifi.attribute.expression.language.evaluation.NumberQueryResult;
import org.apache.nifi.attribute.expression.language.evaluation.QueryResult;
import org.apache.nifi.attribute.expression.language.exception.AttributeExpressionLanguageException;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.Map;
public class MathEvaluator extends NumberEvaluator {
private final Evaluator<Number> subject;
private final Evaluator<String> methodName;
private final Evaluator<Number> optionalArg;
public MathEvaluator(final Evaluator<Number> subject, final Evaluator<String> methodName, final Evaluator<Number> optionalArg) {
this.subject = subject;
this.methodName = methodName;
this.optionalArg = optionalArg;
}
@Override
public QueryResult<Number> evaluate(final Map<String, String> attributes) {
final String methodNamedValue = methodName.evaluate(attributes).getValue();
if (methodNamedValue == null) {
return new NumberQueryResult(null);
}
final Number subjectValue;
if(subject != null) {
subjectValue = subject.evaluate(attributes).getValue();
if(subjectValue == null){
return new NumberQueryResult(null);
}
} else {
subjectValue = null;
}
final Number optionalArgValue;
if(optionalArg != null) {
optionalArgValue = optionalArg.evaluate(attributes).getValue();
if(optionalArgValue == null) {
return new NumberQueryResult(null);
}
} else {
optionalArgValue = null;
}
try {
Number executionValue = null;
if (subjectValue == null){
Method method;
try {
method = Math.class.getMethod(methodNamedValue);
} catch (NoSuchMethodException subjectlessNoMethodException) {
throw new AttributeExpressionLanguageException("Cannot evaluate 'math' function because no subjectless method was found with the name:'" +
methodNamedValue + "'", subjectlessNoMethodException);
}
if(method == null) {
throw new AttributeExpressionLanguageException("Cannot evaluate 'math' function because no subjectless method was found with the name:'" + methodNamedValue + "'");
}
executionValue = (Number) method.invoke(null);
} else if(optionalArg == null) {
boolean subjectIsDecimal = subjectValue instanceof Double;
Method method;
try {
method = Math.class.getMethod(methodNamedValue, subjectIsDecimal ? double.class : long.class);
} catch (NoSuchMethodException noOptionalNoMethodException){
throw new AttributeExpressionLanguageException("Cannot evaluate 'math' function because no method was found matching the passed parameters:" +
" name:'" + methodNamedValue + "', one argument of type: '" + (subjectIsDecimal ? "double" : "long")+"'", noOptionalNoMethodException);
}
if(method == null) {
throw new AttributeExpressionLanguageException("Cannot evaluate 'math' function because no method was found matching the passed parameters:" +
" name:'" + methodNamedValue + "', one argument of type: '" + (subjectIsDecimal ? "double" : "long")+"'");
}
if (subjectIsDecimal){
executionValue = (Number) method.invoke(null, subjectValue.doubleValue());
} else {
executionValue = (Number) method.invoke(null, subjectValue.longValue());
}
} else {
boolean subjectIsDecimal = subjectValue instanceof Double;
boolean optionalArgIsDecimal = optionalArgValue instanceof Double;
Method method;
boolean convertOptionalToInt = false;
try {
method = Math.class.getMethod(methodNamedValue, subjectIsDecimal ? double.class : long.class, optionalArgIsDecimal ? double.class : long.class);
} catch (NoSuchMethodException withOptionalNoMethodException) {
if (!optionalArgIsDecimal) {
try {
method = Math.class.getMethod(methodNamedValue, subjectIsDecimal ? double.class : long.class, int.class);
} catch (NoSuchMethodException withOptionalInnerNoMethodException) {
throw new AttributeExpressionLanguageException("Cannot evaluate 'math' function because no method was found matching the passed parameters: " + "name:'" +
methodNamedValue + "', first argument type: '" + (subjectIsDecimal ? "double" : "long") + "', second argument type: 'long'", withOptionalInnerNoMethodException);
}
convertOptionalToInt = true;
} else {
throw new AttributeExpressionLanguageException("Cannot evaluate 'math' function because no method was found matching the passed parameters: " + "name:'" +
methodNamedValue + "', first argument type: '" + (subjectIsDecimal ? "double" : "long") + "', second argument type: 'double'", withOptionalNoMethodException);
}
}
if(method == null) {
throw new AttributeExpressionLanguageException("Cannot evaluate 'math' function because no method was found matching the passed parameters: " +
"name:'" + methodNamedValue + "', first argument type: '" + (subjectIsDecimal ? "double" : "long") + "', second argument type: '"
+ (optionalArgIsDecimal ? "double" : "long") + "'");
}
if (optionalArgIsDecimal) {
executionValue = (Number) method.invoke(null, subjectValue, optionalArgValue.doubleValue());
} else {
if (convertOptionalToInt) {
executionValue = (Number) method.invoke(null, subjectValue, optionalArgValue.intValue());
} else {
executionValue = (Number) method.invoke(null, subjectValue, optionalArgValue.longValue());
}
}
}
return new NumberQueryResult(executionValue);
} catch (IllegalAccessException | InvocationTargetException e) {
throw new AttributeExpressionLanguageException("Unable to calculate math function value", e);
}
}
@Override
public Evaluator<?> getSubjectEvaluator() {
return subject;
}
}

View File

@ -21,6 +21,7 @@ import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.hamcrest.Matchers.greaterThan;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.junit.Assert.fail;
import java.io.BufferedInputStream;
import java.io.IOException;
@ -813,6 +814,60 @@ public class TestQuery {
verifyEquals("${ten:divide(${two:plus(3)}):toDate():format(\"SSS\")}", attributes, "001");
}
@Test
public void testMathFunction() {
final Map<String, String> attributes = new HashMap<>();
attributes.put("one", "1");
attributes.put("two", "2");
attributes.put("oneDecimal", "1.5");
attributes.put("twoDecimal", "2.3");
attributes.put("negative", "-64");
attributes.put("negativeDecimal", "-64.1");
// Test that errors relating to not finding methods are properly handled
try {
verifyEquals("${math('rand'):toNumber()}", attributes, 0L);
fail();
} catch (AttributeExpressionLanguageException expected) {
assertEquals("Cannot evaluate 'math' function because no subjectless method was found with the name:'rand'", expected.getMessage());
}
try {
verifyEquals("${negativeDecimal:math('absolute')}", attributes, 0L);
fail();
} catch (AttributeExpressionLanguageException expected) {
assertEquals("Cannot evaluate 'math' function because no method was found matching the passed parameters: name:'absolute', one argument of type: 'double'", expected.getMessage());
}
try {
verifyEquals("${oneDecimal:math('power', ${two:toDecimal()})}", attributes, 0L);
fail();
} catch (AttributeExpressionLanguageException expected) {
assertEquals("Cannot evaluate 'math' function because no method was found matching the passed parameters: name:'power', " +
"first argument type: 'double', second argument type: 'double'", expected.getMessage());
}
try {
verifyEquals("${oneDecimal:math('power', ${two})}", attributes, 0L);
fail();
} catch (AttributeExpressionLanguageException expected) {
assertEquals("Cannot evaluate 'math' function because no method was found matching the passed parameters: name:'power', " +
"first argument type: 'double', second argument type: 'long'", expected.getMessage());
}
// Can only verify that it runs. ToNumber() will verify that it produced a number greater than or equal to 0.0 and less than 1.0
verifyEquals("${math('random'):toNumber()}", attributes, 0L);
verifyEquals("${negative:math('abs')}", attributes, 64L);
verifyEquals("${negativeDecimal:math('abs')}", attributes, 64.1D);
verifyEquals("${negative:math('max', ${two})}", attributes, 2L);
verifyEquals("${negativeDecimal:math('max', ${twoDecimal})}", attributes, 2.3D);
verifyEquals("${oneDecimal:math('pow', ${two:toDecimal()})}", attributes, Math.pow(1.5,2));
verifyEquals("${oneDecimal:math('scalb', ${two})}", attributes, Math.scalb(1.5,2));
verifyEquals("${negative:math('abs'):toDecimal():math('cbrt'):math('max', ${two:toDecimal():math('pow',${oneDecimal}):mod(${two})})}", attributes,
Math.max(Math.cbrt(Math.abs(-64)), Math.pow(2,1.5)%2));
}
@Test
public void testMathLiteralOperations() {
final Map<String, String> attributes = new HashMap<>();

View File

@ -1200,9 +1200,9 @@ Each of the following functions will encode a string according the rules of the
*Return Type*: [.returnType]#String#
*Examples*: We can Base64-Encoded an attribute named "payload" by using the Expression
*Examples*: We can Base64-Encoded an attribute named "payload" by using the Expression
`${payload:base64Encode()}` If the attribute payload had a value of "admin:admin"
then the Expression `${payload:base64Encode()}` will return "YWRtaW46YWRtaW4=".
then the Expression `${payload:base64Encode()}` will return "YWRtaW46YWRtaW4=".
@ -1657,6 +1657,33 @@ Divide. This is to preserve backwards compatibility and to not force rounding er
*Examples*: ${random():mod(10):plus(1)} returns random number between 1 and 10 inclusive.
[.function]
=== math
*Description*: [.description]#ADVANCED FEATURE. This expression is designed to be used by advanced users only. It utilizes Java Reflection to run arbitrary java.lang.Math static methods. The exact API will depend on the version of Java you are running. The Java 8 API can be found here: https://docs.oracle.com/javase/8/docs/api/java/lang/Math.html
+
In order to run the correct method, the parameter types must be correct. The Expression Language "Number" (whole number) type is interpreted as a Java "long". The "Decimal" type is interpreted as a Java "double". Running the desired method may require calling "toNumber()" or "toDecimal()" in order to "cast" the value to the desired type. This also is important to remember when cascading "math()" calls since the return type depends on the method that was run.#
*Subject Type*: [.subject .subjectless]#Subjectless, Number or Decimal (depending on the desired method to run)#
*Arguments*:
- [.argName]#_Method_# : [.argDesc]#The name of the Java Math method to run#
- [.argName]#_Optional Argument_# : [.argDesc]#Optional argument that acts as the second parameter to the method.#
*Return Type*: [.returnType]#Number or Decimal (depending on method run)#
*Examples*:
- ${math("random")} runs Math.random().
- ${literal(2):toDecimal:math("pow", 2.5)} runs Math.pow(2D,2.5D).
- ${literal(64):toDouble():math("cbrt"):toNumber():math("max", 5)} runs Math.max((Double.valueOf(Math.cbrt(64D))).longValue(), 5L). Note that the toDecimal() is needed because "cbrt" takes a "double" as input and the "64" will get interpreted as a long. The "toDecimal()" call is necessary to correctly call the method. that the "toNumber()" call is necessary because "cbrt" returns a double and the "max" method is must have parameters of the same type and "5" is interpreted as a long.
- ${literal(5.4):math("scalb", 2)} runs Math.scalb(5.4, 2). This example is important because NiFi EL treats all whole numbers as "longs" and there is no concept of an "int". "scalb" takes a second parameter of an "int" and it is not overloaded to accept longs so it could not be run without special type handling. In the instance where the Java method cannot be found using parameters of type "double" and "long" the "math()" EL function will attempt to find a Java method with the same name but parameters of "double" and "int".
- ${first:toDecimal():math("pow", ${second:toDecimal()})} where attributes evaluate to "first" = 2.5 and "second" = 2. This example runs Math.pow(2.5D, 2D). The explicit calls to toDecimal() are important because of the dynamic nature of EL. When creating the flow, the user is unaware if the expression language values will be able to be interpreted as a whole number or not. In this example without the explicit calls "toDecimal" the "math" function would attempt to run a Java method "pow" with types "double" and "long" (which doesn't exist).
[[dates]]
== Date Manipulation

View File

@ -64,13 +64,17 @@ nf.nfel = (function() {
var returnType = elFunction.find('span.returnType').text();
var subject;
var subjectSpan = subject = elFunction.find('span.subject');
var subjectless = elFunction.find('span.subjectless');
// determine if this function is subjectless
// Determine if this function supports running subjectless
if (subjectless.length) {
subjectlessFunctions.push(name);
subject = '<span class="unset">None</span>';
} else {
}
// Determine if this function supports running with a subject
if (subjectSpan.length) {
functions.push(name);
subject = elFunction.find('span.subject').text();
}