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.xml;
017
018import java.io.BufferedReader;
019import java.io.StringReader;
020import java.util.ArrayList;
021import java.util.Collection;
022import java.util.Collections;
023import java.util.LinkedHashMap;
024import java.util.List;
025import java.util.Map;
026import java.util.regex.Matcher;
027import java.util.regex.Pattern;
028
029import javax.xml.parsers.DocumentBuilderFactory;
030import javax.xml.parsers.ParserConfigurationException;
031import javax.xml.xpath.XPath;
032import javax.xml.xpath.XPathConstants;
033import javax.xml.xpath.XPathExpressionException;
034
035import org.apache.commons.lang.StringUtils;
036import org.kuali.rice.core.api.data.DataType;
037import org.kuali.rice.core.api.search.Range;
038import org.kuali.rice.core.api.search.SearchExpressionUtils;
039import org.kuali.rice.core.api.uif.RemotableAbstractControl;
040import org.kuali.rice.core.api.uif.RemotableAttributeError;
041import org.kuali.rice.core.api.uif.RemotableAttributeField;
042import org.kuali.rice.core.api.uif.RemotableAttributeLookupSettings;
043import org.kuali.rice.core.api.uif.RemotableDatepicker;
044import org.kuali.rice.core.api.uif.RemotableHiddenInput;
045import org.kuali.rice.core.api.uif.RemotableQuickFinder;
046import org.kuali.rice.core.api.uif.RemotableRadioButtonGroup;
047import org.kuali.rice.core.api.uif.RemotableSelect;
048import org.kuali.rice.core.api.uif.RemotableTextInput;
049import org.kuali.rice.core.api.util.KeyValue;
050import org.kuali.rice.core.web.format.Formatter;
051import org.kuali.rice.kew.api.KewApiConstants;
052import org.kuali.rice.kew.api.WorkflowRuntimeException;
053import org.kuali.rice.kew.api.document.DocumentWithContent;
054import org.kuali.rice.kew.api.document.attribute.DocumentAttribute;
055import org.kuali.rice.kew.api.document.attribute.WorkflowAttributeDefinition;
056import org.kuali.rice.kew.api.document.search.DocumentSearchCriteria;
057import org.kuali.rice.kew.api.extension.ExtensionDefinition;
058import org.kuali.rice.kew.docsearch.CaseAwareSearchableAttributeValue;
059import org.kuali.rice.kew.docsearch.DocumentSearchInternalUtils;
060import org.kuali.rice.kew.docsearch.SearchableAttributeValue;
061import org.kuali.rice.kew.framework.document.attribute.SearchableAttribute;
062import org.kuali.rice.kew.rule.xmlrouting.XPathHelper;
063import org.kuali.rice.kim.api.group.Group;
064import org.kuali.rice.kim.api.group.GroupService;
065import org.kuali.rice.kim.api.services.KimApiServiceLocator;
066import org.kuali.rice.kns.lookup.LookupUtils;
067import org.kuali.rice.krad.UserSession;
068import org.kuali.rice.krad.util.GlobalVariables;
069import org.w3c.dom.Document;
070import org.w3c.dom.Element;
071import org.w3c.dom.Node;
072import org.w3c.dom.NodeList;
073import org.xml.sax.InputSource;
074
075import com.google.common.base.Function;
076
077
078/**
079 * Implementation of a {@code SearchableAttribute} whose configuration is driven from XML.
080 *
081 * XML configuration must be supplied in the ExtensionDefinition configuration parameter {@link KewApiConstants#ATTRIBUTE_XML_CONFIG_DATA}.
082 * Parsing of XML search configuration and generation of XML search content proceeds in an analogous fashion to {@link org.kuali.rice.kew.rule.xmlrouting.StandardGenericXMLRuleAttribute}.
083 * Namely, if an <pre>searchingConfig/xmlSearchContent</pre> element is provided, its content is used as a template.  Otherwise a standard XML template is used.
084 * This template is parameterized with variables of the notation <pre>%name%</pre> which are resolved by <pre>searchingConfig/fieldDef[@name]</pre> definitions.
085 *
086 * The XML content is not validated, but it must be well formed.
087 *
088 * Example 1:
089 * <pre>
090 *     <searchingConfig>
091 *         <fieldDef name="def1" ...other attrs/>
092 *             ... other config
093 *         </fieldDef>
094 *         <fieldDef name="def2" ...other attrs/>
095 *             ... other config
096 *         </fieldDef>
097 *     </searchingConfig>
098 * </pre>
099 * Produces, when supplied with the workflow definition parameters: { def1: val1, def2: val2 }:
100 * <pre>
101 *     <xmlRouting>
102 *         <field name="def1"><value>val1</value></field>
103 *         <field name="def2"><value>val2</value></field>
104 *     </xmlRouting>
105 * </pre>
106 *
107 * Example 2:
108 * <pre>
109 *     <searchingConfig>
110 *         <xmlSearchContent>
111 *             <myGeneratedContent>
112 *                 <version>whatever</version>
113 *                 <anythingIWant>Once upon a %def1%...</anythingIWant>
114 *                 <conclusion>Happily ever %def2%.</conclusion>
115 *             </myGeneratedContent>
116 *         </xmlSearchContent>
117 *         <fieldDef name="def1" ...other attrs/>
118 *             ... other config
119 *         </fieldDef>
120 *         <fieldDef name="def2" ...other attrs/>
121 *             ... other config
122 *         </fieldDef>
123 *     </searchingConfig>
124 * </pre>
125 * Produces, when supplied with the workflow definition parameters: { def1: val1, def2: val2 }:
126 * <pre>
127 *     <myGeneratedContent>
128 *         <version>whatever</version>
129 *         <anythingIWant>Once upon a val1...</anythingIWant>
130 *         <conclusion>Happily ever val2.</conclusion>
131 *     </myGeneratedContent>
132 * </pre>
133 * @author Kuali Rice Team (rice.collab@kuali.org)
134 */
135public class StandardGenericXMLSearchableAttribute implements SearchableAttribute {
136
137        private static final org.apache.log4j.Logger LOG = org.apache.log4j.Logger.getLogger(StandardGenericXMLSearchableAttribute.class);
138    private static final String FIELD_DEF_E = "fieldDef";
139    /**
140     * Compile-time option that controls whether we check and return errors for field bounds options that conflict with searchable attribute configuration.
141     */
142    private static final boolean PEDANTIC_BOUNDS_VALIDATION = true;
143
144
145    @Override
146    public String generateSearchContent(ExtensionDefinition extensionDefinition, String documentTypeName, WorkflowAttributeDefinition attributeDefinition) {
147        Map<String, String> propertyDefinitionMap = attributeDefinition.getPropertyDefinitionsAsMap();
148        try {
149            XMLSearchableAttributeContent content = new XMLSearchableAttributeContent(getConfigXML(extensionDefinition));
150            return content.generateSearchContent(propertyDefinitionMap);
151        } catch (XPathExpressionException e) {
152            LOG.error("error in getSearchContent ", e);
153            throw new RuntimeException("Error trying to find xml content with xpath expression", e);
154        } catch (Exception e) {
155            LOG.error("error in getSearchContent attempting to find xml search content", e);
156            throw new RuntimeException("Error trying to get xml search content.", e);
157        }
158    }
159
160    @Override
161    public List<DocumentAttribute> extractDocumentAttributes(ExtensionDefinition extensionDefinition, DocumentWithContent documentWithContent) {
162        List<DocumentAttribute> searchStorageValues = new ArrayList<DocumentAttribute>();
163        String fullDocumentContent = documentWithContent.getDocumentContent().getFullContent();
164        if (StringUtils.isBlank(documentWithContent.getDocumentContent().getFullContent())) {
165            LOG.warn("Empty Document Content found for document id: " + documentWithContent.getDocument().getDocumentId());
166            return searchStorageValues;
167        }
168        Document document;
169        try {
170            document = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(new InputSource(new BufferedReader(new StringReader(fullDocumentContent))));
171        } catch (Exception e){
172            LOG.error("error parsing docContent: "+documentWithContent.getDocumentContent(), e);
173            throw new RuntimeException("Error trying to parse docContent: "+documentWithContent.getDocumentContent(), e);
174        }
175        XMLSearchableAttributeContent content = new XMLSearchableAttributeContent(getConfigXML(extensionDefinition));
176        List<XMLSearchableAttributeContent.FieldDef> fields;
177        try {
178            fields = content.getFieldDefList();
179        } catch (XPathExpressionException xpee) {
180            throw new RuntimeException("Error parsing searchable attribute content", xpee);
181        } catch (ParserConfigurationException pce) {
182            throw new RuntimeException("Error parsing searchable attribute content", pce);
183        }
184        XPath xpath = XPathHelper.newXPath(document);
185        for (XMLSearchableAttributeContent.FieldDef field: fields) {
186            if (StringUtils.isNotEmpty(field.fieldEvaluationExpr)) {
187                List<String> values = new ArrayList<String>();
188                try {
189                    LOG.debug("Trying to retrieve node set with expression: '" + field.fieldEvaluationExpr + "'.");
190                    NodeList searchValues = (NodeList) xpath.evaluate(field.fieldEvaluationExpr, document.getDocumentElement(), XPathConstants.NODESET);
191                    // being that this is the standard xml attribute we will return the key with an empty value
192                    // so we can find it from a doc search using this key
193                    for (int j = 0; j < searchValues.getLength(); j++) {
194                        Node searchValue = searchValues.item(j);
195                        if (searchValue.getFirstChild() != null && (StringUtils.isNotEmpty(searchValue.getFirstChild().getNodeValue()))) {
196                            values.add(searchValue.getFirstChild().getNodeValue());
197                        }
198                    }
199                } catch (XPathExpressionException e) {
200                    LOG.debug("Could not retrieve node set with expression: '" + field.fieldEvaluationExpr + "'. Trying string return type.");
201                    //try for a string being returned from the expression.  This
202                    //seems like a poor way to determine our expression return type but
203                    //it's all I can come up with at the moment.
204                    try {
205                        String searchValue = (String) xpath.evaluate(field.fieldEvaluationExpr, document.getDocumentElement(), XPathConstants.STRING);
206                        if (StringUtils.isNotBlank(searchValue)) {
207                            values.add(searchValue);
208                        }
209                    } catch (XPathExpressionException xpee) {
210                        LOG.error("Error retrieving string with expression: '" + field.fieldEvaluationExpr + "'", xpee);
211                        throw new RuntimeException("Error retrieving string with expression: '" + field.fieldEvaluationExpr + "'", xpee);
212                    }
213                }
214
215                // remove any nulls
216                values.removeAll(Collections.singleton(null));
217                // being that this is the standard xml attribute we will return the key with an empty value
218                // so we can find it from a doc search using this key
219                if (values.isEmpty()) {
220                    values.add(null);
221                }
222                for (String value: values) {
223                    DocumentAttribute searchableValue = this.setupSearchableAttributeValue(field.searchDefinition.dataType, field.name, value);
224                    if (searchableValue != null) {
225                        searchStorageValues.add(searchableValue);
226                    }
227                }
228            }
229        }
230        return searchStorageValues;
231    }
232
233    private DocumentAttribute setupSearchableAttributeValue(String dataType, String key, String value) {
234        SearchableAttributeValue attValue = DocumentSearchInternalUtils.getSearchableAttributeValueByDataTypeString(dataType);
235        if (attValue == null) {
236            String errorMsg = "Cannot find a SearchableAttributeValue associated with the data type '" + dataType + "'";
237            LOG.error("setupSearchableAttributeValue() " + errorMsg);
238            throw new RuntimeException(errorMsg);
239        }
240        value = (value != null) ? value.trim() : null;
241        if ( (StringUtils.isNotBlank(value)) && (!attValue.isPassesDefaultValidation(value)) ) {
242            String errorMsg = "SearchableAttributeValue with the data type '" + dataType + "', key '" + key + "', and value '" + value + "' does not pass default validation and cannot be saved to the database";
243            LOG.error("setupSearchableAttributeValue() " + errorMsg);
244            throw new RuntimeException(errorMsg);
245        }
246        attValue.setSearchableAttributeKey(key);
247        attValue.setupAttributeValue(value);
248        return attValue.toDocumentAttribute();
249    }
250
251    @Override
252    public List<RemotableAttributeField> getSearchFields(ExtensionDefinition extensionDefinition, String documentTypeName) {
253        List<RemotableAttributeField> searchFields = new ArrayList<RemotableAttributeField>();
254        List<SearchableAttributeValue> searchableAttributeValues = DocumentSearchInternalUtils.getSearchableAttributeValueObjectTypes();
255
256        XMLSearchableAttributeContent content = new XMLSearchableAttributeContent(getConfigXML(extensionDefinition));
257        List<XMLSearchableAttributeContent.FieldDef> fields;
258        try {
259            fields = content.getFieldDefList();
260        } catch (XPathExpressionException xpee) {
261            throw new RuntimeException("Error parsing searchable attribute configuration", xpee);
262        } catch (ParserConfigurationException pce) {
263            throw new RuntimeException("Error parsing searchable attribute configuration", pce);
264        }
265        for (XMLSearchableAttributeContent.FieldDef field: fields) {
266            searchFields.add(convertFieldDef(field, searchableAttributeValues));
267        }
268
269        return searchFields;
270    }
271
272    /**
273     * Converts a searchable attribute FieldDef to a RemotableAttributeField
274     */
275    private RemotableAttributeField convertFieldDef(XMLSearchableAttributeContent.FieldDef field, Collection<SearchableAttributeValue> searchableAttributeValues) {
276        RemotableAttributeField.Builder fieldBuilder = RemotableAttributeField.Builder.create(field.name);
277
278        fieldBuilder.setLongLabel(field.title);
279
280        RemotableAttributeLookupSettings.Builder attributeLookupSettings = RemotableAttributeLookupSettings.Builder.create();
281        fieldBuilder.setAttributeLookupSettings(attributeLookupSettings);
282
283        // value
284        if (field.defaultValue != null) {
285            fieldBuilder.setDefaultValues(Collections.singletonList(field.defaultValue));
286        }
287
288        // Visibility
289        applyVisibility(fieldBuilder, attributeLookupSettings, field);
290
291        // Display
292        RemotableAbstractControl.Builder controlBuilder = constructControl(field.display.type, field.display.options);
293        fieldBuilder.setControl(controlBuilder);
294        if ("date".equals(field.display.type)) {
295            fieldBuilder.getWidgets().add(RemotableDatepicker.Builder.create());
296            fieldBuilder.setDataType(DataType.DATE);
297        }
298        if (!field.display.selectedOptions.isEmpty()) {
299            fieldBuilder.setDefaultValues(field.display.selectedOptions);
300        }
301
302        // resultcolumn
303        attributeLookupSettings.setInResults(field.isDisplayedInSearchResults());
304
305        // SearchDefinition
306        // data type operations
307        DataType dataType = DocumentSearchInternalUtils.convertValueToDataType(field.searchDefinition.dataType);
308        fieldBuilder.setDataType(dataType);
309        if (DataType.DATE == fieldBuilder.getDataType()) {
310            fieldBuilder.getWidgets().add(RemotableDatepicker.Builder.create());
311        }
312
313        boolean isRangeSearchField = isRangeSearchField(searchableAttributeValues, fieldBuilder.getDataType(), field);
314        if (isRangeSearchField) {
315            attributeLookupSettings.setRanged(true);
316            // we've established the search is ranged, so we can inspect the bounds
317            attributeLookupSettings.setLowerBoundInclusive(field.searchDefinition.lowerBound.inclusive);
318            attributeLookupSettings.setUpperBoundInclusive(field.searchDefinition.upperBound.inclusive);
319            attributeLookupSettings.setLowerLabel(field.searchDefinition.lowerBound.label);
320            attributeLookupSettings.setUpperLabel(field.searchDefinition.upperBound.label);
321            attributeLookupSettings.setLowerDatePicker(field.searchDefinition.lowerBound.datePicker);
322            attributeLookupSettings.setUpperDatePicker(field.searchDefinition.upperBound.datePicker);
323        }
324
325        Boolean caseSensitive = field.searchDefinition.getRangeBoundOptions().caseSensitive;
326        if (caseSensitive != null) {
327            attributeLookupSettings.setCaseSensitive(caseSensitive);
328        }
329
330        /**
331
332
333
334         String formatterClass = (searchDefAttributes.getNamedItem("formatterClass") == null) ? null : searchDefAttributes.getNamedItem("formatterClass").getNodeValue();
335         if (!StringUtils.isEmpty(formatterClass)) {
336         try {
337         myField.setFormatter((Formatter)Class.forName(formatterClass).newInstance());
338         } catch (InstantiationException e) {
339         LOG.error("Unable to get new instance of formatter class: " + formatterClass);
340         throw new RuntimeException("Unable to get new instance of formatter class: " + formatterClass);
341         }
342         catch (IllegalAccessException e) {
343         LOG.error("Unable to get new instance of formatter class: " + formatterClass);
344         throw new RuntimeException("Unable to get new instance of formatter class: " + formatterClass);
345         } catch (ClassNotFoundException e) {
346         LOG.error("Unable to find formatter class: " + formatterClass);
347         throw new RuntimeException("Unable to find formatter class: " + formatterClass);
348         }
349         }
350
351         */
352
353         String formatter = field.display.formatter == null ? null : field.display.formatter;
354         fieldBuilder.setFormatterName(formatter);
355
356        try {
357        // Register this formatter so that you can use it later in FieldUtils when processing
358            if(StringUtils.isNotEmpty(formatter)){
359                Formatter.registerFormatter(Class.forName(formatter), Class.forName(formatter));
360            }
361        } catch (ClassNotFoundException e) {
362         LOG.error("Unable to find formatter class: " + formatter);
363         throw new RuntimeException("Unable to find formatter class: " + formatter);
364         }
365
366
367        // Lookup
368        // XMLAttributeUtils.establishFieldLookup(fieldBuilder, childNode); // this code can probably die now that parsing has moved out to xmlsearchableattribcontent
369        if (field.lookup.dataObjectClass != null) {
370            RemotableQuickFinder.Builder quickFinderBuilder = RemotableQuickFinder.Builder.create(LookupUtils.getBaseLookupUrl(false), field.lookup.dataObjectClass);
371            quickFinderBuilder.setFieldConversions(field.lookup.fieldConversions);
372            fieldBuilder.getWidgets().add(quickFinderBuilder);
373        }
374
375        return fieldBuilder.build();
376    }
377
378
379    /**
380     * Determines whether the searchable field definition is a ranged search
381     * @param searchableAttributeValues the possible system {@link SearchableAttributeValue}s
382     * @param dataType the UI data type
383     * @return
384     */
385    private boolean isRangeSearchField(Collection<SearchableAttributeValue> searchableAttributeValues, DataType dataType, XMLSearchableAttributeContent.FieldDef field) {
386        for (SearchableAttributeValue attValue : searchableAttributeValues)
387        {
388            DataType attributeValueDataType = DocumentSearchInternalUtils.convertValueToDataType(attValue.getAttributeDataType());
389            if (attributeValueDataType == dataType) {
390                return isRangeSearchField(attValue, field);
391            }
392        }
393        String errorMsg = "Could not find searchable attribute value for data type '" + dataType + "'";
394        LOG.error("isRangeSearchField(List, String, NamedNodeMap, Node) " + errorMsg);
395        throw new WorkflowRuntimeException(errorMsg);
396    }
397
398    private boolean isRangeSearchField(SearchableAttributeValue searchableAttributeValue, XMLSearchableAttributeContent.FieldDef field) {
399        // this is a ranged search if
400        // 1) attribute value type allows ranged search
401        boolean allowRangedSearch = searchableAttributeValue.allowsRangeSearches();
402        // AND
403        // 2) the searchDefinition specifies a ranged search
404        return allowRangedSearch && field.searchDefinition.isRangedSearch();
405    }
406
407    /**
408     * Applies visibility settings to the RemotableAttributeField
409     */
410    private void applyVisibility(RemotableAttributeField.Builder fieldBuilder, RemotableAttributeLookupSettings.Builder attributeLookupSettings, XMLSearchableAttributeContent.FieldDef field) {
411        boolean visible = true;
412        // if visibility is explicitly set, use it
413        if (field.visibility.visible != null) {
414            visible = field.visibility.visible;
415        } else {
416            if (field.visibility.groupName != null) {
417                UserSession session = GlobalVariables.getUserSession();
418                if (session == null) {
419                    throw new WorkflowRuntimeException("UserSession is null!  Attempted to render the searchable attribute outside of an established session.");
420                }
421                GroupService groupService = KimApiServiceLocator.getGroupService();
422
423                Group group = groupService.getGroupByNamespaceCodeAndName(field.visibility.groupNamespace, field.visibility.groupName);
424                visible =  group == null ? false : groupService.isMemberOfGroup(session.getPerson().getPrincipalId(), group.getId());
425            }
426        }
427        String type = field.visibility.type;
428        if ("field".equals(type) || "fieldAndColumn".equals(type)) {
429            // if it's not visible, coerce this field to a hidden type
430            if (!visible) {
431                fieldBuilder.setControl(RemotableHiddenInput.Builder.create());
432            }
433        }
434        if ("column".equals(type) || "fieldAndColumn".equals(type)) {
435            attributeLookupSettings.setInResults(visible);
436        }
437    }
438
439    private RemotableAbstractControl.Builder constructControl(String type, Collection<KeyValue> options) {
440        RemotableAbstractControl.Builder control = null;
441        Map<String, String> optionMap = new LinkedHashMap<String, String>();
442        for (KeyValue option : options) {
443            optionMap.put(option.getKey(), option.getValue());
444        }
445        if ("text".equals(type) || "date".equals(type)) {
446            control = RemotableTextInput.Builder.create();
447        } else if ("select".equals(type)) {
448            control = RemotableSelect.Builder.create(optionMap);
449        } else if ("radio".equals(type)) {
450            control = RemotableRadioButtonGroup.Builder.create(optionMap);
451        } else if ("hidden".equals(type)) {
452            control = RemotableHiddenInput.Builder.create();
453        } else if ("multibox".equals(type)) {
454            RemotableSelect.Builder builder = RemotableSelect.Builder.create(optionMap);
455            builder.setMultiple(true);
456            control = builder;
457        } else {
458            throw new IllegalArgumentException("Illegal field type found: " + type);
459        }
460        return control;
461    }
462
463    @Override
464    public List<RemotableAttributeError> validateDocumentAttributeCriteria(ExtensionDefinition extensionDefinition, DocumentSearchCriteria documentSearchCriteria) {
465                List<RemotableAttributeError> errors = new ArrayList<RemotableAttributeError>();
466        
467        Map<String, List<String>> documentAttributeValues = documentSearchCriteria.getDocumentAttributeValues();
468        if (documentAttributeValues == null || documentAttributeValues.isEmpty()) {
469            // nothing to validate...
470            return errors;
471        }
472
473        XMLSearchableAttributeContent content = new XMLSearchableAttributeContent(getConfigXML(extensionDefinition));
474        List<XMLSearchableAttributeContent.FieldDef> fields;
475        try {
476            fields = content.getFieldDefList();
477        } catch (XPathExpressionException xpee) {
478            throw new RuntimeException("Error parsing searchable attribute configuration", xpee);
479        } catch (ParserConfigurationException pce) {
480            throw new RuntimeException("Error parsing searchable attribute configuration", pce);
481        }
482        if (fields.isEmpty()) {
483            LOG.warn("Could not find any field definitions (<" + FIELD_DEF_E + ">) or possibly a searching configuration (<searchingConfig>) for this XMLSearchAttribute");
484            return errors;
485        }
486
487        for (XMLSearchableAttributeContent.FieldDef field: fields) {
488            String fieldDefName = field.name;
489            String fieldDefTitle = field.title == null ? "" : field.title;
490
491            List<String> testObject = documentAttributeValues.get(fieldDefName);
492
493            if (testObject == null || testObject.isEmpty()) {
494                // no value to validate
495                // not checking for 'required' here since this is *search* criteria, and required field can be omitted
496                continue;
497            }
498
499            // What type of value is this searchable attribute field?
500            // get the searchable attribute value by using the data type
501            SearchableAttributeValue attributeValue = DocumentSearchInternalUtils.getSearchableAttributeValueByDataTypeString(field.searchDefinition.dataType);
502            if (attributeValue == null) {
503                String errorMsg = "Cannot find SearchableAttributeValue for field data type '" + field.searchDefinition.dataType + "'";
504                LOG.error("validateUserSearchInputs() " + errorMsg);
505                throw new RuntimeException(errorMsg);
506            }
507
508            // 1) parse concrete values from possible range expressions
509            // 2) validate any resulting concrete values whether they were original arguments or parsed from range expressions
510            // 3) if the expression was a range expression, validate the logical validity of the range bounds
511
512            List<String> terminalValues = new ArrayList<String>();
513            List<Range> rangeValues = new ArrayList<Range>();
514
515            // we are assuming here that the only expressions evaluated against searchable attributes are simple
516            // non-compound expressions.  parsing compound expressions would require full grammar/parsing support
517            // and would probably be pretty absurd assuming these queries are coming from UIs.
518            // If they are not coming from the UI, do we need to support compound expressions?
519            for (String value: testObject) {
520                // is this a terminal value or does it look like a range?
521                if (value == null) {
522                    // assuming null values are not an error condition
523                    continue;
524                }
525                // this is just a war of attrition, need real parsing
526                String[] clauses = SearchExpressionUtils.splitOnClauses(value);
527                for (String clause: clauses) {
528                    // if it's not empty. see if it's a range
529                    Range r = null;
530                    if (StringUtils.isNotEmpty(value)) {
531                        r = SearchExpressionUtils.parseRange(value);
532                    }
533                    if (r != null) {
534                        // hey, it looks like a range
535                        boolean errs = false;
536                        if (!field.searchDefinition.isRangedSearch()) {
537                            errs = true;
538                            errors.add(RemotableAttributeError.Builder.create(field.name, "field does not support ranged searches but range search expression detected").build());
539                        } else {
540                            // only check bounds if range search is specified
541                            // XXX: FIXME: disabling these pedantic checks as they are causing annoying test breakages
542                            if (PEDANTIC_BOUNDS_VALIDATION) {
543                                // this is not actually an error. just disregard case-sensitivity for data types that don't support it
544                                /*if (!attributeValue.allowsCaseInsensitivity() && Boolean.FALSE.equals(field.searchDefinition.getRangeBoundOptions().caseSensitive)) {
545                                    errs = true;
546                                    errors.add(RemotableAttributeError.Builder.create(field.name, "attribute data type does not support case insensitivity but case-insensitivity specified in attribute definition").build());
547                                }*/
548                                if (r.getLowerBoundValue() != null && r.isLowerBoundInclusive() != field.searchDefinition.lowerBound.inclusive) {
549                                    errs = true;
550                                    errors.add(RemotableAttributeError.Builder.create(field.name, "range expression ('" + value + "') and attribute definition differ on lower bound inclusivity.  Range is: " + r.isLowerBoundInclusive() + " Attrib is: " + field.searchDefinition.lowerBound.inclusive).build());
551                                }
552                                if (r.getUpperBoundValue() != null && r.isUpperBoundInclusive() != field.searchDefinition.upperBound.inclusive) {
553                                    errs = true;
554                                    errors.add(RemotableAttributeError.Builder.create(field.name, "range expression ('" + value + "') and attribute definition differ on upper bound inclusivity.  Range is: " + r.isUpperBoundInclusive() + " Attrib is: " + field.searchDefinition.upperBound.inclusive).build());
555                                }
556                            }
557                        }
558
559                        if (!errs) {
560                            rangeValues.add(r);
561                        }
562                    } else {
563                        terminalValues.add(value);
564                    }
565                }
566            }
567
568            List<String> parsedValues = new ArrayList<String>();
569            // validate all values
570            for (String value: terminalValues) {
571                errors.addAll(performValidation(attributeValue, field, value, fieldDefTitle, parsedValues));
572            }
573            for (Range range: rangeValues) {
574                List<String> parsedLowerValues = new ArrayList<String>();
575                List<String> parsedUpperValues = new ArrayList<String>();
576                List<RemotableAttributeError> lowerErrors = performValidation(attributeValue, field,
577                        range.getLowerBoundValue(), constructRangeFieldErrorPrefix(field.title,
578                        field.searchDefinition.lowerBound), parsedLowerValues);
579                errors.addAll(lowerErrors);
580                List<RemotableAttributeError> upperErrors = performValidation(attributeValue, field, range.getUpperBoundValue(),
581                        constructRangeFieldErrorPrefix(field.title, field.searchDefinition.upperBound), parsedUpperValues);
582                errors.addAll(upperErrors);
583
584                // if both values check out, perform logical range validation
585                if (lowerErrors.isEmpty() && upperErrors.isEmpty()) {
586                    // TODO: how to handle multiple values?? doesn't really make sense
587                    String lowerBoundValue = parsedLowerValues.isEmpty() ? null : parsedLowerValues.get(0);
588                    String upperBoundValue = parsedUpperValues.isEmpty() ? null : parsedUpperValues.get(0);
589
590                    final Boolean rangeValid;
591                    // for the sake of string searches, make sure the bounds are uppercased before comparison if the search
592                    // is case sensitive.
593                    if (KewApiConstants.SearchableAttributeConstants.DATA_TYPE_STRING.equals(field.searchDefinition.dataType)) {
594                        boolean caseSensitive = field.searchDefinition.getRangeBoundOptions().caseSensitive == null ? true : field.searchDefinition.getRangeBoundOptions().caseSensitive;
595                        rangeValid = ((CaseAwareSearchableAttributeValue) attributeValue).isRangeValid(lowerBoundValue, upperBoundValue, caseSensitive);
596                    } else {
597                        rangeValid = attributeValue.isRangeValid(lowerBoundValue, upperBoundValue);
598                    }
599
600                    if (rangeValid != null && !rangeValid) {
601                        String errorMsg = "The " + fieldDefTitle + " range is incorrect.  The " +
602                                (StringUtils.isNotBlank(field.searchDefinition.lowerBound.label) ? field.searchDefinition.lowerBound.label : KewApiConstants.SearchableAttributeConstants.DEFAULT_RANGE_SEARCH_LOWER_BOUND_LABEL)
603                                + " value entered must come before the " +
604                                (StringUtils.isNotBlank(field.searchDefinition.upperBound.label) ? field.searchDefinition.upperBound.label : KewApiConstants.SearchableAttributeConstants.DEFAULT_RANGE_SEARCH_UPPER_BOUND_LABEL)
605                                + " value";
606                        LOG.debug("validateUserSearchInputs() " + errorMsg + " :: field type '" + attributeValue.getAttributeDataType() + "'");
607                        errors.add(RemotableAttributeError.Builder.create(fieldDefName, errorMsg).build());
608                    }
609                }
610            }
611       }
612        return errors;
613    }
614
615    private String constructRangeFieldErrorPrefix(String fieldDefLabel, XMLSearchableAttributeContent.FieldDef.SearchDefinition.RangeBound rangeBound) {
616        if ( StringUtils.isNotBlank(rangeBound.label) && StringUtils.isNotBlank(fieldDefLabel)) {
617            return fieldDefLabel + " " + rangeBound.label + " Field";
618        } else if (StringUtils.isNotBlank(fieldDefLabel)) {
619            return fieldDefLabel + " Range Field";
620        } else if (StringUtils.isNotBlank(rangeBound.label)) {
621            return "Range Field " + rangeBound.label + " Field";
622        }
623        return null;
624    }
625
626    /**
627     * Performs validation on a single DSC attribute value, running any defined custom validation regex after basic validation
628     * @param attributeValue the searchable attribute value type
629     * @param field the XMLSearchableAttributeContent field
630     * @param enteredValue the value to validate
631     * @param errorMessagePrefix a prefix for error messages
632     * @param resultingValues optional list of accumulated parsed values
633     * @return a (possibly empty) list of errors
634     */
635    private List<RemotableAttributeError> performValidation(SearchableAttributeValue attributeValue, final XMLSearchableAttributeContent.FieldDef field, String enteredValue, String errorMessagePrefix, List<String> resultingValues) {
636        return DocumentSearchInternalUtils.validateSearchFieldValue(field.name, attributeValue, enteredValue, errorMessagePrefix, resultingValues, new Function<String, Collection<RemotableAttributeError>>() {
637            @Override
638            public Collection<RemotableAttributeError> apply(String value) {
639                if (StringUtils.isNotEmpty(field.validation.regex)) {
640                    Pattern pattern = Pattern.compile(field.validation.regex);
641                    Matcher matcher = pattern.matcher(value);
642                    if (!matcher.matches()) {
643                        return Collections.singletonList(RemotableAttributeError.Builder.create(field.name, field.validation.message).build());
644                    }
645                }
646                return Collections.emptyList();
647            }
648        });
649    }
650
651    // preserved only for subclasses
652    protected Element getConfigXML(ExtensionDefinition extensionDefinition) {
653        try {
654            String xmlConfigData = extensionDefinition.getConfiguration().get(KewApiConstants.ATTRIBUTE_XML_CONFIG_DATA);
655            return DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(new InputSource(new BufferedReader(new StringReader(xmlConfigData)))).getDocumentElement();
656        } catch (Exception e) {
657            String ruleAttrStr = (extensionDefinition == null ? null : extensionDefinition.getName());
658            LOG.error("error parsing xml data from search attribute: " + ruleAttrStr, e);
659            throw new RuntimeException("error parsing xml data from searchable attribute: " + ruleAttrStr, e);
660        }
661    }
662}