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