org.alfresco.web.bean.search.SearchContext.java Source code

Java tutorial

Introduction

Here is the source code for org.alfresco.web.bean.search.SearchContext.java

Source

/*
 * #%L
 * Alfresco Repository WAR Community
 * %%
 * Copyright (C) 2005 - 2016 Alfresco Software Limited
 * %%
 * This file is part of the Alfresco software. 
 * If the software was purchased under a paid Alfresco license, the terms of 
 * the paid license agreement will prevail.  Otherwise, the software is 
 * provided under the following open source license terms:
 * 
 * Alfresco is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Lesser General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 * 
 * Alfresco is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU Lesser General Public License for more details.
 * 
 * You should have received a copy of the GNU Lesser General Public License
 * along with Alfresco. If not, see <http://www.gnu.org/licenses/>.
 * #L%
 */
package org.alfresco.web.bean.search;

import java.io.Serializable;
import java.io.StringReader;
import java.io.StringWriter;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.StringTokenizer;

import javax.faces.context.FacesContext;

import org.alfresco.error.AlfrescoRuntimeException;
import org.alfresco.model.ContentModel;
import org.alfresco.service.cmr.repository.ChildAssociationRef;
import org.alfresco.service.cmr.repository.NodeRef;
import org.alfresco.service.cmr.repository.Path;
import org.alfresco.service.namespace.NamespaceService;
import org.alfresco.service.namespace.QName;
import org.alfresco.util.ISO9075;
import org.alfresco.util.SearchLanguageConversion;
import org.alfresco.web.app.Application;
import org.alfresco.web.bean.repository.Repository;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.dom4j.Document;
import org.dom4j.DocumentHelper;
import org.dom4j.Element;
import org.dom4j.io.OutputFormat;
import org.dom4j.io.SAXReader;
import org.dom4j.io.XMLWriter;

/**
 * Holds the context required to build a search query and can return the populated query.
 * <p>
 * Builds a lucene format search string from each of the supplied attributes and terms.
 * Can be serialized to and from XML format for saving and restoring of previous searches.
 * 
 * @author Kevin Roast
 */
public class SearchContext implements Serializable {
    private static final long serialVersionUID = 6730844584074229969L;

    /** XML serialization elements */
    private static final String ELEMENT_VALUE = "value";
    private static final String ELEMENT_FIXED_VALUES = "fixed-values";
    private static final String ELEMENT_INCLUSIVE = "inclusive";
    private static final String ELEMENT_UPPER = "upper";
    private static final String ELEMENT_LOWER = "lower";
    private static final String ELEMENT_RANGE = "range";
    private static final String ELEMENT_RANGES = "ranges";
    private static final String ELEMENT_NAME = "name";
    private static final String ELEMENT_ATTRIBUTE = "attribute";
    private static final String ELEMENT_ATTRIBUTES = "attributes";
    private static final String ELEMENT_MIMETYPE = "mimetype";
    private static final String ELEMENT_CONTENT_TYPE = "content-type";
    private static final String ELEMENT_FOLDER_TYPE = "folder-type";
    private static final String ELEMENT_CATEGORY = "category";
    private static final String ELEMENT_CATEGORIES = "categories";
    private static final String ELEMENT_LOCATION = "location";
    private static final String ELEMENT_MODE = "mode";
    private static final String ELEMENT_TEXT = "text";
    private static final String ELEMENT_SEARCH = "search";
    private static final String ELEMENT_QUERY = "query";

    /** advanced search term operators */
    private static final char OP_WILDCARD = '*';
    private static final char OP_AND = '+';
    private static final char OP_NOT = '-';
    private static final String STR_OP_WILDCARD = "" + OP_WILDCARD;

    /** Search mode constants */
    public final static int SEARCH_ALL = 0;
    public final static int SEARCH_FILE_NAMES_CONTENTS = 1;
    public final static int SEARCH_FILE_NAMES = 2;
    public final static int SEARCH_SPACE_NAMES = 3;

    /** the search text string */
    private String text = "";

    /** mode for the search */
    private int mode = SearchContext.SEARCH_ALL;

    /** folder XPath location for the search */
    private String location = null;

