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