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