001/**
002 * Copyright 2005-2018 The Kuali Foundation
003 *
004 * Licensed under the Educational Community License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 * http://www.opensource.org/licenses/ecl2.php
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 */
016package org.kuali.rice.krad.uif.util;
017
018import org.apache.commons.lang.StringUtils;
019import org.apache.commons.logging.Log;
020import org.apache.commons.logging.LogFactory;
021import org.kuali.rice.core.api.exception.RiceRuntimeException;
022import org.kuali.rice.krad.datadictionary.uif.UifDictionaryBean;
023
024import java.util.ArrayList;
025import java.util.HashMap;
026import java.util.List;
027import java.util.Map;
028
029/**
030 * Utility class for UIF expressions
031 *
032 * @author Kuali Rice Team (rice.collab@kuali.org)
033 */
034public class ExpressionUtils {
035    private static final Log LOG = LogFactory.getLog(ExpressionUtils.class);
036
037    /**
038     * Pulls expressions within the expressionConfigurable's expression graph and moves them to the property
039     * expressions
040     * map for the expressionConfigurable or a nested expressionConfigurable (for the case of nested expression
041     * property
042     * names)
043     *
044     * <p>
045     * Expressions that are configured on properties and pulled out by the {@link org.kuali.rice.krad.datadictionary.uif.UifBeanFactoryPostProcessor}
046     * and put in the {@link org.kuali.rice.krad.datadictionary.uif.UifDictionaryBean#getExpressionGraph()} for the
047     * bean
048     * that is
049     * at root (non nested) level. Before evaluating the expressions, they need to be moved to the
050     * {@link org.kuali.rice.krad.datadictionary.uif.UifDictionaryBean#getPropertyExpressions()} map for the
051     * expressionConfigurable that
052     * property
053     * is on.
054     * </p>
055     *
056     * @param expressionConfigurable expressionConfigurable instance to process expressions for
057     */
058    public static void populatePropertyExpressionsFromGraph(UifDictionaryBean expressionConfigurable) {
059        if (expressionConfigurable == null || expressionConfigurable.getExpressionGraph() == null) {
060            return;
061        }
062
063        // will hold graphs to populate the refreshExpressionGraph property on each expressionConfigurable
064        // key is the path to the expressionConfigurable and value is the map of nested property names to expressions
065        Map<String, Map<String, String>> refreshExpressionGraphs = new HashMap<String, Map<String, String>>();
066
067        Map<String, String> expressionGraph = expressionConfigurable.getExpressionGraph();
068        for (Map.Entry<String, String> expressionEntry : expressionGraph.entrySet()) {
069            String propertyName = expressionEntry.getKey();
070            String expression = expressionEntry.getValue();
071
072            // by default assume expression belongs with passed in expressionConfigurable
073            UifDictionaryBean configurableWithExpression = expressionConfigurable;
074
075            // if property name is nested, we need to move the expression to the last expressionConfigurable
076            String adjustedPropertyName = propertyName;
077            if (StringUtils.contains(propertyName, ".")) {
078                String configurablePath = StringUtils.substringBeforeLast(propertyName, ".");
079                adjustedPropertyName = StringUtils.substringAfterLast(propertyName, ".");
080
081                Object nestedObject = ObjectPropertyUtils.getPropertyValue(expressionConfigurable, configurablePath);
082
083                // skip missing expression object for components skipping their lifecycle because objects
084                // in these components may be missing (and are expected to be missing)
085                if (nestedObject == null
086                        && expressionConfigurable instanceof LifecycleElement
087                        && ((LifecycleElement) expressionConfigurable).skipLifecycle()) {
088                    continue;
089                }
090
091                if ((nestedObject == null) || !(nestedObject instanceof UifDictionaryBean)) {
092                    throw new RiceRuntimeException("Object for which expression is configured on is null or does not "
093                            + "implement UifDictionaryBean: '"
094                            + configurablePath
095                            + "' on class "
096                            + expressionConfigurable.getClass().getName()
097                            + " while evaluating "
098                            + "expression for "
099                            + propertyName);
100                }
101
102                // use nested object as the expressionConfigurable which will get the property expression
103                configurableWithExpression = (UifDictionaryBean) nestedObject;
104            }
105
106            configurableWithExpression.getPropertyExpressions().put(adjustedPropertyName, expression);
107        }
108    }
109
110    /**
111     * Takes in an expression and a list to be filled in with names(property names)
112     * of controls found in the expression. This method returns a js expression which can
113     * be executed on the client to determine if the original exp was satisfied before
114     * interacting with the server - ie, this js expression is equivalent to the one passed in.
115     *
116     * There are limitations on the Spring expression language that can be used as this method.
117     * It is only used to parse expressions which are valid case statements for determining if
118     * some action/processing should be performed.  ONLY Properties, comparison operators, booleans,
119     * strings, matches expression, and boolean logic are supported.  Properties must
120     * be a valid property on the form, and should have a visible control within the view.
121     *
122     * Example valid exp: account.name == 'Account Name'
123     *
124     * @param exp
125     * @param controlNames
126     * @return parsed expression, expressed as JS for client side evaluation
127     */
128    public static String parseExpression(String exp, List<String> controlNames) {
129        // clean up expression to ease parsing
130        exp = exp.trim();
131        if (exp.startsWith("@{")) {
132            exp = StringUtils.removeStart(exp, "@{");
133            if (exp.endsWith("}")) {
134                exp = StringUtils.removeEnd(exp, "}");
135            }
136        }
137
138        exp = StringUtils.replace(exp, "!=", " != ");
139        exp = StringUtils.replace(exp, "==", " == ");
140        exp = StringUtils.replace(exp, ">", " > ");
141        exp = StringUtils.replace(exp, "<", " < ");
142        exp = StringUtils.replace(exp, "<=", " <= ");
143        exp = StringUtils.replace(exp, ">=", " >= ");
144
145        String conditionJs = exp;
146        String stack = "";
147
148        boolean expectingSingleQuote = false;
149        boolean ignoreNext = false;
150        for (int i = 0; i < exp.length(); i++) {
151            char c = exp.charAt(i);
152            if (!expectingSingleQuote && !ignoreNext && (c == '(' || c == ' ' || c == ')')) {
153                evaluateCurrentStack(stack.trim(), controlNames);
154                //reset stack
155                stack = "";
156                continue;
157            } else if (!ignoreNext && c == '\'') {
158                stack = stack + c;
159                expectingSingleQuote = !expectingSingleQuote;
160            } else if (c == '\\') {
161                stack = stack + c;
162                ignoreNext = !ignoreNext;
163            } else {
164                stack = stack + c;
165                ignoreNext = false;
166            }
167        }
168
169        if (StringUtils.isNotEmpty(stack)) {
170            evaluateCurrentStack(stack.trim(), controlNames);
171        }
172
173        conditionJs = conditionJs.replaceAll("\\s(?i:ne)\\s", " != ").replaceAll("\\s(?i:eq)\\s", " == ").replaceAll(
174                "\\s(?i:gt)\\s", " > ").replaceAll("\\s(?i:lt)\\s", " < ").replaceAll("\\s(?i:lte)\\s", " <= ")
175                .replaceAll("\\s(?i:gte)\\s", " >= ").replaceAll("\\s(?i:and)\\s", " && ").replaceAll("\\s(?i:or)\\s",
176                        " || ").replaceAll("\\s(?i:not)\\s", " != ").replaceAll("\\s(?i:null)\\s?", " '' ").replaceAll(
177                        "\\s?(?i:#empty)\\((.*?)\\)", "isValueEmpty($1)").replaceAll(
178                        "\\s?(?i:#listContains)\\((.*?)\\)", "listContains($1)").replaceAll(
179                        "\\s?(?i:#emptyList)\\((.*?)\\)", "emptyList($1)");
180
181        if (conditionJs.contains("matches")) {
182            conditionJs = conditionJs.replaceAll("\\s+(?i:matches)\\s+'.*'", ".match(/" + "$0" + "/) != null ");
183            conditionJs = conditionJs.replaceAll("\\(/\\s+(?i:matches)\\s+'", "(/");
184            conditionJs = conditionJs.replaceAll("'\\s*/\\)", "/)");
185        }
186
187        List<String> removeControlNames = new ArrayList<String>();
188        List<String> addControlNames = new ArrayList<String>();
189        //convert property names to use coerceValue function and convert arrays to js arrays
190        for (String propertyName : controlNames) {
191            //array definitions are caught in controlNames because of the nature of the parse - convert them and remove
192            if (propertyName.trim().startsWith("{") && propertyName.trim().endsWith("}")) {
193                String array = propertyName.trim().replace('{', '[');
194                array = array.replace('}', ']');
195                conditionJs = conditionJs.replace(propertyName, array);
196                removeControlNames.add(propertyName);
197                continue;
198            }
199
200            //handle not
201            if (propertyName.startsWith("!")) {
202                String actualPropertyName = StringUtils.removeStart(propertyName, "!");
203                conditionJs = conditionJs.replace(propertyName, "!coerceValue(\"" + actualPropertyName + "\")");
204                removeControlNames.add(propertyName);
205                addControlNames.add(actualPropertyName);
206            } else {
207                conditionJs = conditionJs.replace(propertyName, "coerceValue(\"" + propertyName + "\")");
208            }
209        }
210
211        controlNames.removeAll(removeControlNames);
212        controlNames.addAll(addControlNames);
213
214        return conditionJs;
215    }
216
217    /**
218     * Used internally by parseExpression to evalute if the current stack is a property
219     * name (ie, will be a control on the form)
220     *
221     * @param stack
222     * @param controlNames
223     */
224    public static void evaluateCurrentStack(String stack, List<String> controlNames) {
225        if (StringUtils.isNotBlank(stack)) {
226            if (!(stack.equals("==")
227                    || stack.equals("!=")
228                    || stack.equals(">")
229                    || stack.equals("<")
230                    || stack.equals(">=")
231                    || stack.equals("<=")
232                    || stack.equalsIgnoreCase("ne")
233                    || stack.equalsIgnoreCase("eq")
234                    || stack.equalsIgnoreCase("gt")
235                    || stack.equalsIgnoreCase("lt")
236                    || stack.equalsIgnoreCase("lte")
237                    || stack.equalsIgnoreCase("gte")
238                    || stack.equalsIgnoreCase("matches")
239                    || stack.equalsIgnoreCase("null")
240                    || stack.equalsIgnoreCase("false")
241                    || stack.equalsIgnoreCase("true")
242                    || stack.equalsIgnoreCase("and")
243                    || stack.equalsIgnoreCase("or")
244                    || stack.contains("#empty")
245                    || stack.equals("!")
246                    || stack.contains("#emptyList")
247                    || stack.contains("#listContains")
248                    || stack.startsWith("'")
249                    || stack.endsWith("'"))) {
250
251                boolean isNumber = false;
252                if ((StringUtils.isNumeric(stack.substring(0, 1)) || stack.substring(0, 1).equals("-"))) {
253                    try {
254                        Double.parseDouble(stack);
255                        isNumber = true;
256                    } catch (NumberFormatException e) {
257                        isNumber = false;
258                    }
259                }
260
261                if (!(isNumber)) {
262                    //correct argument of a custom function ending in comma
263                    if (StringUtils.endsWith(stack, ",")) {
264                        stack = StringUtils.removeEnd(stack, ",").trim();
265                    }
266
267                    if (!controlNames.contains(stack)) {
268                        controlNames.add(stack);
269                    }
270                }
271            }
272        }
273    }
274
275}