    /** categories to add to the search */
    private String[] categories = new String[0];

    /** folder type to restrict search against */
    private String folderType = null;

    /** content type to restrict search against */
    private String contentType = null;

    /** content mimetype to restrict search against */
    private String mimeType = null;

    /** any extra simple query attributes to add to the search */
    protected List<QName> simpleSearchAdditionalAttrs = new ArrayList<QName>(4);

    /** any extra query attributes to add to the search */
    private Map<QName, String> queryAttributes = new HashMap<QName, String>(5, 1.0f);

    /** any additional range attribute to add to the search */
    private Map<QName, RangeProperties> rangeAttributes = new HashMap<QName, RangeProperties>(5, 1.0f);

    /** any additional fixed value attributes to add to the search, such as boolean or noderef */
    private Map<QName, String> queryFixedValues = new HashMap<QName, String>(5, 1.0f);

    /** set true to force the use of AND between text terms */
    private boolean forceAndTerms = false;

    /** logger */
    private static Log logger = LogFactory.getLog(SearchContext.class);

    /**
     * Build the search query string based on the current search context members.
     * 
     * @param minimum       small possible textual string used for a match
     *                      this does not effect fixed values searches (e.g. boolean, int values) or date ranges
     * 
     * @return prepared search query string
     */
    public String buildQuery(int minimum) {
        String query;
        boolean validQuery = false;

        // the QName for the well known "name" attribute
        String nameAttr = Repository
                .escapeQName(QName.createQName(NamespaceService.CONTENT_MODEL_1_0_URI, ELEMENT_NAME));

        StringBuilder plBuf = new StringBuilder(500).append("(");
        StringBuilder mnBuf = new StringBuilder(500).append("-(");

        // match against content text
        String text = this.text.trim();
        boolean appendText = mode == SEARCH_FILE_NAMES_CONTENTS || mode == SEARCH_ALL;

        if (text.length() != 0 && text.length() >= minimum) {
            if (text.indexOf(' ') == -1 && text.charAt(0) != '"') {
                // check for existance of a special operator
                boolean operatorAND = (text.charAt(0) == OP_AND);
                boolean operatorNOT = (text.charAt(0) == OP_NOT);
                // strip operator from term if one was found
                if (operatorAND || operatorNOT) {
                    text = text.substring(1);
                }

                if (text.length() != 0) {
                    if (operatorNOT) {
                        processSearchTextAttribute(nameAttr, text, appendText, mnBuf);
                        processSearchAdditionalAttributes(text, mnBuf, this.simpleSearchAdditionalAttrs);
                    } else {
                        if (plBuf.length() > 1) {
                            plBuf.append(' ');
                        }
                        if (operatorAND) {
                            plBuf.append(OP_AND);
                        }
                        plBuf.append('(');
                        processSearchTextAttribute(nameAttr, text, appendText, plBuf);
                        processSearchAdditionalAttributes(text, plBuf, this.simpleSearchAdditionalAttrs);
                        plBuf.append(')');
                    }
                }
            } else {
                // multiple word search
                if (text.charAt(0) == '"' && text.charAt(text.length() - 1) == '"') {
                    // as a single quoted phrase
                    String quotedSafeText = '"'
                            + SearchLanguageConversion.escapeLuceneQuery(text.substring(1, text.length() - 1))
                            + '"';
                    if (appendText) {
                        plBuf.append("TEXT:").append(quotedSafeText);
                    }
                    plBuf.append(" @").append(nameAttr).append(":").append(quotedSafeText);
                    for (QName qname : this.simpleSearchAdditionalAttrs) {
                        plBuf.append(" @").append(Repository.escapeQName(qname)).append(":").append(quotedSafeText);
                    }
                } else {
                    // as individual search terms
                    StringTokenizer t = new StringTokenizer(text, " ");

                    int tokenCount = t.countTokens();
                    for (int i = 0; i < tokenCount; i++) {
                        String term = t.nextToken();

                        // check for existance of a special operator
                        boolean operatorAND = (term.charAt(0) == OP_AND);
                        boolean operatorNOT = (term.charAt(0) == OP_NOT);
                        // strip operator from term if one was found
                        if (operatorAND || operatorNOT) {
                            term = term.substring(1);
                        }

                        // special case for AND all terms if set (apply after operator character removed)
                        // note that we can't force AND if NOT operator has been set
                        if (operatorNOT == false) {
                            operatorAND = operatorAND | this.forceAndTerms;
                        }

                        if (term.length() != 0) {
                            if (operatorNOT) {
                                processSearchTextAttribute(nameAttr, term, appendText, mnBuf);
                                if (mode == SearchContext.SEARCH_ALL) {
                                    processSearchAdditionalAttributes(term, mnBuf,
                                            this.simpleSearchAdditionalAttrs);
                                }
                            } else {
                                if (plBuf.length() > 0) {
                                    plBuf.append(' ');
                                }
                                if (operatorAND) {
                                    plBuf.append(OP_AND);
                                }
                                plBuf.append('(');
                                processSearchTextAttribute(nameAttr, term, appendText, plBuf);
                                if (mode == SearchContext.SEARCH_ALL) {
                                    processSearchAdditionalAttributes(term, plBuf,
                                            this.simpleSearchAdditionalAttrs);
                                }
                                plBuf.append(')');
                            }
                        }
                    }
                }
            }

            plBuf.append(')');
            mnBuf.append(')');

            validQuery = true;
        }

        // match a specific PATH for space location or categories
        StringBuilder pathQuery = null;
        if (location != null || (categories != null && categories.length != 0)) {
            pathQuery = new StringBuilder(128);
            if (location != null) {
                pathQuery.append(" PATH:\"").append(location).append("\" ");
                if (categories != null && categories.length != 0) {
                    pathQuery.append("AND (");
                }
            }
            if (categories != null && categories.length != 0) {
                for (int i = 0; i < categories.length; i++) {
                    pathQuery.append(" PATH:\"").append(categories[i]).append("\" ");
                }
                if (location != null) {
                    pathQuery.append(") ");
                }
            }
        }

        // match any extra query attribute values specified
        StringBuilder attributeQuery = null;
        if (queryAttributes.size() != 0) {
            attributeQuery = new StringBuilder(queryAttributes.size() << 6);
            for (QName qname : queryAttributes.keySet()) {
                String value = queryAttributes.get(qname).trim();
                if (value.length() >= minimum) {
                    processSearchAttribute(qname, value, attributeQuery);
                }
            }

            // handle the case where we did not add any attributes due to minimum length restrictions
            if (attributeQuery.length() == 0) {
                attributeQuery = null;
            }
        }

        // match any extra fixed value attributes specified
        if (queryFixedValues.size() != 0) {
            if (attributeQuery == null) {
                attributeQuery = new StringBuilder(queryFixedValues.size() << 6);
            }
            for (QName qname : queryFixedValues.keySet()) {
                String escapedName = Repository.escapeQName(qname);
                String value = queryFixedValues.get(qname);
                attributeQuery.append(" +@").append(escapedName).append(":\"")
                        .append(SearchLanguageConversion.escapeLuceneQuery(value)).append('"');
            }
        }

        // range attributes are a special case also
        if (rangeAttributes.size() != 0) {
            if (attributeQuery == null) {
                attributeQuery = new StringBuilder(rangeAttributes.size() << 6);
            }
            for (QName qname : rangeAttributes.keySet()) {
                String escapedName = Repository.escapeQName(qname);
                RangeProperties rp = rangeAttributes.get(qname);
                String value1 = SearchLanguageConversion.escapeLuceneQuery(rp.lower);
                String value2 = SearchLanguageConversion.escapeLuceneQuery(rp.upper);
                attributeQuery.append(" +@").append(escapedName).append(":").append(rp.inclusive ? "[" : "{")
                        .append(value1).append(" TO ").append(value2).append(rp.inclusive ? "]" : "}");
            }
        }

        // mimetype is a special case - it is indexed as a special attribute it comes from the combined
        // ContentData attribute of cm:content - ContentData string cannot be searched directly
        if (mimeType != null && mimeType.length() != 0) {
            if (attributeQuery == null) {
                attributeQuery = new StringBuilder(64);
            }
            String escapedName = Repository.escapeQName(QName.createQName(ContentModel.PROP_CONTENT + ".mimetype"));
            attributeQuery.append(" +@").append(escapedName).append(":").append(mimeType);
        }

        // match against appropriate content type
        String fileTypeQuery;
        if (contentType != null) {
            fileTypeQuery = " TYPE:\"" + contentType + "\" ";
        } else {
            // default to cm:content
            fileTypeQuery = " TYPE:\"{" + NamespaceService.CONTENT_MODEL_1_0_URI + "}content\" ";
        }

        // match against appropriate folder type
        String folderTypeQuery;
        if (folderType != null) {
            folderTypeQuery = " TYPE:\"" + folderType + "\" ";
        } else {
            folderTypeQuery = " TYPE:\"{" + NamespaceService.CONTENT_MODEL_1_0_URI + "}folder\" ";
        }

        if (text.length() != 0 && text.length() >= minimum) {
            StringBuilder buf = new StringBuilder(128);
            if (plBuf.length() > 2) {
                buf.append(plBuf);
                if (mnBuf.length() > 3) {
                    buf.append(" AND ");
                }
            }
            if (mnBuf.length() > 3) {
                buf.append(mnBuf);
            }
            // text query for name and/or full text specified
            switch (mode) {
            case SearchContext.SEARCH_ALL:
                query = '(' + fileTypeQuery + " AND " + '(' + buf + ')' + ')' + ' ' + '(' + folderTypeQuery
                        + " AND " + '(' + buf + "))";
                break;

            case SearchContext.SEARCH_FILE_NAMES:
            case SearchContext.SEARCH_FILE_NAMES_CONTENTS:
                query = fileTypeQuery + " AND " + '(' + buf + ')';
                break;

            case SearchContext.SEARCH_SPACE_NAMES:
                query = folderTypeQuery + " AND " + buf;
                break;

            default:
                throw new IllegalStateException("Unknown search mode specified: " + mode);
            }
        } else {
            // no text query specified - must be an attribute/value query only
            switch (mode) {
            case SearchContext.SEARCH_ALL:
                query = '(' + fileTypeQuery + ' ' + folderTypeQuery + ')';
                break;

            case SearchContext.SEARCH_FILE_NAMES:
            case SearchContext.SEARCH_FILE_NAMES_CONTENTS:
                query = fileTypeQuery;
                break;

            case SearchContext.SEARCH_SPACE_NAMES:
                query = folderTypeQuery;
                break;

            default:
                throw new IllegalStateException("Unknown search mode specified: " + mode);
            }
        }

        // match entire query against any additional attributes specified
        if (attributeQuery != null) {
            query = attributeQuery + " AND (" + query + ')';
        }

        // match entire query against any specified paths
        if (pathQuery != null) {
            query = "(" + pathQuery + ") AND (" + query + ')';
        }

        query = "(" + query + ") AND NOT ASPECT:\"sys:hidden\" ";

        // check that we have a query worth executing - if we have no attributes, paths or text/name search
        // then we'll only have a search against files/type TYPE which does nothing by itself!
        validQuery = validQuery | (attributeQuery != null) | (pathQuery != null);
        if (validQuery == false) {
            query = null;
        }

        if (logger.isDebugEnabled())
            logger.debug("Query:\r\n" + query);

        return query;
    }

