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