View Javadoc
1   /**
2    * Copyright 2005-2016 The Kuali Foundation
3    *
4    * Licensed under the Educational Community License, Version 2.0 (the "License");
5    * you may not use this file except in compliance with the License.
6    * You may obtain a copy of the License at
7    *
8    * http://www.opensource.org/licenses/ecl2.php
9    *
10   * Unless required by applicable law or agreed to in writing, software
11   * distributed under the License is distributed on an "AS IS" BASIS,
12   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13   * See the License for the specific language governing permissions and
14   * limitations under the License.
15   */
16  package org.kuali.rice.kew.docsearch.xml;
17  
18  import java.io.BufferedReader;
19  import java.io.StringReader;
20  import java.util.ArrayList;
21  import java.util.Collection;
22  import java.util.Collections;
23  import java.util.LinkedHashMap;
24  import java.util.List;
25  import java.util.Map;
26  import java.util.regex.Matcher;
27  import java.util.regex.Pattern;
28  
29  import javax.xml.parsers.DocumentBuilderFactory;
30  import javax.xml.parsers.ParserConfigurationException;
31  import javax.xml.xpath.XPath;
32  import javax.xml.xpath.XPathConstants;
33  import javax.xml.xpath.XPathExpressionException;
34  
35  import org.apache.commons.lang.StringUtils;
36  import org.kuali.rice.core.api.data.DataType;
37  import org.kuali.rice.core.api.search.Range;
38  import org.kuali.rice.core.api.search.SearchExpressionUtils;
39  import org.kuali.rice.core.api.uif.RemotableAbstractControl;
40  import org.kuali.rice.core.api.uif.RemotableAttributeError;
41  import org.kuali.rice.core.api.uif.RemotableAttributeField;
42  import org.kuali.rice.core.api.uif.RemotableAttributeLookupSettings;
43  import org.kuali.rice.core.api.uif.RemotableDatepicker;
44  import org.kuali.rice.core.api.uif.RemotableHiddenInput;
45  import org.kuali.rice.core.api.uif.RemotableQuickFinder;
46  import org.kuali.rice.core.api.uif.RemotableRadioButtonGroup;
47  import org.kuali.rice.core.api.uif.RemotableSelect;
48  import org.kuali.rice.core.api.uif.RemotableTextInput;
49  import org.kuali.rice.core.api.util.KeyValue;
50  import org.kuali.rice.core.web.format.Formatter;
51  import org.kuali.rice.kew.api.KewApiConstants;
52  import org.kuali.rice.kew.api.WorkflowRuntimeException;
53  import org.kuali.rice.kew.api.document.DocumentWithContent;
54  import org.kuali.rice.kew.api.document.attribute.DocumentAttribute;
55  import org.kuali.rice.kew.api.document.attribute.WorkflowAttributeDefinition;
56  import org.kuali.rice.kew.api.document.search.DocumentSearchCriteria;
57  import org.kuali.rice.kew.api.extension.ExtensionDefinition;
58  import org.kuali.rice.kew.docsearch.CaseAwareSearchableAttributeValue;
59  import org.kuali.rice.kew.docsearch.DocumentSearchInternalUtils;
60  import org.kuali.rice.kew.docsearch.SearchableAttributeValue;
61  import org.kuali.rice.kew.framework.document.attribute.SearchableAttribute;
62  import org.kuali.rice.kew.rule.xmlrouting.XPathHelper;
63  import org.kuali.rice.kim.api.group.Group;
64  import org.kuali.rice.kim.api.group.GroupService;
65  import org.kuali.rice.kim.api.services.KimApiServiceLocator;
66  import org.kuali.rice.kns.lookup.LookupUtils;
67  import org.kuali.rice.krad.UserSession;
68  import org.kuali.rice.krad.util.GlobalVariables;
69  import org.w3c.dom.Document;
70  import org.w3c.dom.Element;
71  import org.w3c.dom.Node;
72  import org.w3c.dom.NodeList;
73  import org.xml.sax.InputSource;
74  
75  import com.google.common.base.Function;
76  
77  
78  /**
79   * Implementation of a {@code SearchableAttribute} whose configuration is driven from XML.
80   *
81   * XML configuration must be supplied in the ExtensionDefinition configuration parameter {@link KewApiConstants#ATTRIBUTE_XML_CONFIG_DATA}.
82   * 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}.
83   * Namely, if an <pre>searchingConfig/xmlSearchContent</pre> element is provided, its content is used as a template.  Otherwise a standard XML template is used.
84   * This template is parameterized with variables of the notation <pre>%name%</pre> which are resolved by <pre>searchingConfig/fieldDef[@name]</pre> definitions.
85   *
86   * The XML content is not validated, but it must be well formed.
87   *
88   * Example 1:
89   * <pre>
90   *     <searchingConfig>
91   *         <fieldDef name="def1" ...other attrs/>
92   *             ... other config
93   *         </fieldDef>
94   *         <fieldDef name="def2" ...other attrs/>
95   *             ... other config
96   *         </fieldDef>
97   *     </searchingConfig>
98   * </pre>
99   * 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  */
135 public 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 }