    /**
     * Build the lucene search terms required for the specified attribute and append to a buffer.
     * Supports text values with a wildcard '*' character as the prefix and/or the suffix. 
     * 
     * @param qname      QName of the attribute
     * @param value      Non-null value of the attribute
     * @param buf        Buffer to append lucene terms to
     */
    private static void processSearchAttribute(QName qname, String value, StringBuilder buf) {
        buf.append(" +@").append(Repository.escapeQName(qname)).append(":\"").append(SearchContext.escape(value))
                .append("\" ");
    }

    /**
     * Build the lucene search terms required for the specified attribute and append to multiple buffers.
     * Supports text values with a wildcard '*' character as the prefix and/or the suffix. 
     * 
     * @param qname      QName.toString() of the attribute
     * @param value      Non-null value of the attribute
     * @param appendText    Attribute search buffer to append lucene terms to
     * @param mnBuf    Text search buffer to append lucene terms to
     */
    private static void processSearchTextAttribute(String qname, String value, boolean appendText,
            StringBuilder mnBuf) {
        mnBuf.append('@').append(qname).append(":\"").append(SearchContext.escape(value)).append('"');
        if (appendText) {
            mnBuf.append(" TEXT:\"").append(SearchContext.escape(value)).append("\" ");
        }
    }

