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