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}