    private static void processSearchAdditionalAttributes(String value, StringBuilder buf,
            List<QName> simpleSearchAdditionalAttrs) {
        for (QName qname : simpleSearchAdditionalAttrs) {
            buf.append(" @").append(Repository.escapeQName(qname)).append(":\"").append(SearchContext.escape(value))
                    .append("\" ");
        }
    }

    /**
     * Returns a String where those characters that QueryParser
     * expects to be escaped are escaped by a preceding <code>\</code>.
     * '*' and '?' are not escaped.
     */
    private static String escape(String s) {
        StringBuffer sb = new StringBuffer(s.length() + 4);
        for (int i = 0; i < s.length(); i++) {
            char c = s.charAt(i);
            if (c == '\\' || c == '+' || c == '-' || c == '!' || c == '(' || c == ')' || c == ':' || c == '^'
                    || c == '[' || c == ']' || c == '\"' || c == '{' || c == '}' || c == '~') {
                sb.append('\\');
            }
            sb.append(c);
        }
        return sb.toString();
    }

    /**
     * Generate a search XPATH pointing to the specified node, optionally return an XPATH
     * that includes the child nodes.
     *  
     * @param ref         Of the node to generate path too
     * @param children   Whether to include children of the node
     * 
     * @return the path
     */
    public static String getPathFromSpaceRef(NodeRef ref, boolean children) {
        FacesContext context = FacesContext.getCurrentInstance();
        Path path = Repository.getServiceRegistry(context).getNodeService().getPath(ref);
        NamespaceService ns = Repository.getServiceRegistry(context).getNamespaceService();
        StringBuilder buf = new StringBuilder(64);
        for (int i = 0; i < path.size(); i++) {
            String elementString = "";
            Path.Element element = path.get(i);
            if (element instanceof Path.ChildAssocElement) {
                ChildAssociationRef elementRef = ((Path.ChildAssocElement) element).getRef();
                if (elementRef.getParentRef() != null) {
                    Collection<String> prefixes = ns.getPrefixes(elementRef.getQName().getNamespaceURI());
                    if (prefixes.size() > 0) {
                        elementString = '/' + (String) prefixes.iterator().next() + ':'
                                + ISO9075.encode(elementRef.getQName().getLocalName());
                    }
                }
            }

            buf.append(elementString);
        }
        if (children == true) {
            // append syntax to get all children of the path
            buf.append("//*");
        } else {
            // append syntax to just represent the path, not the children
            buf.append("/*");
        }

        return buf.toString();
    }

