JEXLFormula.java
/*
* Copyright (C) 2021 INRAE AgroClim
*
* This file is part of Indicators.
*
* Indicators is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Indicators is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Indicators. If not, see <http://www.gnu.org/licenses/>.
*/
package fr.inrae.agroclim.indicators.model;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
import org.apache.commons.jexl3.JexlBuilder;
import org.apache.commons.jexl3.JexlContext;
import org.apache.commons.jexl3.JexlEngine;
import org.apache.commons.jexl3.JexlException;
import org.apache.commons.jexl3.JexlExpression;
import org.apache.commons.jexl3.JexlFeatures;
import org.apache.commons.jexl3.JexlScript;
import org.apache.commons.jexl3.MapContext;
import org.apache.commons.jexl3.introspection.JexlPermissions;
import fr.inrae.agroclim.indicators.exception.IndicatorsException;
import fr.inrae.agroclim.indicators.exception.type.ComputationErrorType;
import fr.inrae.agroclim.indicators.model.criteria.FormulaCriteria;
import fr.inrae.agroclim.indicators.model.data.Variable;
import fr.inrae.agroclim.indicators.model.function.aggregation.MathMethod;
import fr.inrae.agroclim.indicators.util.StringUtils;
import lombok.Getter;
import lombok.Setter;
/**
* Execution of JEXL formula to make computation.
*
* Last changed : $Date$
*
* @author $Author$
* @version $Revision$
*/
public class JEXLFormula {
/**
* org.apache.commons.jexl3.JexlEngine .
*/
private final JexlEngine jexl;
/**
* JEXL expression.
*
* See https://commons.apache.org/proper/commons-jexl/reference/syntax.html.
*/
@Getter
@Setter
private String expression;
/**
* Initialize JEXL engine.
*/
public JEXLFormula() {
// Restricting features; no loops, no side effects
JexlFeatures features = new JexlFeatures()
.loops(false)
// assigning/modifying values on global variables (=, += , -=, ...)
.sideEffectGlobal(false)
// assigning/modifying values on any variables or left-value
.sideEffect(false);
// The cache will contain at most size expressions.
final int size = 512;
jexl = new JexlBuilder()
.cache(size)
.features(features)
.silent(false)
.strict(true)
// setting custom functions
.namespaces(Map.of(
"formulaCriteria", FormulaCriteria.class, //
"math", MathMethod.class))
.permissions(JexlPermissions.UNRESTRICTED)
.create();
}
/**
* Evaluates the expression with the variables.
*
* @param <T> result class
* @param values variable name ⮕ value
* @param clazz result class
* @return The result of this evaluation
* @throws IndicatorsException on any error
*/
@SuppressWarnings("unchecked")
public <T> T evaluate(final Map<String, Double> values, final Class<T> clazz) throws IndicatorsException {
try {
if (getExpression() == null) {
throw new IndicatorsException(ComputationErrorType.FORMULA_EXPRESSION_NULL);
} else if (getExpression().isBlank()) {
throw new IndicatorsException(ComputationErrorType.FORMULA_EXPRESSION_BLANK);
}
final JexlExpression exp = jexl.createExpression(getExpression());
final JexlContext context = new MapContext();
// fill the context
values.keySet().forEach(key -> {
String text = key;
if (StringUtils.isNumeric(key.substring(0, 1))) {
text = "$" + text;
}
context.set(text, values.get(key));
});
return (T) exp.evaluate(context);
} catch (final JexlException.Variable ex) {
throw new IndicatorsException(ComputationErrorType.FORMULA_VARIABLE_UNDEFINED, ex, getExpression(),
ex.getVariable());
} catch (final JexlException.Method ex) {
throw new IndicatorsException(ComputationErrorType.FORMULA_FUNCTION_UNKNOWN, ex, getExpression(),
ex.getMethod());
} catch (final JexlException.Parsing ex) {
throw new IndicatorsException(ComputationErrorType.FORMULA_EXPRESSION_PARSING, ex, getExpression());
} catch (final JexlException.Tokenization ex) {
throw new IndicatorsException(ComputationErrorType.FORMULA_EXPRESSION_PARENTHESIS, ex, getExpression());
} catch (final JexlException ex) {
throw new IndicatorsException(ComputationErrorType.FORMULA, ex, getExpression());
}
}
/**
* @return variable in the expression
*/
public Set<Variable> getDataVariables() {
final Set<Variable> variables = new HashSet<>();
final Set<String> expressionVariables = getExpressionVariables();
if (expressionVariables == null) {
return variables;
}
expressionVariables.forEach(variable -> {
for (final Variable enumVariable : Variable.values()) {
if (enumVariable.getName().equalsIgnoreCase(variable)) {
variables.add(enumVariable);
}
}
});
return variables;
}
/**
* @return variable names in the expression
*/
public Set<String> getExpressionVariables() {
Objects.requireNonNull(expression, "JEXLFormula.expression must not be null to get variables");
final JexlScript script = jexl.createScript(expression);
if (script == null) {
throw new IllegalStateException("Strange, failed to create JexlScript !");
}
final Set<List<String>> variables = script.getVariables();
if (variables != null) {
return variables.stream().flatMap(List::stream).collect(Collectors.toSet());
}
return new HashSet<>();
}
/**
* @return if expression is set
*/
public boolean isValid() {
return expression != null && !expression.isEmpty();
}
}