001/**
002 * Copyright 2005-2016 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.kew.docsearch;
017
018import java.io.IOException;
019import java.sql.Date;
020import java.sql.Timestamp;
021import java.text.ParseException;
022import java.util.ArrayList;
023import java.util.Collection;
024import java.util.Collections;
025import java.util.EnumSet;
026import java.util.List;
027
028import org.apache.commons.collections.CollectionUtils;
029import org.apache.commons.lang.StringUtils;
030import org.apache.log4j.Logger;
031import org.codehaus.jackson.map.ObjectMapper;
032import org.codehaus.jackson.map.annotate.JsonSerialize;
033import org.joda.time.DateTime;
034import org.joda.time.MutableDateTime;
035import org.kuali.rice.core.api.CoreApiServiceLocator;
036import org.kuali.rice.core.api.data.DataType;
037import org.kuali.rice.core.api.reflect.ObjectDefinition;
038import org.kuali.rice.core.api.search.Range;
039import org.kuali.rice.core.api.search.SearchExpressionUtils;
040import org.kuali.rice.core.api.uif.AttributeLookupSettings;
041import org.kuali.rice.core.api.uif.RemotableAttributeError;
042import org.kuali.rice.core.api.uif.RemotableAttributeField;
043import org.kuali.rice.core.api.util.ClassLoaderUtils;
044import org.kuali.rice.core.api.util.RiceConstants;
045import org.kuali.rice.core.api.util.RiceKeyConstants;
046import org.kuali.rice.core.framework.persistence.jdbc.sql.SQLUtils;
047import org.kuali.rice.core.framework.resourceloader.ObjectDefinitionResolver;
048import org.kuali.rice.kew.api.KewApiConstants;
049import org.kuali.rice.kew.api.document.search.DocumentSearchCriteria;
050import org.kuali.rice.krad.util.GlobalVariables;
051
052import com.google.common.base.Function;
053
054/**
055 * Defines various utilities for internal use in the reference implementation of the document search functionality.
056 *
057 * @author Kuali Rice Team (rice.collab@kuali.org)
058 */
059public class DocumentSearchInternalUtils {
060
061    private static final Logger LOG = Logger.getLogger(DocumentSearchInternalUtils.class);
062
063    private static final boolean CASE_SENSITIVE_DEFAULT = false;
064
065    private static final String STRING_ATTRIBUTE_TABLE_NAME = "KREW_DOC_HDR_EXT_T";
066    private static final String DATE_TIME_ATTRIBUTE_TABLE_NAME = "KREW_DOC_HDR_EXT_DT_T";
067    private static final String DECIMAL_ATTRIBUTE_TABLE_NAME = "KREW_DOC_HDR_EXT_FLT_T";
068    private static final String INTEGER_ATTRIBUTE_TABLE_NAME = "KREW_DOC_HDR_EXT_LONG_T";
069
070    private static final List<SearchableAttributeConfiguration> CONFIGURATIONS =
071            new ArrayList<SearchableAttributeConfiguration>();
072    public static final List<Class<? extends SearchableAttributeValue>> SEARCHABLE_ATTRIBUTE_BASE_CLASS_LIST =
073            new ArrayList<Class<? extends SearchableAttributeValue>>();
074
075    static {
076        SEARCHABLE_ATTRIBUTE_BASE_CLASS_LIST.add(SearchableAttributeStringValue.class);
077        SEARCHABLE_ATTRIBUTE_BASE_CLASS_LIST.add(SearchableAttributeFloatValue.class);
078        SEARCHABLE_ATTRIBUTE_BASE_CLASS_LIST.add(SearchableAttributeLongValue.class);
079        SEARCHABLE_ATTRIBUTE_BASE_CLASS_LIST.add(SearchableAttributeDateTimeValue.class);
080    }
081
082    static {
083
084        CONFIGURATIONS.add(new SearchableAttributeConfiguration(
085                STRING_ATTRIBUTE_TABLE_NAME,
086                EnumSet.of(DataType.BOOLEAN, DataType.STRING, DataType.MARKUP),
087                String.class));
088
089        CONFIGURATIONS.add(new SearchableAttributeConfiguration(
090                DATE_TIME_ATTRIBUTE_TABLE_NAME,
091                EnumSet.of(DataType.DATE, DataType.TRUNCATED_DATE, DataType.DATETIME),
092                Timestamp.class));
093
094        CONFIGURATIONS.add(new SearchableAttributeConfiguration(
095                DECIMAL_ATTRIBUTE_TABLE_NAME,
096                EnumSet.of(DataType.FLOAT, DataType.DOUBLE, DataType.CURRENCY),
097                Float.TYPE));
098
099        CONFIGURATIONS.add(new SearchableAttributeConfiguration(
100                INTEGER_ATTRIBUTE_TABLE_NAME,
101                EnumSet.of(DataType.INTEGER, DataType.LONG),
102                Long.TYPE));
103
104    }
105
106    // initialize-on-demand holder class idiom - see Effective Java item #71
107    /**
108     * KULRICE-6704 - cached ObjectMapper for improved performance
109     *
110     */
111    private static ObjectMapper getObjectMapper() { return ObjectMapperHolder.objectMapper; }
112
113    private static class ObjectMapperHolder {
114        static final ObjectMapper objectMapper = initializeObjectMapper();
115
116        private static ObjectMapper initializeObjectMapper() {
117            ObjectMapper jsonMapper = new ObjectMapper();
118            jsonMapper.getSerializationConfig().setSerializationInclusion(JsonSerialize.Inclusion.NON_NULL);
119            return jsonMapper;
120        }
121    }
122    
123    public static boolean isLookupCaseSensitive(RemotableAttributeField remotableAttributeField) {
124        if (remotableAttributeField == null) {
125            throw new IllegalArgumentException("remotableAttributeField was null");
126        }
127        AttributeLookupSettings lookupSettings = remotableAttributeField.getAttributeLookupSettings();
128        if (lookupSettings != null) {
129            if (lookupSettings.isCaseSensitive() != null) {
130                return lookupSettings.isCaseSensitive().booleanValue();
131            }
132        }
133        return CASE_SENSITIVE_DEFAULT;
134    }
135
136    public static String getAttributeTableName(RemotableAttributeField attributeField) {
137        return getConfigurationForField(attributeField).getTableName();
138    }
139
140    public static Class<?> getDataTypeClass(RemotableAttributeField attributeField) {
141        return getConfigurationForField(attributeField).getDataTypeClass();
142    }
143
144    private static SearchableAttributeConfiguration getConfigurationForField(RemotableAttributeField attributeField) {
145        for (SearchableAttributeConfiguration configuration : CONFIGURATIONS) {
146            DataType dataType = attributeField.getDataType();
147            if (dataType == null) {
148                dataType = DataType.STRING;
149            }
150            if (configuration.getSupportedDataTypes().contains(dataType))  {
151                return configuration;
152            }
153        }
154        throw new IllegalArgumentException("Failed to determine proper searchable attribute configuration for given data type of '" + attributeField.getDataType() + "'");
155    }
156
157    public static List<SearchableAttributeValue> getSearchableAttributeValueObjectTypes() {
158        List<SearchableAttributeValue> searchableAttributeValueClasses = new ArrayList<SearchableAttributeValue>();
159        for (Class<? extends SearchableAttributeValue> searchAttributeValueClass : SEARCHABLE_ATTRIBUTE_BASE_CLASS_LIST) {
160            ObjectDefinition objDef = new ObjectDefinition(searchAttributeValueClass);
161            SearchableAttributeValue attributeValue = (SearchableAttributeValue) ObjectDefinitionResolver.createObject(
162                    objDef, ClassLoaderUtils.getDefaultClassLoader(), false);
163            searchableAttributeValueClasses.add(attributeValue);
164        }
165        return searchableAttributeValueClasses;
166    }
167
168    public static SearchableAttributeValue getSearchableAttributeValueByDataTypeString(String dataType) {
169        SearchableAttributeValue returnableValue = null;
170        if (StringUtils.isBlank(dataType)) {
171            return returnableValue;
172        }
173        for (SearchableAttributeValue attValue : getSearchableAttributeValueObjectTypes())
174        {
175            if (dataType.equalsIgnoreCase(attValue.getAttributeDataType()))
176            {
177                if (returnableValue != null)
178                {
179                    String errorMsg = "Found two SearchableAttributeValue objects with same data type string ('" + dataType + "' while ignoring case):  " + returnableValue.getClass().getName() + " and " + attValue.getClass().getName();
180                    LOG.error("getSearchableAttributeValueByDataTypeString() " + errorMsg);
181                    throw new RuntimeException(errorMsg);
182                }
183                LOG.debug("getSearchableAttributeValueByDataTypeString() SearchableAttributeValue class name is " + attValue.getClass().getName() + "... ojbConcreteClassName is " + attValue.getOjbConcreteClass());
184                ObjectDefinition objDef = new ObjectDefinition(attValue.getClass());
185                returnableValue = (SearchableAttributeValue) ObjectDefinitionResolver.createObject(objDef, ClassLoaderUtils.getDefaultClassLoader(), false);
186            }
187        }
188        return returnableValue;
189    }
190
191    public static String getDisplayValueWithDateOnly(DateTime value) {
192        return getDisplayValueWithDateOnly(new Timestamp(value.getMillis()));
193    }
194
195    public static String getDisplayValueWithDateOnly(Timestamp value) {
196        return RiceConstants.getDefaultDateFormat().format(new Date(value.getTime()));
197    }
198
199    public static DateTime getLowerDateTimeBound(String dateRange) throws ParseException {
200        Range range = SearchExpressionUtils.parseRange(dateRange);
201        if (range == null) {
202            throw new IllegalArgumentException("Failed to parse date range from given string: " + dateRange);
203        }
204        if (range.getLowerBoundValue() != null) {
205            java.util.Date lowerRangeDate = null;
206            try{
207                lowerRangeDate = CoreApiServiceLocator.getDateTimeService().convertToDate(range.getLowerBoundValue());
208            }catch(ParseException pe){
209                GlobalVariables.getMessageMap().putError("dateFrom", RiceKeyConstants.ERROR_CUSTOM, pe.getMessage());
210            }
211            MutableDateTime dateTime = new MutableDateTime(lowerRangeDate);
212            dateTime.setMillisOfDay(0);
213            return dateTime.toDateTime();
214        }
215        return null;
216    }
217
218    public static DateTime getUpperDateTimeBound(String dateRange) throws ParseException {
219        Range range = SearchExpressionUtils.parseRange(dateRange);
220        if (range == null) {
221            throw new IllegalArgumentException("Failed to parse date range from given string: " + dateRange);
222        }
223        if (range.getUpperBoundValue() != null) {
224            java.util.Date upperRangeDate = null;
225            try{
226                upperRangeDate = CoreApiServiceLocator.getDateTimeService().convertToDate(range.getUpperBoundValue());
227            }catch(ParseException pe){
228                GlobalVariables.getMessageMap().putError("dateCreated", RiceKeyConstants.ERROR_CUSTOM, pe.getMessage());
229            }
230            MutableDateTime dateTime = new MutableDateTime(upperRangeDate);
231            // set it to the last millisecond of the day
232            dateTime.setMillisOfDay((24 * 60 * 60 * 1000) - 1);
233            return dateTime.toDateTime();
234        }
235        return null;
236    }
237
238    public static class SearchableAttributeConfiguration {
239
240        private final String tableName;
241        private final EnumSet<DataType> supportedDataTypes;
242        private final Class<?> dataTypeClass;
243
244        public SearchableAttributeConfiguration(String tableName,
245                EnumSet<DataType> supportedDataTypes,
246                Class<?> dataTypeClass) {
247            this.tableName = tableName;
248            this.supportedDataTypes = supportedDataTypes;
249            this.dataTypeClass = dataTypeClass;
250        }
251
252        public String getTableName() {
253            return tableName;
254        }
255
256        public EnumSet<DataType> getSupportedDataTypes() {
257            return supportedDataTypes;
258        }
259
260        public Class<?> getDataTypeClass() {
261            return dataTypeClass;
262        }
263
264    }
265
266    /**
267     * Unmarshals a DocumentSearchCriteria from JSON string
268     * @param string the JSON
269     * @return unmarshalled DocumentSearchCriteria
270     * @throws IOException
271     */
272    public static DocumentSearchCriteria unmarshalDocumentSearchCriteria(String string) throws IOException {
273        DocumentSearchCriteria.Builder builder = getObjectMapper().readValue(string, DocumentSearchCriteria.Builder.class);
274        // fix up the Joda DateTimes
275        builder.normalizeDateTimes();
276        // build() it
277        return builder.build();
278    }
279
280    /**
281     * Marshals a DocumentSearchCriteria to JSON string
282     * @param criteria the criteria
283     * @return a JSON string
284     * @throws IOException
285     */
286    public static String marshalDocumentSearchCriteria(DocumentSearchCriteria criteria) throws IOException {
287        // Jackson XC support not included by Rice, so no auto-magic JAXB-compatibility
288        // AnnotationIntrospector introspector = new JaxbAnnotationIntrospector();
289        // // make deserializer use JAXB annotations (only)
290        // mapper.getDeserializationConfig().setAnnotationIntrospector(introspector);
291        // // make serializer use JAXB annotations (only)
292        // mapper.getSerializationConfig().setAnnotationIntrospector(introspector);
293        return getObjectMapper().writeValueAsString(criteria);
294    }
295
296    public static List<RemotableAttributeError> validateSearchFieldValues(String fieldName, SearchableAttributeValue attributeValue, List<String> searchValues, String errorMessagePrefix, List<String> resultingValues, Function<String, Collection<RemotableAttributeError>> customValidator) {
297        List<RemotableAttributeError> errors = new ArrayList<RemotableAttributeError>();
298        // nothing to validate
299        if (CollectionUtils.isEmpty(searchValues)) {
300            return errors;
301        }
302        for (String searchValue: searchValues) {
303            errors.addAll(validateSearchFieldValue(fieldName, attributeValue, searchValue, errorMessagePrefix, resultingValues, customValidator));
304        }
305        return Collections.unmodifiableList(errors);
306    }
307
308    /**
309     * Validates a single DocumentSearchCriteria searchable attribute field value (of the list of possibly multiple values)
310     * @param attributeValue the searchable attribute value type
311     * @param enteredValue the incoming DSC field value
312     * @param fieldName the name of the searchable attribute field/key
313     * @param errorMessagePrefix error message prefix
314     * @param resultingValues optional list of accumulated parsed values
315     * @param customValidator custom value validator to invoke on default validation success
316     * @return (possibly empty) list of validation error
317     */
318    public static List<RemotableAttributeError> validateSearchFieldValue(String fieldName, SearchableAttributeValue attributeValue, String enteredValue, String errorMessagePrefix, List<String> resultingValues, Function<String, Collection<RemotableAttributeError>> customValidator) {
319        List<RemotableAttributeError> errors = new ArrayList<RemotableAttributeError>();
320        if (enteredValue == null) {
321            return errors;
322        }
323        // TODO: this also parses compound expressions and therefore produces a list of strings
324        //       how does this relate to DocumentSearchInternalUtils.parseRange... which should consume which?
325        List<String> parsedValues = SQLUtils.getCleanedSearchableValues(enteredValue, attributeValue.getAttributeDataType());
326        for (String value: parsedValues) {
327            errors.addAll(validateParsedSearchFieldValue(fieldName, attributeValue, value, errorMessagePrefix, resultingValues, customValidator));
328        }
329        return errors;
330    }
331
332    /**
333     * Validates a single terminal value from a single search field (list of values); calls a custom validator if default validation passes and
334     * custom validator is given
335     * @param attributeValue the searchable value type
336     * @param parsedValue the parsed value to validate
337     * @param fieldName the field name for error message
338     * @param errorMessagePrefix the prefix for error message
339     * @param resultingValues parsed value is appended to this list if present (non-null)
340     * @return immutable collection of errors (possibly empty)
341     */
342    public static Collection<RemotableAttributeError> validateParsedSearchFieldValue(String fieldName, SearchableAttributeValue attributeValue, String parsedValue, String errorMessagePrefix, List<String> resultingValues, Function<String, Collection<RemotableAttributeError>> customValidator) {
343        Collection<RemotableAttributeError> errors = new ArrayList<RemotableAttributeError>(1);
344        String value = parsedValue;
345        if (attributeValue.allowsWildcards()) { // TODO: how should this work in relation to criteria expressions?? clean above removes *
346            value = value.replaceAll(KewApiConstants.SearchableAttributeConstants.SEARCH_WILDCARD_CHARACTER_REGEX_ESCAPED, "");
347        }
348
349        if (resultingValues != null) {
350            resultingValues.add(value);
351        }
352
353        if (!attributeValue.isPassesDefaultValidation(value)) {
354            errorMessagePrefix = (StringUtils.isNotBlank(errorMessagePrefix)) ? errorMessagePrefix : "Field";
355            String errorMsg = errorMessagePrefix + " with value '" + value + "' does not conform to standard validation for field type.";
356            LOG.debug("validateSimpleSearchFieldValue: " + errorMsg + " :: field type '" + attributeValue.getAttributeDataType() + "'");
357            errors.add(RemotableAttributeError.Builder.create(fieldName, errorMsg).build());
358        } else if (customValidator != null) {
359            errors.addAll(customValidator.apply(value));
360        }
361
362        return Collections.unmodifiableCollection(errors);
363    }
364
365    /**
366     * Converts a searchable attribute field data type into a UI data type
367     * @param dataTypeValue the {@link SearchableAttributeValue} data type
368     * @return the corresponding {@link DataType}
369     */
370    public static DataType convertValueToDataType(String dataTypeValue) {
371        if (StringUtils.isBlank(dataTypeValue)) {
372            return DataType.STRING;
373        } else if (KewApiConstants.SearchableAttributeConstants.DATA_TYPE_STRING.equals(dataTypeValue)) {
374            return DataType.STRING;
375        } else if (KewApiConstants.SearchableAttributeConstants.DATA_TYPE_DATE.equals(dataTypeValue)) {
376            return DataType.DATE;
377        } else if (KewApiConstants.SearchableAttributeConstants.DATA_TYPE_LONG.equals(dataTypeValue)) {
378            return DataType.LONG;
379        } else if (KewApiConstants.SearchableAttributeConstants.DATA_TYPE_FLOAT.equals(dataTypeValue)) {
380            return DataType.FLOAT;
381        }
382        throw new IllegalArgumentException("Invalid dataTypeValue was given: " + dataTypeValue);
383    }
384
385}