    /**
     * @return Returns the categories to use for the search
     */
    public String[] getCategories() {
        return this.categories;
    }

    /**
     * @param categories    The categories to set as a list of search XPATHs
     */
    public void setCategories(String[] categories) {
        if (categories != null) {
            this.categories = categories;
        }
    }

    /**
     * @return Returns the node XPath to search in or null for all.
     */
    public String getLocation() {
        return this.location;
    }

    /**
     * @param location      The node XPATH to search from or null for all..
     */
    public void setLocation(String location) {
        this.location = location;
    }

    /**
     * @return Returns the mode to use during the search (see constants)
     */
    public int getMode() {
        return this.mode;
    }

    /**
     * @param mode The mode to use during the search (see constants)
     */
    public void setMode(int mode) {
        this.mode = mode;
    }

    public String getSearchText() {
        if (this.text != null && this.text.trim().length() > 0) {
            return getText();
        }
        StringBuilder res = new StringBuilder();
        for (QName attrName : this.queryAttributes.keySet()) {
            if (ContentModel.PROP_TITLE.isMatch(attrName)) {
                res.append(res.length() > 0 ? ", " : "");
                res.append(Application.getMessage(FacesContext.getCurrentInstance(), "title"));
                res.append(": ");
                res.append(this.queryAttributes.get(attrName));
            } else if (ContentModel.PROP_DESCRIPTION.isMatch(attrName)) {
                res.append(res.length() > 0 ? ", " : "");
                res.append(Application.getMessage(FacesContext.getCurrentInstance(), "description"));
                res.append(": ");
                res.append(this.queryAttributes.get(attrName));
            } else if (ContentModel.PROP_AUTHOR.isMatch(attrName)) {
                res.append(res.length() > 0 ? ", " : "");
                res.append(Application.getMessage(FacesContext.getCurrentInstance(), "author"));
                res.append(": ");
                res.append(this.queryAttributes.get(attrName));
            } else {
                res.append(res.length() > 0 ? ", " : "");
                res.append(this.queryAttributes.get(attrName));
            }
        }
        return res.toString();
    }

