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 com.google.common.collect.Maps;
19  import org.apache.commons.collections.CollectionUtils;
20  import org.apache.commons.lang.StringUtils;
21  import org.kuali.rice.core.api.util.type.KualiDecimal;
22  import org.kuali.rice.krad.comparator.NumericValueComparator;
23  import org.kuali.rice.krad.comparator.TemporalValueComparator;
24  import org.kuali.rice.krad.uif.UifConstants;
25  import org.kuali.rice.krad.uif.component.BindingInfo;
26  import org.kuali.rice.krad.uif.container.CollectionGroup;
27  import org.kuali.rice.krad.uif.field.DataField;
28  import org.kuali.rice.krad.uif.field.Field;
29  import org.kuali.rice.krad.uif.layout.TableLayoutManager;
30  import org.kuali.rice.krad.uif.view.ExpressionEvaluator;
31  import org.kuali.rice.krad.uif.view.View;
32  import org.kuali.rice.krad.util.KRADUtils;
33  import org.kuali.rice.krad.util.ObjectUtils;
34  
35  import java.lang.reflect.InvocationTargetException;
36  import java.util.ArrayList;
37  import java.util.Comparator;
38  import java.util.HashMap;
39  import java.util.Iterator;
40  import java.util.List;
41  import java.util.Map;
42  import java.util.WeakHashMap;
43  import java.util.regex.Matcher;
44  import java.util.regex.Pattern;
45  
46  /**
47   * Comparator used for server side sorting of CollectionGroup data.
48   * 
49   * <p>
50   * This may include DataFields, as well as Fields that don't map directly to elements in the model
51   * collection, such as {@link org.kuali.rice.krad.uif.field.LinkField}s that may contain
52   * expressions.
53   * </p>
54   * 
55   * <p>
56   * NOTE: This class is not thread safe, and each instance is intended to be used only once.
57   * </p>
58   * 
59   * @author Kuali Rice Team (rice.collab@kuali.org)
60   */
61  public class MultiColumnComparator implements Comparator<Integer> {
62  
63      private final List<Object> modelCollection;
64      private final CollectionGroup collectionGroup;
65      private final List<ColumnSort> columnSorts;
66      private final View view;
67  
68      // we use the layout manager a lot, so for convenience we'll keep a handy reference to it
69      private final TableLayoutManager tableLayoutManager;
70  
71      // we need the prototype row to be able to get Fields that can be used in extracting & calculating column values
72      private final List<Field> prototypeRow;
73  
74      // if we have to evaluate expressions to sort a column, we want to cache the values so we don't have to
75      // evaluate the same expressions repeatedly.  This cache could get too big, so we'll use a weak reference map
76      private final WeakHashMap<String, String> calculatedValueCache;
77  
78      // Reflection is used to determine the class of certain column values.  Cache those classes
79      private final HashMap<String, Class> propertyClassCache;
80  
81      /**
82       * Constructs a MultiColumnComparator instance
83       * 
84       * @param modelCollection the model collection that the CollectionGroup is associated with
85       * @param collectionGroup the CollectionGroup whose columns are being sorted
86       * @param columnSorts A list from highest to lowest precedence of the column sorts to apply
87       * @param view The view
88       */
89      public MultiColumnComparator(List<Object> modelCollection, CollectionGroup collectionGroup,
90              List<ColumnSort> columnSorts, View view) {
91          this.modelCollection = modelCollection;
92          this.collectionGroup = collectionGroup;
93          this.columnSorts = columnSorts;
94          this.view = view;
95  
96          //
97          // initialize convenience members and calculated members.  Caches first!
98          //
99  
100         calculatedValueCache = new WeakHashMap<String, String>();
101         propertyClassCache = new HashMap<String, Class>();
102 
103         tableLayoutManager = (TableLayoutManager) collectionGroup.getLayoutManager();
104         prototypeRow = buildPrototypeRow();
105     }
106 
107     /**
108      * Compares the modelCollecton element at index1 to the element at index2 based on the provided
109      * {@link org.kuali.rice.krad.uif.util.ColumnSort}s.
110      * 
111      * @param index1 the index of the first modelCollection element used for comparison
112      * @param index2 the index of the second modelCollection element used for comparison
113      * @return 0 if the two elements are considered equal, a positive integer if the element at
114      *         index1 is considered greater, else a negative integer
115      */
116     @Override
117     public int compare(Integer index1, Integer index2) {
118         int sortResult = 0;
119 
120         for (ColumnSort columnSort : columnSorts) {
121 
122             Field protoField = prototypeRow.get(columnSort.getColumnIndex()); // get the prototype field for this column
123             Object modelElement1 = modelCollection.get(index1);
124             Object modelElement2 = modelCollection.get(index2);
125 
126             if (isOneNull(modelElement1, modelElement2)) { // is one of the modelCollection elements null?
127                 sortResult = compareOneIsNull(modelElement1, modelElement2);
128             } else if (protoField instanceof DataField) {
129                 sortResult = compareDataFieldValues(columnSort, (DataField) protoField, index1, index2);
130             } else {
131                 sortResult = compareFieldStringValues(columnSort, protoField, index1, index2);
132             }
133 
134             if (sortResult != 0) { // stop looking at additional columns, we've made our determination
135                 // Handle sort direction here
136                 if (columnSort.getDirection() == ColumnSort.Direction.DESC) {
137                     sortResult *= -1;
138                 }
139 
140                 break;
141             }
142         }
143 
144         return sortResult;
145     }
146 
147     /**
148      * Compare the DataField values for the two modelCollection element indexes.
149      * 
150      * @param columnSort the comparison metadata (which column number, which direction, what type of
151      *        sort)
152      * @param protoField the prototype DataField for the column being sorted
153      * @param index1 the index of the first modelCollection element for comparison
154      * @param index2 the index of the second modelCollection element for comparison
155      * @return 0 if the two elements are considered equal, a positive integer if the element at
156      *         index1 is considered greater, else a negative integer
157      */
158     private int compareDataFieldValues(ColumnSort columnSort, DataField protoField, Integer index1, Integer index2) {
159         final int sortResult;// for DataFields, try to get the property value and use it directly
160 
161         final Object modelElement1 = modelCollection.get(index1);
162         final Object modelElement2 = modelCollection.get(index2);
163 
164         // get the rest of the property path after the collection
165         final String propertyPath = protoField.getBindingInfo().getBindingName();
166         final Class<?> columnDataClass = getColumnDataClass(propertyPath);
167 
168         // we can do smart comparisons for Comparables
169         if (Comparable.class.isAssignableFrom(columnDataClass)) {
170             Comparable datum1 = (Comparable) ObjectUtils.getPropertyValue(modelElement1, propertyPath);
171             Comparable datum2 = (Comparable) ObjectUtils.getPropertyValue(modelElement2, propertyPath);
172 
173             if (isOneNull(datum1, datum2)) {
174                 sortResult = compareOneIsNull(datum1, datum2);
175             } else if (String.class.equals(columnDataClass)) {
176                 sortResult = columnTypeCompare((String) datum1, (String) datum2, columnSort.getSortType());
177             } else {
178                 sortResult = datum1.compareTo(datum2);
179             }
180         } else { // resort to basic column string value comparison if the column data class isn't Comparable
181             sortResult = compareFieldStringValues(columnSort, protoField, index1, index2);
182         }
183 
184         return sortResult;
185     }
186 
187     /**
188      * Attempt to determine the class of the column data value using the given modelCollection.
189      * 
190      * <p>
191      * If the class can not be determined, Object will be returned.
192      * </p>
193      * 
194      * @param propertyPath the path to the datum (which applies to modelCollection elements) whose
195      *        class we are attempting to determine
196      * @return the class of the given property from the modelElements, or Object if the class cannot
197      *         be determined.
198      */
199     private Class<?> getColumnDataClass(String propertyPath) {
200         Class<?> dataClass = propertyClassCache.get(propertyPath);
201 
202         if (dataClass == null) {
203 
204             // for the elements in the modelCollection while dataClass is null
205             for (int i = 0; i < modelCollection.size() && dataClass == null; i++) {
206                 // try getting the class from the modelCollection element
207                 dataClass = ObjectPropertyUtils.getPropertyType(modelCollection.get(i), propertyPath);
208             }
209 
210             if (dataClass == null) {
211                 dataClass = Object.class; // default
212             }
213 
214             propertyClassCache.put(propertyPath, dataClass);
215         }
216 
217         return dataClass;
218     }
219 
220     /**
221      * Compare the field values by computing the two string values and comparing them based on the
222      * sort type.
223      * 
224      * @param columnSort the comparison metadata (which column number, which direction, what type of
225      *        sort)
226      * @param protoField the prototype Field for the column being sorted
227      * @param index1 the index of the first modelCollection element for comparison
228      * @param index2 the index of the second modelCollection element for comparison
229      * @return 0 if the two elements are considered equal, a positive integer if the element at
230      *         index1 is considered greater, else a negative integer
231      */
232     private int compareFieldStringValues(ColumnSort columnSort, Field protoField, Integer index1, Integer index2) {
233         final int sortResult;
234         final String fieldValue1;
235         final String fieldValue2;
236 
237         if (!CollectionUtils.sizeIsEmpty(protoField.getPropertyExpressions())) {
238             // We have to evaluate expressions
239             fieldValue1 = calculateFieldValue(protoField, index1, columnSort.getColumnIndex());
240             fieldValue2 = calculateFieldValue(protoField, index2, columnSort.getColumnIndex());
241         } else {
242             fieldValue1 = KRADUtils.getSimpleFieldValue(modelCollection.get(index1), protoField);
243             fieldValue2 = KRADUtils.getSimpleFieldValue(modelCollection.get(index2), protoField);
244         }
245 
246         sortResult = columnTypeCompare(fieldValue1, fieldValue2, columnSort.getSortType());
247         return sortResult;
248     }
249 
250     /**
251      * Calculates the value for a field that may contain expressions.
252      * 
253      * <p>
254      * Checks for a cached value for this calculated value, and if there isn't one, expressions are
255      * evaluated before getting the value, which is then cached and returned.
256      * </p>
257      * 
258      * @param protoField the Field whose expressions need evaluation
259      * @param collectionIndex the index of the model collection element being used in the
260      *        calculation
261      * @param columnIndex the index of the column whose value is being calculated
262      * @return the calculated value for the field for this collection line
263      */
264     private String calculateFieldValue(Field protoField, Integer collectionIndex, int columnIndex) {
265         final String fieldValue1;
266 
267         // cache key format is "<elementIndex>,<columnIndex>"
268         final String cacheKey = String.format("%d,%d", collectionIndex, columnIndex);
269         String cachedValue = calculatedValueCache.get(cacheKey);
270 
271         if (cachedValue == null) {
272             Object collectionElement = modelCollection.get(collectionIndex);
273             ExpressionEvaluator expressionEvaluator = view.getViewHelperService().getExpressionEvaluator();
274 
275             // set up expression context
276             Map<String, Object> viewContext = view.getContext();
277             Map<String, Object> expressionContext = new HashMap<String, Object>();
278             
279             if (viewContext != null) {
280                 expressionContext.putAll(viewContext);
281             }
282             
283             expressionContext.put(UifConstants.ContextVariableNames.LINE, collectionElement);
284             expressionContext.put(UifConstants.ContextVariableNames.INDEX, collectionIndex);
285             expressionContext.put(UifConstants.ContextVariableNames.COLLECTION_GROUP, collectionGroup);
286             expressionContext.put(UifConstants.ContextVariableNames.MANAGER, tableLayoutManager);
287             expressionContext.put(UifConstants.ContextVariableNames.COMPONENT, protoField);
288             expressionContext.put(UifConstants.ContextVariableNames.PARENT, collectionGroup);
289 
290             expressionEvaluator.evaluateExpressionsOnConfigurable(view, protoField, expressionContext);
291 
292             fieldValue1 = KRADUtils.getSimpleFieldValue(collectionElement, protoField);
293 
294             calculatedValueCache.put(cacheKey, fieldValue1);
295         } else {
296             fieldValue1 = cachedValue;
297         }
298 
299         return fieldValue1;
300     }
301 
302     /**
303      * Compare the string values based on the given sortType, which must match one of the constants
304      * in {@link UifConstants.TableToolsValues}.
305      * 
306      * @param val1 The first string value for comparison
307      * @param val2 The second string value for comparison
308      * @param sortType the sort type
309      * @return 0 if the two elements are considered equal, a positive integer if the element at
310      *         index1 is considered greater, else a negative integer
311      */
312     private int columnTypeCompare(String val1, String val2, String sortType) {
313         final int result;
314 
315         if (isOneNull(val1, val2)) {
316             result = compareOneIsNull(val1, val2);
317         } else if (UifConstants.TableToolsValues.STRING.equals(sortType)) {
318             result = val1.compareTo(val2);
319         } else if (UifConstants.TableToolsValues.NUMERIC.equals(sortType)) {
320             result = NumericValueComparator.getInstance().compare(val1, val2);
321         } else if (UifConstants.TableToolsValues.PERCENT.equals(sortType)) {
322             result = NumericValueComparator.getInstance().compare(val1, val2);
323         } else if (UifConstants.TableToolsValues.DATE.equals(sortType)) {
324             result = TemporalValueComparator.getInstance().compare(val1, val2);
325         } else if (UifConstants.TableToolsValues.CURRENCY.equals(sortType)) {
326             // strip off non-numeric symbols, convert to KualiDecimals, and compare
327             KualiDecimal decimal1 = new KualiDecimal(val1.replaceAll("[^0-9.]", ""));
328             KualiDecimal decimal2 = new KualiDecimal(val2.replaceAll("[^0-9.]", ""));
329 
330             result = decimal1.compareTo(decimal2);
331         } else {
332             throw new RuntimeException("unknown sort type: " + sortType);
333         }
334 
335         return result;
336     }
337 
338     /**
339      * Is one of the given objects null?
340      * 
341      * @param o1 the first object
342      * @param o2 the second object
343      * @return true if one of the given references is null, false otherwise
344      */
345     private boolean isOneNull(Object o1, Object o2) {
346         return (o1 == null || o2 == null);
347     }
348 
349     /**
350      * Compare two referenced objects (assuming at least one of them is null).
351      * 
352      * <p>
353      * The arbitrary determination here is that a non-null reference is greater than a null
354      * reference, and two null references are equal.
355      * </p>
356      * 
357      * @param o1 the first object
358      * @param o2 the second object
359      * @return 0 if both are null, 1 if the first is non-null, and -1 if the second is non-null.
360      */
361     private int compareOneIsNull(Object o1, Object o2) {
362         if (o1 == null) {
363             if (o2 == null) {
364                 return 0;
365             }
366 
367             return -1;
368         }
369 
370         if (o2 != null) {
371             throw new IllegalStateException("at least one parameter must be null");
372         }
373 
374         return 1;
375     }
376 
377     /**
378      * Build a List of prototype Fields representing a row of the table.
379      * 
380      * <p>
381      * Any DataFields will have their binding paths shortened to access the model collection
382      * elements directly, instead of via the data object
383      * </p>
384      * 
385      * @return a List of prototype Fields representing a row in the table
386      */
387     private List<Field> buildPrototypeRow() {
388         final List<Field> prototypeRow = new ArrayList<Field>(tableLayoutManager.getNumberOfColumns());
389 
390         final List<Field> allRowFields = tableLayoutManager.getAllRowFields();
391         final Iterator<Field> allRowFieldsIter = allRowFields.iterator();
392 
393         // find the index of the first component beyond the add line
394         int componentsSkipped = 0;
395         int columnsSkipped = 0;
396 
397         if (collectionGroup.isRenderAddLine() && !collectionGroup.isReadOnly()
398                 && !tableLayoutManager.isSeparateAddLine()) {
399             while (columnsSkipped < tableLayoutManager.getNumberOfColumns()) {
400                 columnsSkipped += allRowFieldsIter.next().getColSpan();
401                 componentsSkipped += 1;
402             }
403         }
404 
405         // build prototypes from first row, starting just past the add line components
406         for (int i = 0; i < tableLayoutManager.getNumberOfColumns(); i++) {
407             Field protoField = allRowFields.get(componentsSkipped + i).copy(); // note the adjusted index
408 
409             if (protoField instanceof DataField) {
410                 // adjust binding path for direct element access
411                 final DataField dataField = (DataField) protoField;
412 
413                 // use a copy of the binding info so no shared data gets affected
414                 final BindingInfo bindingInfoCopy = dataField.getBindingInfo().copy();
415                 dataField.setBindingInfo(bindingInfoCopy);
416 
417                 String elementAdjustedBindingPath = dataField.getBindingInfo().getBindingName();
418                 bindingInfoCopy.setBindingPath(elementAdjustedBindingPath);
419             }
420 
421             prototypeRow.add(protoField);
422         }
423 
424         return prototypeRow;
425     }
426 }