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 018 019/** 020 * Provides modular support parsing path expressions using Spring's BeanWrapper expression Syntax. 021 * (see <a href= 022 * "http://static.springsource.org/spring/docs/3.2.x/spring-framework-reference/html/validation.html" 023 * >The Spring Manual</a>) 024 * 025 * @author Kuali Rice Team (rice.collab@kuali.org) 026 */ 027public final class ObjectPathExpressionParser { 028 029 /** 030 * Used by {@link #parsePathExpression(Object, String, PathEntry)} to track parse state without 031 * the need to construct a new parser stack for each expression. 032 */ 033 private static final ThreadLocal<ParseState> TL_EL_PARSE_STATE = new ThreadLocal<ParseState>(); 034 035 /** 036 * Path entry interface for use with 037 * {@link ObjectPathExpressionParser#parsePathExpression(Object, String, PathEntry)}. 038 */ 039 public static interface PathEntry { 040 041 /** 042 * Parse one node. 043 * 044 * @param parentPath The path expression parsed so far. 045 * @param node The current parse node. 046 * @param next The next path token. 047 * @return A reference to the next parse node. 048 */ 049 Object parse(String parentPath, Object node, String next); 050 051 } 052 053 /** 054 * Tracks parser state for 055 * {@link ObjectPathExpressionParser#parsePathExpression(Object, String, PathEntry)}. 056 */ 057 private static final class ParseState { 058 059 /** 060 * The lexical index at which to begin the next lexical scan. 061 */ 062 private int nextScanIndex; 063 064 /** 065 * The lexical index of the next path separator token. 066 */ 067 private int nextTokenIndex; 068 069 /** 070 * The full original parse string. 071 */ 072 private String originalPath; 073 074 /** 075 * The current lexical index in the original path. 076 */ 077 private int originalPathIndex; 078 079 /** 080 * The portion of the path parsed so far. 081 */ 082 private String parentPath; 083 084 /** 085 * The continuation point of the parse expression currently being evaluation. 086 */ 087 private Object currentContinuation; 088 089 /** 090 * Determine if this parse state is active. 091 */ 092 private boolean isActive() { 093 return currentContinuation != null; 094 } 095 096 /** 097 * Reset parse state, allowing this state marker to be reused on the next expression. 098 */ 099 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}