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.collections.CollectionUtils; 019import org.kuali.rice.core.api.util.type.KualiDecimal; 020import org.kuali.rice.krad.comparator.NumericValueComparator; 021import org.kuali.rice.krad.comparator.TemporalValueComparator; 022import org.kuali.rice.krad.uif.UifConstants; 023import org.kuali.rice.krad.uif.container.CollectionGroup; 024import org.kuali.rice.krad.uif.container.CollectionGroupLineBuilder; 025import org.kuali.rice.krad.uif.container.collections.LineBuilderContext; 026import org.kuali.rice.krad.uif.field.DataField; 027import org.kuali.rice.krad.uif.field.Field; 028import org.kuali.rice.krad.uif.layout.TableLayoutManager; 029import org.kuali.rice.krad.uif.layout.collections.TableRow; 030import org.kuali.rice.krad.uif.layout.collections.TableRowBuilder; 031import org.kuali.rice.krad.uif.lifecycle.ViewLifecycle; 032import org.kuali.rice.krad.uif.lifecycle.ViewLifecyclePhase; 033import org.kuali.rice.krad.uif.view.ExpressionEvaluator; 034import org.kuali.rice.krad.uif.view.View; 035import org.kuali.rice.krad.uif.view.ViewModel; 036import org.kuali.rice.krad.util.KRADUtils; 037 038import java.util.Comparator; 039import java.util.HashMap; 040import java.util.List; 041import java.util.Map; 042import java.util.WeakHashMap; 043 044/** 045 * Comparator used for server side sorting of CollectionGroup data. 046 * 047 * <p> 048 * This may include DataFields, as well as Fields that don't map directly to elements in the model 049 * collection, such as {@link org.kuali.rice.krad.uif.field.LinkField}s that may contain 050 * expressions. 051 * </p> 052 * 053 * <p> 054 * NOTE: This class is not thread safe, and each instance is intended to be used only once. 055 * </p> 056 * 057 * @author Kuali Rice Team (rice.collab@kuali.org) 058 */ 059public class MultiColumnComparator implements Comparator<Integer> { 060 061 private final List<Object> modelCollection; 062 private final CollectionGroup collectionGroup; 063 private final List<ColumnSort> columnSorts; 064 private final View view; 065 private final ViewModel form; 066 067 // we use the layout manager a lot, so for convenience we'll keep a handy reference to it 068 private final TableLayoutManager tableLayoutManager; 069 070 // we need the prototype row to be able to get Fields that can be used in extracting & calculating column values 071 private final List<Field> prototypeRow; 072 073 // if we have to evaluate expressions to sort a column, we want to cache the values so we don't have to 074 // evaluate the same expressions repeatedly. This cache could get too big, so we'll use a weak reference map 075 private final WeakHashMap<String, String> calculatedValueCache; 076 077 // Reflection is used to determine the class of certain column values. Cache those classes 078 private final HashMap<String, Class> propertyClassCache; 079 080 /** 081 * Constructs a MultiColumnComparator instance 082 * 083 * @param modelCollection the model collection that the CollectionGroup is associated with 084 * @param collectionGroup the CollectionGroup whose columns are being sorted 085 * @param columnSorts A list from highest to lowest precedence of the column sorts to apply 086 * @param form object containing the view's data 087 * @param view The view 088 */ 089 public MultiColumnComparator(List<Object> modelCollection, CollectionGroup collectionGroup, 090 List<ColumnSort> columnSorts, ViewModel form, View view) { 091 this.modelCollection = modelCollection; 092 this.collectionGroup = collectionGroup; 093 this.columnSorts = columnSorts; 094 this.view = view; 095 this.form = form; 096 097 // 098 // initialize convenience members and calculated members. Caches first! 099 // 100 101 calculatedValueCache = new WeakHashMap<String, String>(); 102 propertyClassCache = new HashMap<String, Class>(); 103 104 tableLayoutManager = (TableLayoutManager) collectionGroup.getLayoutManager(); 105 prototypeRow = buildPrototypeRow(); 106 } 107 108 /** 109 * Compares the modelCollecton element at index1 to the element at index2 based on the provided 110 * {@link org.kuali.rice.krad.uif.util.ColumnSort}s. 111 * 112 * @param index1 the index of the first modelCollection element used for comparison 113 * @param index2 the index of the second modelCollection element used for comparison 114 * @return 0 if the two elements are considered equal, a positive integer if the element at 115 * index1 is considered greater, else a negative integer 116 */ 117 @Override 118 public int compare(Integer index1, Integer index2) { 119 int sortResult = 0; 120 121 for (ColumnSort columnSort : columnSorts) { 122 Field protoField = prototypeRow.get(columnSort.getColumnIndex()); 123 124 Object modelElement1 = modelCollection.get(index1); 125 Object modelElement2 = modelCollection.get(index2); 126 127 if (isOneNull(modelElement1, modelElement2)) { // is one of the modelCollection elements null? 128 sortResult = compareOneIsNull(modelElement1, modelElement2); 129 } else if (protoField instanceof DataField) { 130 sortResult = compareDataFieldValues(columnSort, (DataField) protoField, index1, index2); 131 } else { 132 sortResult = compareFieldStringValues(columnSort, protoField, index1, index2); 133 } 134 135 if (sortResult != 0) { // stop looking at additional columns, we've made our determination 136 // Handle sort direction here 137 if (columnSort.getDirection() == ColumnSort.Direction.DESC) { 138 sortResult *= -1; 139 } 140 141 break; 142 } 143 } 144 145 return sortResult; 146 } 147 148 /** 149 * Compare the DataField values for the two modelCollection element indexes. 150 * 151 * @param columnSort the comparison metadata (which column number, which direction, what type of 152 * sort) 153 * @param protoField the prototype DataField for the column being sorted 154 * @param index1 the index of the first modelCollection element for comparison 155 * @param index2 the index of the second modelCollection element for comparison 156 * @return 0 if the two elements are considered equal, a positive integer if the element at 157 * index1 is considered greater, else a negative integer 158 */ 159 private int compareDataFieldValues(ColumnSort columnSort, DataField protoField, Integer index1, Integer index2) { 160 final int sortResult;// for DataFields, try to get the property value and use it directly 161 162 final Object modelElement1 = modelCollection.get(index1); 163 final Object modelElement2 = modelCollection.get(index2); 164 165 // get the rest of the property path after the collection 166 final String propertyPath = protoField.getBindingInfo().getBindingName(); 167 final Class<?> columnDataClass = getColumnDataClass(propertyPath); 168 169 // we can do smart comparisons for Comparables 170 if (Comparable.class.isAssignableFrom(columnDataClass)) { 171 Comparable datum1 = (Comparable) ObjectPropertyUtils.getPropertyValue(modelElement1, propertyPath); 172 Comparable datum2 = (Comparable) ObjectPropertyUtils.getPropertyValue(modelElement2, propertyPath); 173 174 if (isOneNull(datum1, datum2)) { 175 sortResult = compareOneIsNull(datum1, datum2); 176 } else if (String.class.equals(columnDataClass)) { 177 sortResult = columnTypeCompare((String) datum1, (String) datum2, columnSort.getSortType()); 178 } else { 179 sortResult = datum1.compareTo(datum2); 180 } 181 } else { // resort to basic column string value comparison if the column data class isn't Comparable 182 sortResult = compareFieldStringValues(columnSort, protoField, index1, index2); 183 } 184 185 return sortResult; 186 } 187 188 /** 189 * Attempt to determine the class of the column data value using the given modelCollection. 190 * 191 * <p> 192 * If the class can not be determined, Object will be returned. 193 * </p> 194 * 195 * @param propertyPath the path to the datum (which applies to modelCollection elements) whose 196 * class we are attempting to determine 197 * @return the class of the given property from the modelElements, or Object if the class cannot 198 * be determined. 199 */ 200 private Class<?> getColumnDataClass(String propertyPath) { 201 Class<?> dataClass = propertyClassCache.get(propertyPath); 202 203 if (dataClass == null) { 204 205 // for the elements in the modelCollection while dataClass is null 206 for (int i = 0; i < modelCollection.size() && dataClass == null; i++) { 207 // try getting the class from the modelCollection element 208 dataClass = ObjectPropertyUtils.getPropertyType(modelCollection.get(i), propertyPath); 209 } 210 211 if (dataClass == null) { 212 dataClass = Object.class; // default 213 } 214 215 propertyClassCache.put(propertyPath, dataClass); 216 } 217 218 return dataClass; 219 } 220 221 /** 222 * Compare the field values by computing the two string values and comparing them based on the 223 * sort type. 224 * 225 * @param columnSort the comparison metadata (which column number, which direction, what type of 226 * sort) 227 * @param protoField the prototype Field for the column being sorted 228 * @param index1 the index of the first modelCollection element for comparison 229 * @param index2 the index of the second modelCollection element for comparison 230 * @return 0 if the two elements are considered equal, a positive integer if the element at 231 * index1 is considered greater, else a negative integer 232 */ 233 private int compareFieldStringValues(ColumnSort columnSort, Field protoField, Integer index1, Integer index2) { 234 final int sortResult; 235 final String fieldValue1; 236 final String fieldValue2; 237 238 if (!CollectionUtils.sizeIsEmpty(protoField.getPropertyExpressions())) { 239 // We have to evaluate expressions 240 fieldValue1 = calculateFieldValue(protoField, index1, columnSort.getColumnIndex()); 241 fieldValue2 = calculateFieldValue(protoField, index2, columnSort.getColumnIndex()); 242 } else { 243 fieldValue1 = KRADUtils.getSimpleFieldValue(modelCollection.get(index1), protoField); 244 fieldValue2 = KRADUtils.getSimpleFieldValue(modelCollection.get(index2), protoField); 245 } 246 247 sortResult = columnTypeCompare(fieldValue1, fieldValue2, columnSort.getSortType()); 248 return sortResult; 249 } 250 251 /** 252 * Calculates the value for a field that may contain expressions. 253 * 254 * <p> 255 * Checks for a cached value for this calculated value, and if there isn't one, expressions are 256 * evaluated before getting the value, which is then cached and returned. 257 * </p> 258 * 259 * @param protoField the Field whose expressions need evaluation 260 * @param collectionIndex the index of the model collection element being used in the 261 * calculation 262 * @param columnIndex the index of the column whose value is being calculated 263 * @return the calculated value for the field for this collection line 264 */ 265 private String calculateFieldValue(Field protoField, Integer collectionIndex, int columnIndex) { 266 final String fieldValue1; 267 268 // cache key format is "<elementIndex>,<columnIndex>" 269 final String cacheKey = String.format("%d,%d", collectionIndex, columnIndex); 270 String cachedValue = calculatedValueCache.get(cacheKey); 271 272 if (cachedValue == null) { 273 View view = ViewLifecycle.getView(); 274 275 Object collectionElement = modelCollection.get(collectionIndex); 276 ExpressionEvaluator expressionEvaluator = ViewLifecycle.getExpressionEvaluator(); 277 278 // set up expression context 279 Map<String, Object> viewContext = view.getContext(); 280 Map<String, Object> expressionContext = new HashMap<String, Object>(); 281 282 if (viewContext != null) { 283 expressionContext.putAll(viewContext); 284 } 285 286 ViewLifecyclePhase phase = ViewLifecycle.getPhase(); 287 if (phase.getParent() instanceof CollectionGroup) { 288 CollectionGroup collectionGroup = (CollectionGroup) phase.getParent(); 289 expressionContext.put(UifConstants.ContextVariableNames.COLLECTION_GROUP, collectionGroup); 290 expressionContext.put(UifConstants.ContextVariableNames.MANAGER, collectionGroup.getLayoutManager()); 291 expressionContext.put(UifConstants.ContextVariableNames.PARENT, collectionGroup); 292 } 293 294 expressionContext.put(UifConstants.ContextVariableNames.LINE, collectionElement); 295 expressionContext.put(UifConstants.ContextVariableNames.INDEX, collectionIndex); 296 expressionContext.put(UifConstants.ContextVariableNames.COMPONENT, protoField); 297 298 expressionEvaluator.evaluateExpressionsOnConfigurable(view, protoField, expressionContext); 299 300 fieldValue1 = KRADUtils.getSimpleFieldValue(collectionElement, protoField); 301 302 calculatedValueCache.put(cacheKey, fieldValue1); 303 } else { 304 fieldValue1 = cachedValue; 305 } 306 307 return fieldValue1; 308 } 309 310 /** 311 * Compare the string values based on the given sortType, which must match one of the constants 312 * in {@link org.kuali.rice.krad.uif.UifConstants.TableToolsValues}. 313 * 314 * @param val1 The first string value for comparison 315 * @param val2 The second string value for comparison 316 * @param sortType the sort type 317 * @return 0 if the two elements are considered equal, a positive integer if the element at 318 * index1 is considered greater, else a negative integer 319 */ 320 private int columnTypeCompare(String val1, String val2, String sortType) { 321 final int result; 322 323 if (isOneNull(val1, val2)) { 324 result = compareOneIsNull(val1, val2); 325 } else if (UifConstants.TableToolsValues.STRING.equals(sortType)) { 326 result = val1.compareTo(val2); 327 } else if (UifConstants.TableToolsValues.NUMERIC.equals(sortType)) { 328 result = NumericValueComparator.getInstance().compare(val1, val2); 329 } else if (UifConstants.TableToolsValues.PERCENT.equals(sortType)) { 330 result = NumericValueComparator.getInstance().compare(val1, val2); 331 } else if (UifConstants.TableToolsValues.DATE.equals(sortType)) { 332 result = TemporalValueComparator.getInstance().compare(val1, val2); 333 } else if (UifConstants.TableToolsValues.CURRENCY.equals(sortType)) { 334 // strip off non-numeric symbols, convert to KualiDecimals, and compare 335 KualiDecimal decimal1 = new KualiDecimal(val1.replaceAll("[^0-9.]", "")); 336 KualiDecimal decimal2 = new KualiDecimal(val2.replaceAll("[^0-9.]", "")); 337 338 result = decimal1.compareTo(decimal2); 339 } else { 340 throw new RuntimeException("unknown sort type: " + sortType); 341 } 342 343 return result; 344 } 345 346 /** 347 * Is one of the given objects null? 348 * 349 * @param o1 the first object 350 * @param o2 the second object 351 * @return true if one of the given references is null, false otherwise 352 */ 353 private boolean isOneNull(Object o1, Object o2) { 354 return (o1 == null || o2 == null); 355 } 356 357 /** 358 * Compare two referenced objects (assuming at least one of them is null). 359 * 360 * <p> 361 * The arbitrary determination here is that a non-null reference is greater than a null 362 * reference, and two null references are equal. 363 * </p> 364 * 365 * @param o1 the first object 366 * @param o2 the second object 367 * @return 0 if both are null, 1 if the first is non-null, and -1 if the second is non-null. 368 */ 369 private int compareOneIsNull(Object o1, Object o2) { 370 if (o1 == null) { 371 if (o2 == null) { 372 return 0; 373 } 374 375 return -1; 376 } 377 378 if (o2 != null) { 379 throw new IllegalStateException("at least one parameter must be null"); 380 } 381 382 return 1; 383 } 384 385 /** 386 * Build a List of prototype Fields representing a row of the table. 387 * 388 * <p>Any DataFields will have their binding paths shortened to access the model collection 389 * elements directly, instead of via the data object</p> 390 * 391 * @return a List of prototype Fields representing a row in the table 392 */ 393 protected List<Field> buildPrototypeRow() { 394 LineBuilderContext lineBuilderContext = new LineBuilderContext(0, modelCollection.get(0), null, false, form, 395 collectionGroup, collectionGroup.getLineActions()); 396 397 CollectionGroupLineBuilder collectionGroupLineBuilder = 398 collectionGroup.getCollectionGroupBuilder().getCollectionGroupLineBuilder(lineBuilderContext); 399 collectionGroupLineBuilder.preprocessLine(); 400 401 TableRowBuilder tableRowBuilder = new TableRowBuilder(collectionGroup, lineBuilderContext); 402 TableRow tableRow = tableRowBuilder.buildRow(); 403 404 return tableRow.getColumns(); 405 } 406}