    /**
     * @return Returns the search text string.
     */
    public String getText() {
        return this.text;
    }

    /**
     * @param text       The search text string.
     */
    public void setText(String text) {
        this.text = text;
    }

    /**
     * @return Returns the contentType.
     */
    public String getContentType() {
        return this.contentType;
    }

    /**
     * @param contentType The content type to restrict attribute search against.
     */
    public void setContentType(String contentType) {
        this.contentType = contentType;
    }

    /**
     * @return Returns the folderType.
     */
    public String getFolderType() {
        return this.folderType;
    }

    /**
     * @param folderType The folder type to restrict attribute search against.
     */
    public void setFolderType(String folderType) {
        this.folderType = folderType;
    }

    /**
     * @return Returns the mimeType.
     */
    public String getMimeType() {
        return this.mimeType;
    }

    /**
     * @param mimeType The mimeType to set.
     */
    public void setMimeType(String mimeType) {
        this.mimeType = mimeType;
    }

    /**
     * Add an additional attribute to search against for simple searches
     * 
     * @param qname      QName of the attribute to search against
     */
    public void addSimpleAttributeQuery(QName qname) {
        this.simpleSearchAdditionalAttrs.add(qname);
    }

    /**
     * Sets the additional attribute to search against for simple searches.
     * 
     * @param attrs      The list of attributes to search against
     */
    public void setSimpleSearchAdditionalAttributes(List<QName> attrs) {
        if (attrs != null) {
            this.simpleSearchAdditionalAttrs = attrs;
        }
    }

    /**
     * Add an additional attribute to search against
     * 
     * @param qname      QName of the attribute to search against
     * @param value      Value of the attribute to use
     */
    public void addAttributeQuery(QName qname, String value) {
        this.queryAttributes.put(qname, value);
    }

    public String getAttributeQuery(QName qname) {
        return this.queryAttributes.get(qname);
    }

    /**
     * Add an additional range attribute to search against
     * 
     * @param qname      QName of the attribute to search against
     * @param lower      Lower value for range
     * @param upper      Upper value for range
     * @param inclusive  True for inclusive within the range, false otherwise
     */
    public void addRangeQuery(QName qname, String lower, String upper, boolean inclusive) {
        this.rangeAttributes.put(qname, new RangeProperties(qname, lower, upper, inclusive));
    }

    public RangeProperties getRangeProperty(QName qname) {
        return this.rangeAttributes.get(qname);
    }

    /**
     * Add an additional fixed value attribute to search against
     * 
     * @param qname      QName of the attribute to search against
     * @param value      Fixed value of the attribute to use
     */
    public void addFixedValueQuery(QName qname, String value) {
        this.queryFixedValues.put(qname, value);
    }

    public String getFixedValueQuery(QName qname) {
        return this.queryFixedValues.get(qname);
    }

    /**
     * @return Returns if AND is forced between text terms. False (OR terms) is the default.
     */
    public boolean getForceAndTerms() {
        return this.forceAndTerms;
    }

    /**
     * @param forceAndTerms Set true to force AND between text terms. Otherwise OR is the default.
     */
    public void setForceAndTerms(boolean forceAndTerms) {
        this.forceAndTerms = forceAndTerms;
    }

