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();
    }
}