View Javadoc
1   /**
2    * Copyright 2005-2016 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  
19  /**
20   * Provides modular support parsing path expressions using Spring's BeanWrapper expression Syntax.
21   * (see <a href=
22   * "http://static.springsource.org/spring/docs/3.2.x/spring-framework-reference/html/validation.html"
23   * >The Spring Manual</a>)
24   * 
25   * @author Kuali Rice Team (rice.collab@kuali.org)
26   */
27  public final class ObjectPathExpressionParser {
28  
29      /**
30       * Used by {@link #parsePathExpression(Object, String, PathEntry)} to track parse state without
31       * the need to construct a new parser stack for each expression.
32       */
33      private static final ThreadLocal<ParseState> TL_EL_PARSE_STATE = new ThreadLocal<ParseState>();
34  
35      /**
36       * Path entry interface for use with
37       * {@link ObjectPathExpressionParser#parsePathExpression(Object, String, PathEntry)}.
38       */
39      public static interface PathEntry {
40  
41          /**
42           * Parse one node.
43           * 
44           * @param parentPath The path expression parsed so far.
45           * @param node The current parse node.
46           * @param next The next path token.
47           * @return A reference to the next parse node.
48           */
49          Object parse(String parentPath, Object node, String next);
50  
51      }
52  
53      /**
54       * Tracks parser state for
55       * {@link ObjectPathExpressionParser#parsePathExpression(Object, String, PathEntry)}.
56       */
57      private static final class ParseState {
58  
59          /**
60           * The lexical index at which to begin the next lexical scan.
61           */
62          private int nextScanIndex;
63  
64          /**
65           * The lexical index of the next path separator token.
66           */
67          private int nextTokenIndex;
68  
69          /**
70           * The full original parse string.
71           */
72          private String originalPath;
73  
74          /**
75           * The current lexical index in the original path.
76           */
77          private int originalPathIndex;
78  
79          /**
80           * The portion of the path parsed so far.
81           */
82          private String parentPath;
83  
84          /**
85           * The continuation point of the parse expression currently being evaluation.
86           */
87          private Object currentContinuation;
88  
89          /**
90           * Determine if this parse state is active.
91           */
92          private boolean isActive() {
93              return currentContinuation != null;
94          }
95  
96          /**
97           * Reset parse state, allowing this state marker to be reused on the next expression.
98           */
99          private void reset() {
100             currentContinuation = null;
101             originalPath = null;
102             originalPathIndex = 0;
103             parentPath = null;
104         }
105 
106         /**
107          * Prepare for the next lexical scan.
108          * 
109          * <p>
110          * When a parenthetical expression occurs on the left hand side of the path, remove the
111          * parentheses.
112          * </p>
113          * 
114          * <p>
115          * When using Spring syntax, treat brackets the same as parentheses.
116          * </p>
117          * 
118          * <p>
119          * Upon returning from this method, the value of {@link #nextScanIndex} will point at the
120          * position of the character formerly to the right of the removed parenthetical group, if
121          * applicable. If no parenthetical group was removed, {@link #nextScanIndex} will be reset
122          * to 0.
123          * </p>
124          * 
125          * @param path The path expression from the current continuation point.
126          * @return The path expression, with brackets and quotes related to a collection reference removed.
127          */
128         public String prepareNextScan(String path) {
129             nextScanIndex = 0;
130 
131             if (path.length() == 0) {
132                 throw new IllegalArgumentException("Unexpected end of input " + parentPath);
133             }
134 
135             int endOfCollectionReference = indexOfCloseBracket(path, 0);
136             
137             if (endOfCollectionReference == -1) {
138                 return path;
139             }
140 
141             // Strip brackets from parse path.
142             StringBuilder pathBuilder = new StringBuilder(path);
143             pathBuilder.deleteCharAt(endOfCollectionReference);
144             pathBuilder.deleteCharAt(0);
145 
146             // Also strip quotes from the front/back of the collection reference.
147             char firstChar = pathBuilder.charAt(0);
148             if ((firstChar == '\'' || firstChar == '\"') &&
149                     path.charAt(endOfCollectionReference - 1) == firstChar) {
150                 
151                 pathBuilder.deleteCharAt(endOfCollectionReference - 2);
152                 pathBuilder.deleteCharAt(0);
153             }
154             
155             int diff = path.length() - pathBuilder.length();
156 
157             // Step scan index past collection reference, accounting for stripped characters.
158             nextScanIndex += endOfCollectionReference + 1 - diff;
159 
160             // Move original path index forward to correct for stripped characters.
161             originalPathIndex += diff;
162 
163             return pathBuilder.toString();
164         }
165 
166         /**
167          * Update current parse state with the lexical indexes of the next token break.
168          * 
169          * @param path The path being parsed, starting from the current continuation point.
170          */
171         public void scan(String path) {
172             nextTokenIndex = -1;
173 
174             // Scan the character sequence, starting with the character following the open marker.
175             for (int currentIndex = nextScanIndex; currentIndex < path.length(); currentIndex++) {
176                 switch (path.charAt(currentIndex)) {
177                     case ']':
178                         // should have been removed by prepareNextScan
179                         throw new IllegalArgumentException("Unmatched ']': " + path);
180                         // else fall through
181                     case '[':
182                     case '.':
183                         if (nextTokenIndex == -1) {
184                             nextTokenIndex = currentIndex;
185                         }
186 
187                         // Move original path index forward
188                         originalPathIndex += nextTokenIndex;
189                         return;
190                 }
191             }
192         }
193 
194         /**
195          * Step to the next continuation point in the parse path.
196          * 
197          * <p>
198          * Upon returning from this method, the value of {@link #currentContinuation} will reflect
199          * the resolved state of parsing the path. When null is returned, then
200          * {@link #currentContinuation} will be the reflect the result of parsing the expression.
201          * </p>
202          * 
203          * @param path The path expression from the current continuation point.
204          * @return The path expression for the next continuation point, null if the path has been
205          *         completely parsed.
206          */
207         private String step(String path, PathEntry pathEntry) {
208 
209             if (nextTokenIndex == -1) {
210                 // Only a symbolic reference, resolve it and return.
211                 currentContinuation = pathEntry.parse(parentPath, currentContinuation, path);
212                 parentPath = originalPath.substring(0, originalPathIndex);
213                 return null;
214             }
215 
216             char nextToken = path.charAt(nextTokenIndex);
217 
218             switch (nextToken) {
219 
220                 case '[':
221                     // Approaching a collection reference.
222                     currentContinuation = pathEntry.parse(parentPath, currentContinuation,
223                             path.substring(0, nextTokenIndex));
224                     parentPath = originalPath.substring(0, originalPathIndex);
225                     return path.substring(nextTokenIndex); // Keep the left parenthesis
226 
227                 case '.':
228                     // Crossing a period, not preceded by a collection reference.
229                     currentContinuation = pathEntry.parse(parentPath, currentContinuation,
230                             path.substring(0, nextTokenIndex));
231 
232                     // Step past the period
233                     parentPath = originalPath.substring(0, originalPathIndex++);
234 
235                     return path.substring(nextTokenIndex + 1);
236 
237                 default:
238                     throw new IllegalArgumentException("Unexpected '" + nextToken + "' :" + path);
239             }
240         }
241 
242     }
243 
244     /**
245      * Return the index of the close bracket that matches the bracket at the start of the path.
246      * 
247      * @param path The string to scan.
248      * @param leftBracketIndex The index of the left bracket.
249      * @return The index of the right bracket that matches the left bracket at index given. If the
250      *         path does not begin with an open bracket, then -1 is returned.
251      * @throw IllegalArgumentException If the left bracket is unmatched by the right bracket in the
252      *        parse string.
253      */
254     public static int indexOfCloseBracket(String path, int leftBracketIndex) {
255         if (path == null || path.length() <= leftBracketIndex || path.charAt(leftBracketIndex) != '[') {
256             return -1;
257         }
258 
259         char inQuote = '\0';
260         int pathLen = path.length() - 1;
261         int bracketCount = 1;
262         int currentPos = leftBracketIndex;
263 
264         do {
265             char currentChar = path.charAt(++currentPos);
266 
267             // Toggle quoted state as applicable.
268             if (inQuote == '\0' && (currentChar == '\'' || currentChar == '\"')) {
269                 inQuote = currentChar;
270             } else if (inQuote == currentChar) {
271                 inQuote = '\0';
272             }
273 
274             // Ignore quoted characters.
275             if (inQuote != '\0') continue;
276 
277             // Adjust bracket count as applicable.
278             if (currentChar == '[') bracketCount++;
279             if (currentChar == ']') bracketCount--;
280         } while (currentPos < pathLen && bracketCount > 0);
281 
282         if (bracketCount > 0) {
283             throw new IllegalArgumentException("Unmatched '[': " + path);
284         }
285         
286         return currentPos;
287     }
288 
289     /**
290      * Determine if a property name is a path or a plain property reference.
291      *
292      * <p>
293      * This method is used to eliminate parsing and object creation overhead when resolving an
294      * object property reference with a non-complex property path.
295      * </p>
296      * @param propertyName property name
297      *
298      * @return true if the name is a path, false if a plain reference
299      */
300     public static boolean isPath(String propertyName) {
301         if (propertyName == null) {
302             return false;
303         }
304 
305         int length = propertyName.length();
306         for (int i = 0; i < length; i++) {
307             char c = propertyName.charAt(i);
308             if (c != '_' && c != '$' && !Character.isLetterOrDigit(c)) {
309                 return true;
310             }
311         }
312 
313         return false;
314     }
315 
316     /**
317      * Parse a path expression.
318      * 
319      * @param root The root object.
320      * @param path The path expression.
321      * @param pathEntry The path entry adaptor to use for processing parse node transition.
322      * 
323      * @return The valid of the bean property indicated by the given path expression, null if the
324      *         path expression doesn't resolve to a valid property.
325      * @see ObjectPropertyUtils#getPropertyValue(Object, String)
326      */
327     @SuppressWarnings("unchecked")
328     public static <T> T parsePathExpression(Object root, String path, final PathEntry pathEntry) {
329 
330         // NOTE: This iterative parser allows support for subexpressions
331         // without recursion. When a subexpression start token '[' is
332         // encountered the current continuation is pushed onto a stack. When
333         // the subexpression is resolved, the continuation is popped back
334         // off the stack and resolved using the subexpression result as the
335         // arg. All subexpressions start with the same root passed in as an
336         // argument for this method. - MWF
337 
338         ParseState parseState = (ParseState) TL_EL_PARSE_STATE.get();
339         boolean recycle;
340 
341         if (parseState == null) {
342             TL_EL_PARSE_STATE.set(new ParseState());
343             parseState = TL_EL_PARSE_STATE.get();
344             recycle = true;
345         } else if (parseState.isActive()) {
346             ProcessLogger.ntrace("el-parse:", ":nested", 100);
347             parseState = new ParseState();
348             recycle = false;
349         } else {
350             recycle = true;
351         }
352 
353         try {
354             parseState.originalPath = path;
355             parseState.originalPathIndex = 0;
356             parseState.parentPath = null;
357             parseState.currentContinuation = pathEntry.parse(null, root, null);
358             while (path != null) {
359                 path = parseState.prepareNextScan(path);
360                 parseState.scan(path);
361                 path = parseState.step(path, pathEntry);
362             }
363             return (T) parseState.currentContinuation;
364         } finally {
365             assert !recycle || parseState == TL_EL_PARSE_STATE.get();
366             parseState.reset();
367         }
368     }
369 
370     /**
371      * Private constructor - utility class only.
372      */
373     private ObjectPathExpressionParser() {}
374 
375 }