    /**
     * @return this SearchContext as XML
     * 
     * Example:
     * <code>
     * <?xml version="1.0" encoding="UTF-8"?>
     * <search>
     *    <text>CDATA</text>
     *    <mode>int</mode>
     *    <location>XPath</location>
     *    <categories>
     *       <category>XPath</category>
     *    </categories>
     *    <content-type>String</content-type>
     *    <folder-type>String</folder-type>
     *    <mimetype>String</mimetype>
     *    <attributes>
     *       <attribute name="String">String</attribute>
     *    </attributes>
     *    <ranges>
     *       <range name="String">
     *          <lower>String</lower>
     *          <upper>String</upper>
     *          <inclusive>boolean</inclusive>
     *       </range>
     *    </ranges>
     *    <fixed-values>
     *       <value name="String">String</value>
     *    </fixed-values>
     *    <query>CDATA</query>
     * </search>
     * </code>
     */
    public String toXML() {
        try {
            NamespaceService ns = Repository.getServiceRegistry(FacesContext.getCurrentInstance())
                    .getNamespaceService();

            Document doc = DocumentHelper.createDocument();

            Element root = doc.addElement(ELEMENT_SEARCH);

            root.addElement(ELEMENT_TEXT).addCDATA(this.text);
            root.addElement(ELEMENT_MODE).addText(Integer.toString(this.mode));
            if (this.location != null) {
                root.addElement(ELEMENT_LOCATION).addText(this.location);
            }

            Element categories = root.addElement(ELEMENT_CATEGORIES);
            for (String path : this.categories) {
                categories.addElement(ELEMENT_CATEGORY).addText(path);
            }

            if (this.contentType != null) {
                root.addElement(ELEMENT_CONTENT_TYPE).addText(this.contentType);
            }
            if (this.folderType != null) {
                root.addElement(ELEMENT_FOLDER_TYPE).addText(this.folderType);
            }
            if (this.mimeType != null && this.mimeType.length() != 0) {
                root.addElement(ELEMENT_MIMETYPE).addText(this.mimeType);
            }

            Element attributes = root.addElement(ELEMENT_ATTRIBUTES);
            for (QName attrName : this.queryAttributes.keySet()) {
                attributes.addElement(ELEMENT_ATTRIBUTE).addAttribute(ELEMENT_NAME, attrName.toPrefixString(ns))
                        .addCDATA(this.queryAttributes.get(attrName));
            }

            Element ranges = root.addElement(ELEMENT_RANGES);
            for (QName rangeName : this.rangeAttributes.keySet()) {
                RangeProperties rangeProps = this.rangeAttributes.get(rangeName);
                Element range = ranges.addElement(ELEMENT_RANGE);
                range.addAttribute(ELEMENT_NAME, rangeName.toPrefixString(ns));
                range.addElement(ELEMENT_LOWER).addText(rangeProps.lower);
                range.addElement(ELEMENT_UPPER).addText(rangeProps.upper);
                range.addElement(ELEMENT_INCLUSIVE).addText(Boolean.toString(rangeProps.inclusive));
            }

            Element values = root.addElement(ELEMENT_FIXED_VALUES);
            for (QName valueName : this.queryFixedValues.keySet()) {
                values.addElement(ELEMENT_VALUE).addAttribute(ELEMENT_NAME, valueName.toPrefixString(ns))
                        .addCDATA(this.queryFixedValues.get(valueName));
            }

            // outputing the full lucene query may be useful for some situations
            Element query = root.addElement(ELEMENT_QUERY);
            String queryString = buildQuery(0);
            if (queryString != null) {
                query.addCDATA(queryString);
            }

            StringWriter out = new StringWriter(1024);
            XMLWriter writer = new XMLWriter(OutputFormat.createPrettyPrint());
            writer.setWriter(out);
            writer.write(doc);

            return out.toString();
        } catch (Throwable err) {
            throw new AlfrescoRuntimeException("Failed to export SearchContext to XML.", err);
        }
    }

