View Javadoc

1   /**
2    * Copyright 2005-2014 The Kuali Foundation
3    *
4    * Licensed under the Educational Community License, Version 2.0 (the "License");
5    * you may not use this file except in compliance with the License.
6    * You may obtain a copy of the License at
7    *
8    * http://www.opensource.org/licenses/ecl2.php
9    *
10   * Unless required by applicable law or agreed to in writing, software
11   * distributed under the License is distributed on an "AS IS" BASIS,
12   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13   * See the License for the specific language governing permissions and
14   * limitations under the License.
15   */
16  package org.kuali.rice.krad.uif.util;
17  
18  import java.util.Deque;
19  
20  /**
21   * Provides modular support for the partial JSP EL path syntax using an arbitrary root bean as the
22   * initial name space.
23   * 
24   * <p>
25   * NOTE: This is not full JSP EL, only the path reference portion without support for floating point
26   * literals. See this <a href= "http://jsp.java.net/spec/jsp-2_1-fr-spec-el.pdf"> JSP Reference</a>
27   * for the full BNF.
28   * </p>
29   * 
30   * <pre>
31   * Value ::= ValuePrefix (ValueSuffix)*
32   * ValuePrefix ::= Literal
33   *     | NonLiteralValuePrefix
34   * NonLiteralValuePrefix ::= '(' Expression ')'
35   *     | Identifier
36   * ValueSuffix ::= '.' Identifier
37   *     | '[' Expression ']'
38   * Identifier ::= Java language identifier
39   * Literal ::= BooleanLiteral
40   *     | IntegerLiteral
41   *     | FloatingPointLiteral
42   *     | StringLiteral
43   *     | NullLiteral
44   * BooleanLiteral ::= 'true'
45   *     | 'false'
46   * StringLiteral ::= '([^'\]|\'|\\)*'
47   *     | "([^"\]|\"|\\)*"
48   *   i.e., a string of any characters enclosed by
49   *   single or double quotes, where \ is used to
50   *   escape ', ",and \. It is possible to use single
51   *   quotes within double quotes, and vice versa,
52   *   without escaping.
53   * IntegerLiteral ::= ['0'-'9']+
54   * NullLiteral ::= 'null'
55   * </pre>
56   * 
57   * @author Kuali Rice Team (rice.collab@kuali.org)
58   */
59  public class ObjectPathExpressionParser {
60  
61      /**
62       * Used by {@link #parsePathExpression(Object, String, PathEntry)} to track parse state without
63       * the need to construct a new parser stack for each expression.
64       */
65      private static final ThreadLocal<ParseState> TL_EL_PARSE_STATE = new ThreadLocal<ParseState>();
66  
67      /**
68       * Tracks parser state for
69       * {@link ObjectPathExpressionParser#parsePathExpression(Object, String, PathEntry)}.
70       */
71      private static final class ParseState {
72  
73          /**
74           * The continuation stack.
75           * <p>
76           * When evaluating subexpressions, the outer expression is pushed onto this stack.
77           * </p>
78           */
79          private final Deque<Object> stack = new java.util.LinkedList<Object>();
80  
81          /**
82           * The lexical index at which to begin the next lexical scan.
83           */
84          private int nextScanIndex;
85  
86          /**
87           * The lexical index of the next path separator token.
88           */
89          private int nextTokenIndex;
90  
91          /**
92           * The root continuation.
93           */
94          private Object root;
95  
96          /**
97           * The continuation point of the parse expression currently being evaluation.
98           */
99          private Object currentContinuation;
100 
101         /**
102          * Determine if this parse state is active.
103          */
104         private boolean isActive() {
105             return (stack != null && !stack.isEmpty()) || currentContinuation != null;
106         }
107 
108         /**
109          * Reset parse state, allowing this state marker to be reused on the next expression.
110          */
111         private void reset() {
112             stack.clear();
113             currentContinuation = null;
114         }
115 
116         /**
117          * Prepare for the next lexical scan.
118          * 
119          * <p>
120          * When a parenthetical expression occurs on the left hand side of the path, remove the
121          * parentheses.
122          * </p>
123          * 
124          * <p>
125          * Upon returning from this method, the value of {@link #nextScanIndex} will point at the
126          * position of the character formerly to the right of the removed parenthetical group, if
127          * applicable. If no parenthetical group was removed, {@link #nextScanIndex} will be reset
128          * to 0.
129          * </p>
130          * 
131          * @param path The path expression from the current continuation point.
132          * @return The path expression, with parentheses related to a grouping on the left removed.
133          */
134         public String prepareNextScan(String path) {
135             nextScanIndex = 0;
136 
137             char firstChar = path.charAt(0);
138 
139             if (firstChar != '(' && firstChar != '\'' && firstChar != '\"') {
140                 return path;
141             }
142 
143             int parenCount = firstChar == '(' ? 1 : 0;
144             int pathLen = path.length() - 1;
145 
146             // Look back during lexical scanning to detect quote and escape characers.
147             char lastChar = firstChar;
148             char currentChar;
149 
150             // Track quote state.
151             char inQuote = firstChar == '(' ? '\0' : firstChar;
152 
153             while (nextScanIndex < pathLen
154                     && ((firstChar == '(' && parenCount > 0) || (firstChar != '(' && inQuote != '\0'))) {
155                 nextScanIndex++;
156                 currentChar = path.charAt(nextScanIndex);
157 
158                 // Ignore escaped characters.
159                 if (lastChar == '\\') {
160                     continue;
161                 }
162 
163                 // Toggle quote state when a quote is encountered.
164                 if (inQuote == '\0') {
165                     if (currentChar == '\'' || currentChar == '\"') {
166                         inQuote = currentChar;
167                     }
168                 } else if (currentChar == inQuote) {
169                     // Emulate BeanWrapper bad quotes support. Automatically escape quotes where the close
170                     // quote is not positioned next to a path separator token.
171                     // i.e. aBean.aMap['aPoorly'quoted'key'] should reference "aPoorly'quoted'key" as the
172                     // key in the map.
173                     switch (nextScanIndex >= path.length() - 1 ? '\0' : path.charAt(nextScanIndex + 1)) {
174                     // Only accept close quote if followed by a lexical separator token.
175                         case ']':
176                         case '.':
177                         case '[':
178                             inQuote = '\0';
179                             break;
180                     }
181                 }
182 
183                 // Ignore quoted characters.
184                 if (inQuote != '\0') {
185                     continue;
186                 }
187 
188                 if (currentChar == '(') {
189                     parenCount++;
190                 }
191 
192                 if (currentChar == ')') {
193                     parenCount--;
194                 }
195             }
196 
197             if (parenCount > 0) {
198                 throw new IllegalArgumentException("Unmatched '(': " + path);
199             }
200 
201             if (firstChar == '(') {
202                 assert path.charAt(nextScanIndex) == ')';
203                 if (nextScanIndex < pathLen) {
204                     path = path.substring(1, nextScanIndex) + path.substring(nextScanIndex + 1);
205                 } else {
206                     path = path.substring(1, nextScanIndex);
207                 }
208                 nextScanIndex--;
209             } else {
210                 nextScanIndex++;
211             }
212 
213             return path;
214         }
215 
216         /**
217          * Update current parse state with the lexical indexes of the next token break.
218          * 
219          * @param path The path being parsed, starting from the current continuation point.
220          */
221         public void scan(String path) {
222             nextTokenIndex = -1;
223 
224             // Scan the character sequence, starting with the character following the open marker.
225             for (int currentIndex = nextScanIndex; currentIndex < path.length(); currentIndex++) {
226                 switch (path.charAt(currentIndex)) {
227                     case ')':
228                         // should have been removed by prepareNextScan
229                         throw new IllegalArgumentException("Unmatched ')': " + path);
230                     case '\'':
231                     case '\"':
232                     case '(':
233                     case '[':
234                     case '.':
235                     case ']':
236                         if (nextTokenIndex == -1) {
237                             nextTokenIndex = currentIndex;
238                         }
239                         return;
240                 }
241             }
242         }
243 
244         /**
245          * Step to the next continuation point in the parse path.
246          * 
247          * <p>
248          * Upon returning from this method, the value of {@link #currentContinuation} will reflect
249          * the resolved state of parsing the path. When null is returned, then
250          * {@link #currentContinuation} will be the reflect the result of parsing the expression.
251          * </p>
252          * 
253          * @param path The path expression from the current continuation point.
254          * @return The path expression for the next continuation point, null if the path has been
255          *         completely parsed.
256          */
257         private String step(String path, PathEntry pathEntry) {
258 
259             if (nextTokenIndex == -1) {
260                 // Only a symbolic reference, resolve it and return.
261                 currentContinuation = pathEntry.parse(pathEntry.prepare(currentContinuation), path, false);
262                 return null;
263             }
264 
265             char nextToken = path.charAt(nextTokenIndex);
266 
267             switch (nextToken) {
268 
269                 case '[':
270                     // Entering bracketed subexpression
271 
272                     // Resolve non-empty key reference as the current continuation
273                     if (nextTokenIndex != 0) {
274                         currentContinuation = pathEntry.parse(
275                                 pathEntry.prepare(currentContinuation),
276                                 path.substring(0, nextTokenIndex), false);
277                     }
278 
279                     // Push current continuation down in the stack.
280                     stack.push(currentContinuation);
281 
282                     // Reset the current continuation for evaluating the
283                     // subexpression
284                     currentContinuation = pathEntry.parse(root, null, false);
285                     return path.substring(nextTokenIndex + 1);
286 
287                 case '(':
288                     // Approaching a parenthetical expression, not preceded by a subexpression,
289                     // resolve the key reference as the current continuation
290                     currentContinuation = pathEntry.parse(pathEntry.prepare(currentContinuation),
291                             path.substring(0, nextTokenIndex), false);
292                     return path.substring(nextTokenIndex); // Keep the left parenthesis
293 
294                 case '.':
295                     // Crossing a period, not preceded by a subexpression,
296                     // resolve the key reference as the current continuation
297                     currentContinuation = pathEntry.parse(pathEntry.prepare(currentContinuation),
298                             path.substring(0, nextTokenIndex), false);
299                     return path.substring(nextTokenIndex + 1); // Skip the period
300 
301                 case ']':
302                     if (nextTokenIndex > 0) {
303                         // Approaching a right bracket, resolve the key reference as the current continuation
304                         currentContinuation = pathEntry.parse(pathEntry.prepare(currentContinuation),
305                                 path.substring(0, nextTokenIndex), false);
306                         return path.substring(nextTokenIndex); // Keep the right bracket
307 
308                     } else {
309                         // Crossing a right bracket.
310 
311                         // Use the current continuation as the parameter for resolving
312                         // the top continuation on the stack, then make the result the
313                         // current continuation.
314                         currentContinuation = pathEntry.parse(pathEntry.prepare(stack.pop()),
315                                 pathEntry.dereference(currentContinuation), true);
316                         if (nextTokenIndex + 1 < path.length()) {
317                             // short-circuit the next step, as an optimization for
318                             // handling dot resolution without permitting double-dots
319                             switch (path.charAt(nextTokenIndex + 1)) {
320                                 case '.':
321                                     // crossing a dot, skip it
322                                     return path.substring(nextTokenIndex + 2);
323                                 case '[':
324                                 case ']':
325                                     // crossing to another subexpression, don't skip it.
326                                     return path.substring(nextTokenIndex + 1);
327                                 default:
328                                     throw new IllegalArgumentException(
329                                             "Expected '.', '[', or ']': " + path);
330                             }
331                         } else {
332                             return null;
333                         }
334                     }
335 
336                 default:
337                     throw new IllegalArgumentException("Unexpected '" + nextToken + "' :" + path);
338             }
339         }
340 
341     }
342 
343     /**
344      * Path entry interface for use with
345      * {@link ObjectPathExpressionParser#parsePathExpression(Object, String, PathEntry)}.
346      */
347     public static interface PathEntry {
348 
349         /**
350          * Parse one node.
351          * 
352          * @param node The current parse node.
353          * @param next The next path token.
354          * @param inherit True indicates that the current node is the result of a subexpression,
355          *        false indicates the next node in the same expression.
356          * @return A reference to the next parse node.
357          */
358         Object parse(Object node, String next, boolean inherit);
359 
360         /**
361          * Prepare the next parse node based on a reference returned from the prior node.
362          * 
363          * @param prev The reference data from the previous node.
364          * @return The next parse node.
365          */
366         Object prepare(Object prev);
367 
368         /**
369          * Resolve the next path element based on reference data from the previous node.
370          * 
371          * @param prev The reference data from the previous node.
372          * @return the next path element based on the returned reference data.
373          */
374         String dereference(Object prev);
375     }
376 
377     /**
378      * Parse a path expression.
379      * 
380      * @param root The root object.
381      * @param path The path expression.
382      * @param pathEntry The path entry adaptor to use for processing parse node transition.
383      * @param <T> Reference type representing the next parse node.
384      * @param <S> The parse node type.
385      * 
386      * @return The valid of the bean property indicated by the given path expression, null if the
387      *         path expression doesn't resolve to a valid property.
388      * @see ObjectPathExpressionParser#getPropertyValue(Object, String)
389      */
390     public static Object parsePathExpression(Object root, String path,
391             final PathEntry pathEntry) {
392 
393         // NOTE: This iterative parser allows support for subexpressions
394         // without recursion. When a subexpression start token '[' is
395         // encountered the current continuation is pushed onto a stack. When
396         // the subexpression is resolved, the continuation is popped back
397         // off the stack and resolved using the subexpression result as the
398         // arg. All subexpressions start with the same root passed in as an
399         // argument for this method. - MWF
400 
401         ParseState parseState = (ParseState) TL_EL_PARSE_STATE.get();
402         boolean recycle;
403 
404         if (parseState == null) {
405             TL_EL_PARSE_STATE.set(new ParseState());
406             parseState = TL_EL_PARSE_STATE.get();
407             recycle = true;
408         } else if (parseState.isActive()) {
409             ProcessLogger.ntrace("el-parse:", ":nested", 100);
410             parseState = new ParseState();
411             recycle = false;
412         } else {
413             recycle = true;
414         }
415 
416         try {
417             parseState.root = root;
418             parseState.currentContinuation = pathEntry.parse(root, null, false);
419             while (path != null) {
420                 path = parseState.prepareNextScan(path);
421                 parseState.scan(path);
422                 path = parseState.step(path, pathEntry);
423             }
424             return parseState.currentContinuation;
425         } finally {
426             assert !recycle || parseState == TL_EL_PARSE_STATE.get();
427             parseState.reset();
428         }
429     }
430 
431     /**
432      * Private constructor - utility class only.
433      */
434     private ObjectPathExpressionParser() {}
435 
436 }