View Javadoc

1   /**
2    * Copyright 2005-2012 The Kuali Foundation
3    *
4    * Licensed under the Educational Community License, Version 2.0 (the "License");
5    * you may not use this file except in compliance with the License.
6    * You may obtain a copy of the License at
7    *
8    * http://www.opensource.org/licenses/ecl2.php
9    *
10   * Unless required by applicable law or agreed to in writing, software
11   * distributed under the License is distributed on an "AS IS" BASIS,
12   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13   * See the License for the specific language governing permissions and
14   * limitations under the License.
15   */
16  package org.kuali.rice.kew.docsearch.xml;
17  
18  import org.apache.commons.lang.StringUtils;
19  import org.apache.log4j.Logger;
20  import org.kuali.rice.core.api.config.ConfigurationException;
21  import org.kuali.rice.core.api.impex.xml.XmlConstants;
22  import org.kuali.rice.core.api.util.ConcreteKeyValue;
23  import org.kuali.rice.core.api.util.KeyValue;
24  import org.kuali.rice.core.api.util.xml.XmlHelper;
25  import org.kuali.rice.core.api.util.xml.XmlJotter;
26  import org.kuali.rice.kew.api.KewApiConstants;
27  import org.kuali.rice.kew.api.extension.ExtensionDefinition;
28  import org.kuali.rice.kew.rule.xmlrouting.XPathHelper;
29  import org.kuali.rice.kew.util.Utilities;
30  import org.w3c.dom.Attr;
31  import org.w3c.dom.Element;
32  import org.w3c.dom.NamedNodeMap;
33  import org.w3c.dom.Node;
34  import org.w3c.dom.NodeList;
35  import org.xml.sax.InputSource;
36  
37  import javax.xml.parsers.DocumentBuilderFactory;
38  import javax.xml.parsers.ParserConfigurationException;
39  import javax.xml.transform.TransformerException;
40  import javax.xml.xpath.XPath;
41  import javax.xml.xpath.XPathConstants;
42  import javax.xml.xpath.XPathExpressionException;
43  import java.io.BufferedReader;
44  import java.io.StringReader;
45  import java.util.ArrayList;
46  import java.util.Collection;
47  import java.util.Collections;
48  import java.util.HashMap;
49  import java.util.LinkedHashMap;
50  import java.util.List;
51  import java.util.Map;
52  
53  /**
54   * Immutable object that encapsulates the XML searchable attribute content
55   */
56  class XMLSearchableAttributeContent {
57      private static final Logger LOG = Logger.getLogger(XMLSearchableAttributeContent.class);
58  
59      private ExtensionDefinition def;
60      private Element attributeConfig;
61      private Node searchingConfig;
62      private String searchContent;
63      private Map<String, FieldDef> fieldDefs;
64  
65      XMLSearchableAttributeContent(ExtensionDefinition ed) {
66          this.def = ed;
67      }
68  
69      XMLSearchableAttributeContent(String configXML) throws TransformerException {
70          this.attributeConfig = XmlHelper.readXml(configXML).getDocumentElement();
71      }
72  
73      XMLSearchableAttributeContent(Element configXML) {
74          if (configXML == null) {
75              throw new IllegalArgumentException("Configuration element must not be nil");
76          }
77          this.attributeConfig = configXML;
78      }
79  
80      Node getSearchingConfig() throws XPathExpressionException, ParserConfigurationException {
81          if (searchingConfig == null) {
82              XPath xpath = XPathHelper.newXPath();
83              // technically this should probably only be "searchingConfig", and not search the whole tree
84              String searchingConfigExpr = "//searchingConfig";
85              searchingConfig = (Node) xpath.evaluate(searchingConfigExpr, getAttributeConfig(), XPathConstants.NODE);
86          }
87          return searchingConfig;
88      }
89  
90      String getSearchContent() throws XPathExpressionException, ParserConfigurationException {
91          if (searchContent == null) {
92              Node cfg = getSearchingConfig();
93              XPath xpath = XPathHelper.newXPath();
94              Node n = (Node) xpath.evaluate("xmlSearchContent", cfg, XPathConstants.NODE);
95              if (n != null) {
96                  StringBuilder sb = new StringBuilder();
97                  NodeList list = n.getChildNodes();
98                  for (int i = 0; i < list.getLength(); i++) {
99                      sb.append(XmlJotter.jotNode(list.item(i)));
100                 }
101                 this.searchContent = sb.toString();
102             }
103         }
104         return searchContent;
105     }
106 
107     String generateSearchContent(Map<String, String> properties) throws XPathExpressionException, ParserConfigurationException {
108         if (properties == null) {
109             properties = new HashMap<String, String>();
110         }
111         // implementation quirk: if no fields were present, empty search content was returned
112         List<FieldDef> fields = getFieldDefList();
113         if (fields.size() == 0) {
114             return "";
115         }
116 
117         String searchContent = getSearchContent();
118 
119         // custom search content template is provided, evaluate it
120         if (searchContent != null) {
121             String generatedContent = searchContent;
122             // if properties have been passed in, perform string replacement
123             // NOTE: should default field <value>s also be used for substitution in addition to given properties?
124             // Implementation note: if we want to be 100% backwards compatible we can't simply use a global StrSubstitutor
125             // to replace all variables.  The implementation actually only replaces variables that are names of
126             // *defined fields*; that means properties for fields which are not present on the attribute are NOT replaced.
127             for (FieldDef field: fields) {
128                 if (StringUtils.isNotBlank(field.name)) {
129                     String propValue = properties.get(field.name);
130                     if (StringUtils.isNotBlank(propValue)) {
131                         generatedContent = generatedContent.replaceAll("%" + field.name + "%", propValue);
132                     }
133                 }
134             }
135             return generatedContent;
136         } else { // use a default format
137             // Standard doc content if no doc content is found in the searchingConfig xml.
138             StringBuilder buf = new StringBuilder("<xmlRouting>");
139             for (FieldDef field: fields) {
140                 if (StringUtils.isNotBlank(field.name)) {
141                     String propValue = properties.get(field.name);
142                     if (StringUtils.isNotBlank(propValue)) {
143                         buf.append("<field name=\"");
144                         buf.append(field.name);
145                         buf.append("\"><value>");
146                         buf.append(propValue);
147                         buf.append("</value></field>");
148                     }
149                 }
150             }
151             buf.append("</xmlRouting>");
152             return buf.toString();
153         }
154     }
155 
156     /**
157      * Returns a non-null but possibly empty list of FieldDefs
158      * @return
159      * @throws XPathExpressionException
160      * @throws ParserConfigurationException
161      */
162     List<FieldDef> getFieldDefList() throws XPathExpressionException, ParserConfigurationException {
163         return Collections.unmodifiableList(new ArrayList<FieldDef>(getFieldDefs().values()));
164     }
165 
166     Map<String, FieldDef> getFieldDefs() throws XPathExpressionException, ParserConfigurationException {
167         if (fieldDefs == null) {
168             fieldDefs = new LinkedHashMap<String, FieldDef>();
169             XPath xpath = XPathHelper.newXPath();
170             Node searchingConfig = getSearchingConfig();
171             if (searchingConfig != null) {
172                 NodeList list = (NodeList) xpath.evaluate("fieldDef", searchingConfig, XPathConstants.NODESET);
173                 for (int i = 0; i < list.getLength(); i++) {
174                     FieldDef def = new FieldDef(list.item(i));
175                     fieldDefs.put(def.name, def);
176                 }
177             }
178         }
179         return fieldDefs;
180     }
181 
182     protected Element getAttributeConfig() {
183         if (attributeConfig == null) {
184             try {
185                 String xmlConfigData = def.getConfiguration().get(KewApiConstants.ATTRIBUTE_XML_CONFIG_DATA);
186                 this.attributeConfig = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(new InputSource(new BufferedReader(new StringReader(xmlConfigData)))).getDocumentElement();
187             } catch (Exception e) {
188                 String ruleAttrStr = (def == null ? null : def.getName());
189                 LOG.error("error parsing xml data from search attribute: " + ruleAttrStr, e);
190                 throw new RuntimeException("error parsing xml data from searchable attribute: " + ruleAttrStr, e);
191             }
192         }
193         return attributeConfig;
194     }
195 
196     /**
197      * Encapsulates a field definition
198      */
199     static class FieldDef {
200         final String name;
201         final String title;
202         final String defaultValue;
203         final Display display;
204         final Validation validation;
205         final Visibility visibility;
206         final SearchDefinition searchDefinition;
207         final String fieldEvaluationExpr;
208         final Boolean showResultColumn;
209         final Lookup lookup;
210 
211         FieldDef(Node n) throws XPathExpressionException {
212             XPath xpath = XPathHelper.newXPath();
213             this.name = getStringAttr(n, "name");
214             this.title= getStringAttr(n, "title");
215             this.defaultValue = getNodeText(xpath, n, "value");
216             this.fieldEvaluationExpr = getNodeText(xpath, n, "fieldEvaluation/xpathexpression");
217             this.showResultColumn = getBoolean(xpath, n, "resultColumn/@show");
218             // TODO: it might be better to invert responsibility here
219             // so we can assign null values for missing entries (at least those we don't expect defaults for)
220             this.display = new Display(xpath, n);
221             this.validation = new Validation(xpath, n);
222             this.visibility = new Visibility(xpath, n);
223             this.searchDefinition = new SearchDefinition(xpath, n);
224             this.lookup = new Lookup(xpath, n, name);
225         }
226 
227         /**
228          * Returns whether this field should be displayed in search results.  If 'resultColumn/@show' is explicitly defined
229          * this value will be used, otherwise it will defer to the field criteria visibility, defaulting to 'true' if unset
230          * @return
231          */
232         boolean isDisplayedInSearchResults() {
233             return showResultColumn != null ? showResultColumn : (visibility.visible != null ? visibility.visible : true);
234         }
235 
236         /**
237          * Encapsulates display definition
238          */
239         static class Display {
240             final String type;
241             final String meta;
242             final String formatter;
243             final Collection<KeyValue> options;
244             final Collection<String> selectedOptions;
245 
246             Display(XPath xpath, Node n) throws XPathExpressionException {
247                 type = getNodeText(xpath, n, "display/type");
248                 meta = getNodeText(xpath, n, "display/meta");
249                 formatter = getNodeText(xpath, n, "display/formatter");
250                 Collection<KeyValue> options = new ArrayList<KeyValue>();
251                 Collection<String> selectedOptions = new ArrayList<String>();
252                 
253                 NodeList nodes = (NodeList) xpath.evaluate("display[1]/values", n, XPathConstants.NODESET);
254                 for (int i = 0; i < nodes.getLength(); i++) {
255                     Node node = nodes.item(i);
256                     boolean selected = getBooleanAttr(node, "selected", false);
257                     String title = getStringAttr(node, "title");
258                     // TODO: test this - intent is that value without text content results in blank entry?
259                     // this is to allow an empty drop down choice and can probably implemented in a better way
260                     String value = node.getTextContent();
261                     if (value == null) {
262                         value = "";
263                     }
264                     options.add(new ConcreteKeyValue(value, title));
265                     if (selected) {
266                         selectedOptions.add(node.getTextContent());
267                     }
268                 }
269 
270                 this.options = Collections.unmodifiableCollection(options);
271                 this.selectedOptions = Collections.unmodifiableCollection(selectedOptions);
272             }
273         }
274 
275         /**
276          * Encapsulates validation definition
277          */
278         static class Validation {
279             final boolean required;
280             final String regex;
281             final String message;
282 
283             Validation(XPath xpath, Node n) throws XPathExpressionException {
284                 required = Boolean.parseBoolean(getNodeText(xpath, n, "validation/@required"));
285                 regex = getNodeText(xpath, n, "validation/regex");
286                 message = getNodeText(xpath, n, "validation/message");
287             }
288         }
289 
290         /**
291          * Encapsulates visibility definition
292          */
293         static class Visibility {
294             final Boolean visible;
295             final String type;
296             final String groupName;
297             final String groupNamespace;
298 
299             Visibility(XPath xpath, Node n) throws XPathExpressionException {
300                 Boolean visible = null;
301                 String type = null;
302                 String groupName = null;
303                 String groupNamespace = null;
304                 Node node = (Node) xpath.evaluate("(visibility/field | visibility/column | visibility/fieldAndColumn)", n, XPathConstants.NODE); // NODE - just use first one
305                 if (node != null && node instanceof Element) {
306                     Element visibilityEl = (Element) node;
307                     type = visibilityEl.getNodeName();
308                     Attr attr = visibilityEl.getAttributeNode("visible");
309                     if (attr != null) {
310                         visible = Boolean.valueOf(attr.getValue());
311                     }
312                     Node groupMember = (Node) xpath.evaluate("(" + XmlConstants.IS_MEMBER_OF_GROUP + "|" + XmlConstants.IS_MEMBER_OF_WORKGROUP + ")", visibilityEl, XPathConstants.NODE);
313                     if (groupMember != null && groupMember instanceof Element) {
314                         Element groupMemberEl = (Element) groupMember;
315                         boolean group_def_found = false;
316                         if (XmlConstants.IS_MEMBER_OF_GROUP.equals(groupMember.getNodeName())) {
317                             group_def_found = true;
318                             groupName = Utilities.substituteConfigParameters(groupMember.getTextContent().trim());
319                             groupNamespace = Utilities.substituteConfigParameters(groupMemberEl.getAttribute(XmlConstants.NAMESPACE)).trim();
320                         } else if (XmlConstants.IS_MEMBER_OF_WORKGROUP.equals(groupMember.getNodeName())) {
321                             group_def_found = true;
322                             LOG.warn("Rule Attribute XML is using deprecated element '" + XmlConstants.IS_MEMBER_OF_WORKGROUP +
323                                      "', please use '" + XmlConstants.IS_MEMBER_OF_GROUP + "' instead.");
324                             String workgroupName = Utilities.substituteConfigParameters(groupMember.getTextContent());
325                             groupNamespace = Utilities.parseGroupNamespaceCode(workgroupName);
326                             groupName = Utilities.parseGroupName(workgroupName);
327                         }
328                         if (group_def_found) {
329                             if (StringUtils.isEmpty(groupName) || StringUtils.isEmpty(groupNamespace)) {
330                                 throw new RuntimeException("Both group name and group namespace must be present for group-based visibility.");
331                             }
332                         }
333                     }
334                 }
335                 this.visible = visible;
336                 this.type = type;
337                 this.groupName = groupName;
338                 this.groupNamespace = groupNamespace;
339             }
340         }
341 
342         /**
343          * Encapsulates a SearchDefinition
344          */
345         static class SearchDefinition {
346             final RangeOptions DEFAULTS = new RangeOptions(null, false, false);
347             /**
348              * The field search data type.  Guaranteed to be defined (defaulted if missing).
349              */
350             final String dataType;
351             final boolean rangeSearch;
352             final RangeOptions searchDef;
353             final RangeOptions rangeDef;
354             final RangeBound lowerBound;
355             final RangeBound upperBound;
356 
357             SearchDefinition(XPath xpath, Node n) throws XPathExpressionException {
358                 String dataType = KewApiConstants.SearchableAttributeConstants.DEFAULT_SEARCHABLE_ATTRIBUTE_TYPE_NAME;
359                 // if element is missing outright, omit the defaults as well as it cannot be a ranged search
360                 // caller should check whether this is a ranged search
361                 RangeOptions searchDefDefaults = new RangeOptions();
362                 RangeOptions rangeDef = null;
363                 RangeBound lowerBound = null;
364                 RangeBound upperBound = null;
365                 boolean rangeSearch = false;
366                 Node searchDefNode = (Node) xpath.evaluate("searchDefinition", n, XPathConstants.NODE);
367                 if (searchDefNode != null) {
368                     String s = getStringAttr(searchDefNode, "dataType");
369                     // TODO: empty data type should really be invalid or default to something (String?)
370                     if (StringUtils.isNotEmpty(s)) {
371                         dataType = s;
372                     }
373                     // clearly there is a conflict if rangeSearch is false while range bounds are defined!
374                     // this is not currently enforced
375                     rangeSearch = getBooleanAttr(searchDefNode, "rangeSearch", false);
376 
377                     searchDefDefaults = new RangeOptions(xpath, searchDefNode, DEFAULTS);
378                     Node rangeDefinition = (Node) xpath.evaluate("rangeDefinition", searchDefNode, XPathConstants.NODE);
379                     // if range definition element is present, bounds derive settings from range definition
380                     if (rangeDefinition != null) {
381                         rangeDef = new RangeOptions(xpath, rangeDefinition, searchDefDefaults);
382                         Node lower = (Node) xpath.evaluate("lower", rangeDefinition, XPathConstants.NODE);
383                         lowerBound = lower == null ? new RangeBound(defaultInclusive(rangeDef, true)) : new RangeBound(xpath, lower, defaultInclusive(rangeDef, true));
384                         Node upper = (Node) xpath.evaluate("upper", rangeDefinition, XPathConstants.NODE);
385                         upperBound = upper == null ? new RangeBound(defaultInclusive(rangeDef, false)) : new RangeBound(xpath, upper, defaultInclusive(rangeDef, false));
386                     } else if (rangeSearch) {
387                         // otherwise if range search is specified but no rangedefinition element is present,
388                         // bounds use options from search definition element
389                         lowerBound = new RangeBound(defaultInclusive(searchDefDefaults, true));
390                         upperBound = new RangeBound(defaultInclusive(searchDefDefaults, false));
391                     }
392                 }
393                 this.dataType = dataType;
394                 this.rangeSearch = rangeSearch;
395                 this.searchDef = searchDefDefaults;
396                 this.rangeDef = rangeDef;
397                 this.lowerBound = lowerBound;
398                 this.upperBound = upperBound;
399             }
400 
401             private static BaseRangeOptions defaultInclusive(BaseRangeOptions opts, boolean inclusive) {
402                 boolean inc = opts.inclusive == null ? inclusive : opts.inclusive;
403                 return new BaseRangeOptions(inc, opts.datePicker);
404             }
405 
406             /**
407              * Returns the most specific global/non-bounds options
408              */
409             public RangeOptions getRangeBoundOptions() {
410                 return rangeDef == null ? searchDef : rangeDef;
411             }
412 
413             /**
414              * Whether this appears to be a ranged search
415              */
416             public boolean isRangedSearch() {
417                 // this is a ranged search if
418                 // 1) searchDefinition declares this is a rangeSearch
419                 // OR
420                 // 2) rangeDefinition/bounds are present in searchDefinition
421                 return this.rangeSearch || (rangeDef != null);
422             }
423 
424             /**
425              * Base range options class used by search/range definition and bounds elements
426              */
427             static class BaseRangeOptions {
428                 protected final Boolean inclusive;
429                 protected final Boolean datePicker;
430 
431                 BaseRangeOptions() {
432                     this.inclusive = this.datePicker = null;
433                 }
434                 BaseRangeOptions(Boolean inclusive, Boolean datePicker) {
435                     this.inclusive = inclusive;
436                     this.datePicker = datePicker;
437                 }
438                 BaseRangeOptions(BaseRangeOptions defaults) {
439                     this.inclusive = defaults.inclusive;
440                     this.datePicker = defaults.datePicker;
441                 }
442                 BaseRangeOptions(XPath xpath, Node n, BaseRangeOptions defaults) {
443                     this.inclusive = getBooleanAttr(n, "inclusive", defaults.inclusive);
444                     this.datePicker = getBooleanAttr(n, "datePicker", defaults.datePicker);
445                 }
446             }
447 
448             /**
449              * Reads inclusive, caseSensitive, and datePicker options from attributes of
450              * search definition and range definition elements.
451              */
452             static class RangeOptions extends BaseRangeOptions {
453                 protected final Boolean caseSensitive;
454                 RangeOptions() {
455                     super();
456                     this.caseSensitive = null;
457                 }
458                 RangeOptions(Boolean inclusive, Boolean caseSensitive, Boolean datePicker) {
459                     super(inclusive, datePicker);
460                     this.caseSensitive = caseSensitive;
461                 }
462                 RangeOptions(RangeOptions defaults) {
463                     super(defaults);
464                     this.caseSensitive = defaults.caseSensitive;
465                 }
466                 RangeOptions(XPath xpath, Node n, RangeOptions defaults) {
467                     super(xpath, n, defaults);
468                     this.caseSensitive = getBooleanAttr(n, "caseSensitive", defaults.caseSensitive);
469                 }
470             }
471 
472             /**
473              * Adds label to BaseRangeOptions
474              */
475             static class RangeBound extends BaseRangeOptions {
476                 final String label;
477                 RangeBound(BaseRangeOptions defaults) {
478                     super(defaults);
479                     this.label = null;
480                 }
481                 RangeBound(XPath xpath, Node n, BaseRangeOptions defaults) {
482                     super(xpath, n, defaults);
483                     this.label = getStringAttr(n, "label");
484                 }
485             }
486         }
487 
488         /**
489          * Encapsulates a lookup definition
490          * <lookup businessObjectClass="org.kuali.rice.kew.docsearch.xml.MyLookupable">
491          *   <fieldConversions>
492          *     <fieldConversion lookupFieldName="chart" localFieldName="MyBean.data"/>
493          *   </fieldConversions>
494          * </lookup>
495          */
496         static class Lookup {
497             final String dataObjectClass;
498             final Map<String, String> fieldConversions;
499 
500             Lookup(XPath xpath, Node n, String fieldName) throws XPathExpressionException {
501                 String dataObjectClass = null;
502                 Map<String, String> fieldConversions = new HashMap<String, String>();
503 
504                 Node lookupNode = (Node) xpath.evaluate("lookup", n, XPathConstants.NODE);
505                 if (lookupNode != null) {
506                     NamedNodeMap quickfinderAttributes = lookupNode.getAttributes();
507                     Node dataObjectNode = quickfinderAttributes.getNamedItem("dataObjectClass");
508                     if (dataObjectNode == null) {
509                         // for legacy compatibility, though businessObjectClass is deprecated
510                         dataObjectNode = quickfinderAttributes.getNamedItem("businessObjectClass");
511                         if (dataObjectNode != null) {
512                             LOG.warn("Field is using deprecated 'businessObjectClass' instead of 'dataObjectClass' for lookup definition, field name is: " + fieldName);
513                         } else {
514                             throw new ConfigurationException("Failed to locate 'dataObjectClass' for lookup definition.");
515                         }
516                     }
517                     dataObjectClass = dataObjectNode.getNodeValue();
518                     NodeList list = (NodeList) xpath.evaluate("fieldConversions/fieldConversion", lookupNode, XPathConstants.NODESET);
519                     for (int i = 0; i < list.getLength(); i++) {
520                         Node fieldConversionChildNode = list.item(i);
521                         NamedNodeMap fieldConversionAttributes = fieldConversionChildNode.getAttributes();
522                         // TODO: no validation on these attrs, could throw NPE
523                         String lookupFieldName = fieldConversionAttributes.getNamedItem("lookupFieldName").getNodeValue();
524                         String localFieldName = fieldConversionAttributes.getNamedItem("localFieldName").getNodeValue();
525                         fieldConversions.put(lookupFieldName, localFieldName);
526                     }
527                 }
528                 
529                 this.dataObjectClass = dataObjectClass;
530                 this.fieldConversions = Collections.unmodifiableMap(fieldConversions);
531             }
532         }
533     }
534 
535     private static Boolean getBooleanAttr(Node n, String attributeName, Boolean dflt) {
536         String nodeValue = getStringAttr(n, attributeName);
537         return nodeValue == null ? dflt : Boolean.valueOf(nodeValue);
538     }
539 
540     private static String getStringAttr(Node n, String attributeName) {
541         Node attr = n.getAttributes().getNamedItem(attributeName);
542         return attr == null ? null : attr.getNodeValue();
543     }
544 
545     private static String getNodeText(XPath xpath, Node n, String expression) throws XPathExpressionException {
546         Node node = (Node) xpath.evaluate(expression, n, XPathConstants.NODE);
547         if (node == null) return null;
548         return node.getTextContent();
549     }
550 
551     private static Boolean getBoolean(XPath xpath, Node n, String expression) throws XPathExpressionException {
552         String val = getNodeText(xpath, n, expression);
553         return val == null ? null : Boolean.valueOf(val);
554     }
555 }