    /**
     * Restore a SearchContext from an XML definition
     * 
     * @param xml     XML format SearchContext @see #toXML()
     */
    public SearchContext fromXML(String xml) {
        try {
            NamespaceService ns = Repository.getServiceRegistry(FacesContext.getCurrentInstance())
                    .getNamespaceService();

            // get the root element
            SAXReader reader = new SAXReader();
            Document document = reader.read(new StringReader(xml));
            Element rootElement = document.getRootElement();
            Element textElement = rootElement.element(ELEMENT_TEXT);
            if (textElement != null) {
                this.text = textElement.getText();
            }
            Element modeElement = rootElement.element(ELEMENT_MODE);
            if (modeElement != null) {
                this.mode = Integer.parseInt(modeElement.getText());
            }
            Element locationElement = rootElement.element(ELEMENT_LOCATION);
            if (locationElement != null) {
                this.location = locationElement.getText();
            }
            Element categoriesElement = rootElement.element(ELEMENT_CATEGORIES);
            if (categoriesElement != null) {
                List<String> categories = new ArrayList<String>(4);
                for (Iterator i = categoriesElement.elementIterator(ELEMENT_CATEGORY); i.hasNext(); /**/) {
                    Element categoryElement = (Element) i.next();
                    categories.add(categoryElement.getText());
                }
                this.categories = categories.toArray(this.categories);
            }
            Element contentTypeElement = rootElement.element(ELEMENT_CONTENT_TYPE);
            if (contentTypeElement != null) {
                this.contentType = contentTypeElement.getText();
            }
            Element folderTypeElement = rootElement.element(ELEMENT_FOLDER_TYPE);
            if (folderTypeElement != null) {
                this.folderType = folderTypeElement.getText();
            }
            Element mimetypeElement = rootElement.element(ELEMENT_MIMETYPE);
            if (mimetypeElement != null) {
                this.mimeType = mimetypeElement.getText();
            }
            Element attributesElement = rootElement.element(ELEMENT_ATTRIBUTES);
            if (attributesElement != null) {
                for (Iterator i = attributesElement.elementIterator(ELEMENT_ATTRIBUTE); i.hasNext(); /**/) {
                    Element attrElement = (Element) i.next();
                    QName qname = QName.createQName(attrElement.attributeValue(ELEMENT_NAME), ns);
                    addAttributeQuery(qname, attrElement.getText());
                }
            }
            Element rangesElement = rootElement.element(ELEMENT_RANGES);
            if (rangesElement != null) {
                for (Iterator i = rangesElement.elementIterator(ELEMENT_RANGE); i.hasNext(); /**/) {
                    Element rangeElement = (Element) i.next();
                    Element lowerElement = rangeElement.element(ELEMENT_LOWER);
                    Element upperElement = rangeElement.element(ELEMENT_UPPER);
                    Element incElement = rangeElement.element(ELEMENT_INCLUSIVE);
                    if (lowerElement != null && upperElement != null && incElement != null) {
                        QName qname = QName.createQName(rangeElement.attributeValue(ELEMENT_NAME), ns);
                        addRangeQuery(qname, lowerElement.getText(), upperElement.getText(),
                                Boolean.parseBoolean(incElement.getText()));
                    }
                }
            }

            Element valuesElement = rootElement.element(ELEMENT_FIXED_VALUES);
            if (valuesElement != null) {
                for (Iterator i = valuesElement.elementIterator(ELEMENT_VALUE); i.hasNext(); /**/) {
                    Element valueElement = (Element) i.next();
                    QName qname = QName.createQName(valueElement.attributeValue(ELEMENT_NAME), ns);
                    addFixedValueQuery(qname, valueElement.getText());
                }
            }
        } catch (Throwable err) {
            throw new AlfrescoRuntimeException("Failed to import SearchContext from XML.", err);
        }
        return this;
    }

    /**
     * Simple wrapper class for range query attribute properties 
     */
    static class RangeProperties implements Serializable {
        private static final long serialVersionUID = 5627339191207625169L;

        QName qname;
        String lower;
        String upper;
        boolean inclusive;

        RangeProperties(QName qname, String lower, String upper, boolean inclusive) {
            this.qname = qname;
            this.lower = lower;
            this.upper = upper;
            this.inclusive = inclusive;
        }
    }
}