org.intermine.pathquery.PathQuery.java Source code

Java tutorial

Introduction

Here is the source code for org.intermine.pathquery.PathQuery.java

Source

package org.intermine.pathquery;

/*
 * Copyright (C) 2002-2013 FlyMine
 *
 * This code may be freely distributed and modified under the
 * terms of the GNU Lesser General Public Licence.  This should
 * be distributed with the code.  See the LICENSE file for more
 * information or http://www.gnu.org/copyleft/lesser.html.
 *
 */

import java.io.StringWriter;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.Set;
import java.util.TreeMap;
import java.util.Map.Entry;
import java.util.regex.Pattern;

import javax.xml.stream.XMLOutputFactory;
import javax.xml.stream.XMLStreamException;
import javax.xml.stream.XMLStreamWriter;

import org.apache.commons.lang.StringEscapeUtils;
import org.intermine.metadata.ClassDescriptor;
import org.intermine.metadata.Model;
import org.intermine.util.DynamicUtil;
import org.intermine.util.TypeUtil;

/**
 * Class to represent a path-based query.
 *
 * @author Matthew Wakeling
 */
public class PathQuery implements Cloneable {
    /** A Pattern that finds spaces in a String. */
    protected static final Pattern SPACE_SPLITTER = Pattern.compile(" ", Pattern.LITERAL);
    /** Version number for the userprofile and PathQuery XML format. */
    public static final int USERPROFILE_VERSION = 2;

    /** The lowest code value a constraint may be assigned. **/
    public static final char MIN_CODE = 'A';

    /** The highest code value a constraint may be assigned. **/
    public static final char MAX_CODE = 'Z';

    /** The maximum number of coded constraints a PathQuery may hold. **/
    public static final int MAX_CONSTRAINTS = MAX_CODE - MIN_CODE;

    private final Model model;
    private List<String> view = new ArrayList<String>();
    private List<OrderElement> orderBy = new ArrayList<OrderElement>();
    private Map<PathConstraint, String> constraints = new LinkedHashMap<PathConstraint, String>();
    private LogicExpression logic = null;
    private Map<String, OuterJoinStatus> outerJoinStatus = new LinkedHashMap<String, OuterJoinStatus>();
    private Map<String, String> descriptions = new LinkedHashMap<String, String>();
    private String description = null;

    /** Query title **/
    private String title = null;

    // Verification variables:
    private boolean isVerified = false;
    /** The root path of this query */
    private String rootClass = null;
    /** A Map from path to class name for all PathConstraintSubclass objects in the query. */
    private Map<String, String> subclasses = null;
    /** A Map from path to outer join group for all main paths in the query. */
    private Map<String, String> outerJoinGroups = null;
    /** A Map from outer join group to the set of constraint codes in that group. */
    private Map<String, Set<String>> constraintGroups = null;
    /** A Set of Strings describing all the loop constraints in the query, in order to check for
     * uniqueness */
    private Set<String> existingLoops = null;
    /** A boolean that determines if the constraints are broken enough to not bother validating the
     * constraint logic */
    private boolean doNotVerifyLogic = false;

    private static final String NO_VIEW_ERROR = "No columns selected for output";

    // See http://intrac.flymine.org/wiki/PathQueryRefactor

    /**
     * Constructor. Takes a Model object, to enable verification later.
     *
     * @param model a Model object
     */
    public PathQuery(Model model) {
        this.model = model;
    }

    /**
     * Constructor. Takes an existing PathQuery object, and copies all the data. Similar to the
     * clone method.
     *
     * @param o a PathQuery to copy
     */
    public PathQuery(PathQuery o) {
        model = o.model;
        view = new ArrayList<String>(o.view);
        orderBy = new ArrayList<OrderElement>(o.orderBy);
        constraints = new LinkedHashMap<PathConstraint, String>(o.constraints);
        if (o.logic != null) {
            logic = new LogicExpression(o.logic.toString());
        }
        outerJoinStatus = new LinkedHashMap<String, OuterJoinStatus>(o.outerJoinStatus);
        descriptions = new LinkedHashMap<String, String>(o.descriptions);
        description = o.description;
    }

    /**
     * Returns the Model object stored in this object.
     *
     * @return a Model
     */
    public Model getModel() {
        return model;
    }

    // ------------- View control methods ---------------

    /**
     * Add a single element to the view list. The element should be a normal path expression, with
     * dots separating the parts. Do not use colons to represent outer joins, and do not use
     * square brackets to represent subclass constraints. The path will not be verified until the
     * verifyQuery() method is called, but will be merely checked for format.
     *
     * @param viewPath the new path String to add to the view list
     * @throws NullPointerException if viewPath is null
     * @throws IllegalArgumentException if the viewPath contains colons or square brackets, or is
     * otherwise in a bad format
     */
    public synchronized void addView(String viewPath) {
        deVerify();
        checkPathFormat(viewPath);
        view.add(viewPath);
    }

    /**
     * Removes a single element from the view list. The element should be a normal path expression,
     * with dots separating the parts. Do not use colons to represent outer joins, and do not use
     * square brackets to represent subclass constraints. If there are multiple copies of the path
     * on the view list (which is an invalid query), then this method will remove all of them.
     *
     * @param viewPath the path String to remove from the view list
     * @throws NullPointerException if the viewPath is null
     * @throws NoSuchElementException if the viewPath is not already on the view list
     */
    public synchronized void removeView(String viewPath) {
        deVerify();
        checkPathFormat(viewPath);
        if (!view.contains(viewPath)) {
            throw new NoSuchElementException(
                    "Path \"" + viewPath + "\" is not in the view list: \"" + view + "\" - cannot remove it");
        }
        view.removeAll(Collections.singleton(viewPath));
    }

    /**
     * Clears the entire view list.
     */
    public synchronized void clearView() {
        deVerify();
        view.clear();
    }

    /**
     * Adds a group of elements to the view list. The elements should be normal path expressions,
     * with dots separating the parts. Do not use colons to represent outer joins, and do not use
     * square brackets to represent subclass constraints. The paths will not be verified until the
     * verifyQuery() method is called, but will merely be checked for format. The paths will be
     * added in the order of the iterator of the collection. If there is an error with any of the
     * elements of the collection, then none of the elements will be added and the query will be
     * unchanged.
     *
     * @param viewPaths a Collection of String paths to add to the view list
     * @throws NullPointerException if viewPaths is null or contains a null element
     * @throws IllegalArgumentException if a view path contains colons or square brackets, or is
     * otherwise in a bad format
     */
    public synchronized void addViews(Collection<String> viewPaths) {
        deVerify();
        try {
            for (String viewPath : viewPaths) {
                checkPathFormat(viewPath);
            }
            for (String viewPath : viewPaths) {
                addView(viewPath);
            }
        } catch (NullPointerException e) {
            NullPointerException e2 = new NullPointerException("While adding list to view: " + viewPaths);
            e2.initCause(e);
            throw e2;
        } catch (IllegalArgumentException e) {
            throw new IllegalArgumentException("While adding list to view: " + viewPaths, e);
        }
    }

    /**
     * Adds a group of elements to the view list. The elements should be normal path expressions,
     * with dots separating the parts. Do not use colons to represent outer joins, and do not use
     * square brackets to represent subclass constraints. The paths will not be verified until the
     * verifyQuery() method is called, but will merely be checked for format. The paths will be
     * added in the order of the arguments (varargs or array). If there is an error with any of the
     * elements of the array/varargs, then none of the elements will be added and the query will be
     * unchanged.
     *
     * @param viewPaths String paths to add to the view list
     * @throws NullPointerException if viewPaths is null or contains a null element
     * @throws IllegalArgumentException if a view path contains colons or square brackets, or is
     * otherwise in a bad format
     */
    public synchronized void addViews(String... viewPaths) {
        deVerify();
        try {
            for (String viewPath : viewPaths) {
                checkPathFormat(viewPath);
            }
            for (String viewPath : viewPaths) {
                addView(viewPath);
            }
        } catch (NullPointerException e) {
            NullPointerException e2 = new NullPointerException("While adding array to view: " + viewPaths);
            e2.initCause(e);
            throw e2;
        } catch (IllegalArgumentException e) {
            throw new IllegalArgumentException("While adding array to view: " + viewPaths, e);
        }
    }

    /**
     * Adds a group of elements to the view list, given a space-separated list. The elements should
     * be normal path expressions, with dots separating the parts. Do not use colons to represent
     * outer joins, and do not use square brackets to represent subclass constraints. The paths
     * will not be verified until the verifyQuery() method is called, but will merely be checked
     * for format. The paths will be added preserving the order in the argument. The paths should be
     * separated by spaces in the argument, but not commas. If there is an error with any of the
     * elements in the argument, then none of the elements will be added and the query will be
     * unchanged.
     *
     * @param viewPaths String paths to add to the view list
     * @throws NullPointerException if viewPaths is null or contains a null element
     * @throws IllegalArgumentException if a view path contains colons or square brackets, or is
     * otherwise in a bad format
     */
    public synchronized void addViewSpaceSeparated(String viewPaths) {
        deVerify();
        try {
            String[] viewPathArray = SPACE_SPLITTER.split(viewPaths.trim());
            for (String viewPath : viewPathArray) {
                if (!"".equals(viewPath)) {
                    checkPathFormat(viewPath);
                }
            }
            for (String viewPath : viewPathArray) {
                if (!"".equals(viewPath)) {
                    addView(viewPath);
                }
            }
        } catch (NullPointerException e) {
            NullPointerException e2 = new NullPointerException(
                    "While adding space-separated list " + "to view: \"" + viewPaths + "\"");
            e2.initCause(e);
            throw e2;
        } catch (IllegalArgumentException e) {
            throw new IllegalArgumentException("While adding space-separated list to view: \"" + viewPaths + "\"",
                    e);
        }
    }

    /**
     * Returns the current view list. This is an unmodifiable copy of the view list as it is at the
     * point of execution of this method. Changes in this query are not reflected in the result of
     * this method. The paths listed are normal path expressions without colons or square brackets.
     * The paths may not have been verified.
     *
     * @return a List of String paths
     */
    public synchronized List<String> getView() {
        return Collections.unmodifiableList(new ArrayList<String>(view));
    }

    // ---------------- Order By List Control -----------------

    /**
     * Adds an element to the order by list of this query. The element should be a normal path
     * expression, with dots separating the parts. Do not use colons to represent outer joins, and
     * do not use square brackets to represent subclass constraints. The path will not be verified
     * until the verifyQuery() method is called, but will merely be checked for format.
     *
     * @param orderPath the path expression to add to the order by list
     * @param direction the sort order
     * @throws NullPointerException if orderPath or direction is null
     * @throws IllegalArgumentException if the orderPath contains colons or square brackets, or is
     * otherwise in a bad format
     */
    public synchronized void addOrderBy(String orderPath, OrderDirection direction) {
        deVerify();
        addOrderBy(new OrderElement(orderPath, direction));
    }

    /**
     * Removes an element from the order by list of this query. The element should be a normal path
     * expression, with dots separating the parts. Do not use colons to represent outer joins, and
     * do not use square brackets to represent subclass constraints. If there are multiple copies
     * of the path on the order by list (which is an invalid query), then this method will remove
     * all of them.
     *
     * @param orderPath the path String to remove from the order by list
     * @throws NullPointerException if the orderPath is null
     * @throws NoSuchElementException if the orderPath is not already in the order by list
     */
    public synchronized void removeOrderBy(String orderPath) {
        deVerify();
        checkPathFormat(orderPath);
        boolean found = false;
        int i = 0;
        while (i < orderBy.size()) {
            if (orderPath.equals(orderBy.get(i).getOrderPath())) {
                orderBy.remove(i);
                found = true;
            } else {
                i++;
            }
        }
        if (!found) {
            throw new NoSuchElementException("Path \"" + orderPath + "\" is not in the order by " + "list: \""
                    + orderBy + "\" - cannot remove it");
        }
    }

    /**
     * Clears the entire order by list.
     */
    public synchronized void clearOrderBy() {
        deVerify();
        orderBy.clear();
    }

    /**
     * Adds an element to the order by list of this query. The OrderElement will have already
     * checked the path for format, and the path will not be verified until the verifyQuery()
     * method is called.
     *
     * @param orderElement an OrderElement to add to the order by list
     * @throws NullPointerException if orderElement is null
     */
    public synchronized void addOrderBy(OrderElement orderElement) {
        deVerify();
        if (orderElement == null) {
            throw new NullPointerException("Cannot add a null OrderElement to the order by list");
        }
        orderBy.add(orderElement);
    }

    /**
     * Adds a group of elements to the order by list of this query. The elements will have already
     * checked the paths for format, but the paths will not be verified until the verifyQuery()
     * method is called. If there is an error with any of the elements of the collection, then none
     * of the elements will be added and the query will be unchanged.
     *
     * @param orderElements a Collection of OrderElement objects to add to the view list
     * @throws NullPointerException if orderElements is null or contains a null element
     */
    public synchronized void addOrderBys(Collection<OrderElement> orderElements) {
        deVerify();
        if (orderElements == null) {
            throw new NullPointerException("Cannot add null collection of OrderElements to order by" + " list");
        }
        for (OrderElement orderElement : orderElements) {
            if (orderElement == null) {
                throw new NullPointerException("Cannot add null OrderElement (from collection \"" + orderElements
                        + "\") to the order by list");
            }
        }
        for (OrderElement orderElement : orderElements) {
            addOrderBy(orderElement);
        }
    }

    /**
     * Adds a group of elements to the order by list of this query. The elements will have already
     * checked the paths for format, but the paths will not be verified until the verifyQuery()
     * method is called. If there is an error with any of the elements in this array/varargs, then
     * none of the elements will be added and the query will be unchanged.
     *
     * @param orderElements an array/varargs of OrderElement objects to add to the view list
     * @throws NullPointerException if orderElements is null or contains a null element
     */
    public synchronized void addOrderBys(OrderElement... orderElements) {
        deVerify();
        if (orderElements == null) {
            throw new NullPointerException("Cannot add null array of OrderElements to order by " + "list");
        }
        for (OrderElement orderElement : orderElements) {
            if (orderElement == null) {
                throw new NullPointerException(
                        "Cannot add null OrderElement (from array \"" + orderElements + "\") to the order by list");
            }
        }
        for (OrderElement orderElement : orderElements) {
            addOrderBy(orderElement);
        }
    }

    /**
     * Adds a group of elements to the order by list, given a space-separated list. The elements
     * should be normal path expressions, with dots separating the parts. Do not use colons to
     * represent outer joins, and do not use square brackets to represent subclass constraints. The
     * paths will not be verified until the verifyQuery() method is called, but will merely be
     * checked for format. The paths will be added preserving the order in the argument. Each
     * element should be a path expression followed by a space and then either "asc" or "desc" to
     * describe the direction of sorting, and the elements should be separated by spaces. If there
     * is an error with any of the elements in the argument, then none of the elements will be
     * added and the query will be unchanged.
     *
     * @param orderString the order elements in space-separated string form
     * @throws NullPointerException if orderString is null
     * @throws IllegalArgumentException if a path expression contains colons or square brackets, or
     * is otherwise in a bad format, or if there is not an even number of space-separated elements,
     * or if any even-numbered element is not either "asc" or "desc".
     */
    public synchronized void addOrderBySpaceSeparated(String orderString) {
        deVerify();
        try {
            String[] orderPathArray = SPACE_SPLITTER.split(orderString.trim());
            if (orderPathArray.length % 2 != 0) {
                throw new IllegalArgumentException("Order String must contain alternating paths and"
                        + " directions, so must have an even number of space-separated elements.");
            }
            List<OrderElement> toAdd = new ArrayList<OrderElement>();
            for (int i = 0; i < orderPathArray.length - 1; i += 2) {
                if ("asc".equals(orderPathArray[i + 1].toLowerCase())) {
                    toAdd.add(new OrderElement(orderPathArray[i], OrderDirection.ASC));
                } else if ("desc".equals(orderPathArray[i + 1].toLowerCase())) {
                    toAdd.add(new OrderElement(orderPathArray[i], OrderDirection.DESC));
                } else {
                    throw new IllegalArgumentException(
                            "Order direction \"" + orderPathArray[i + 1] + "\" must be either \"asc\" or \"desc\"");
                }
            }
            addOrderBys(toAdd);
        } catch (NullPointerException e) {
            NullPointerException e2 = new NullPointerException(
                    "While adding space-separated list " + "to order by: \"" + orderString + "\"");
            e2.initCause(e);
            throw e2;
        } catch (IllegalArgumentException e) {
            throw new IllegalArgumentException(
                    "While adding space-separated list to order by: \"" + orderString + "\"");
        }
    }

    /**
     * Returns the current order by list. This is an unmodifiable copy of the order by list as it is
     * at the point of execution of this method. Changes in this query are not reflected in the
     * result of this method. The returned value is a List containing OrderElement objects, which
     * contain a String path expression without colons or square brackets, and an OrderDirection.
     * The paths may not have been verified.
     *
     * @return a List of OrderElement objects
     */
    public synchronized List<OrderElement> getOrderBy() {
        return Collections.unmodifiableList(new ArrayList<OrderElement>(orderBy));
    }

    // ------------------ Constraint Control ------------------

    /**
     * Adds a PathConstraint to this query. The PathConstraint will be attached to the path in the
     * constraint, which will have already been checked for format (no colons or square brackets),
     * but will not be verified until the verifyQuery() method is called. This method returns a
     * String code which is a single character that can be used in the constraint logic to logically
     * combine constraints. The constraint will be added to the existing constraint logic with the
     * AND operator - for any other arrangement, set the logic after calling this method. If the
     * constraint is already present in the query, then this method will do nothing.
     *
     * @param constraint the PathConstraint to add to this query
     * @return a String constraint code for use in the constraint logic
     * @throws NullPointerException if the constraint is null
     */
    public synchronized String addConstraint(PathConstraint constraint) {
        deVerify();
        if (constraint == null) {
            throw new NullPointerException("Cannot add a null constraint to this query");
        }
        if (constraints.containsKey(constraint)) {
            return constraints.get(constraint);
        }
        if (constraint instanceof PathConstraintSubclass) {
            // Subclass constraints don't get a code
            constraints.put(constraint, null);
            return null;
        }
        Set<String> usedCodes = new HashSet<String>(constraints.values());
        char charCode = 'A';
        String code = "A";
        while (usedCodes.contains(code)) {
            charCode++;
            code = "" + charCode;
        }
        if (logic == null) {
            logic = new LogicExpression(code);
        } else {
            logic = new LogicExpression("(" + logic.toString() + ") AND " + code);
        }
        constraints.put(constraint, code);
        return code;
    }

    /**
     * Adds a PathConstraints to this query, associated with a given constraint code. The
     * PathConstraint will be attached to the path in the constraint, which will have already been
     * checked for format (no colons or square brackets), but will not be verified until the
     * verifyQuery() method is called. If the given code is already in use by a different
     * constraint, or if the constraint already has a different code, then an exception is thrown.
     * The new constraint will be added to the existing constraint logic with the AND operator -
     * for any other arrangement, set the logic after calling this method. If the constraint is
     * already present in the query with the same constraint code, then this method will do nothing.
     *
     * @param constraint the PathConstraint to add to this query
     * @param code the constraint code to associate with this constraint. This must be a
     *             string consisting of one of the following characters "A","B","C","D","E","F","G",
     *             "H","I","J","K","L","M","N","O","P","Q","R","S","T","U","V","W","X","Y","Z".
     * @throws NullPointerException if the constraint or the code is null
     * @throws IllegalStateException if the constraint is already associated with a different code,
     * or the code is already associated with a different constraint
     * @throws IllegalArgumentException if the code is in an inappropriate format - that is, if it
     * is not a single uppercase character
     */
    public synchronized void addConstraint(PathConstraint constraint, String code) {
        deVerify();
        if (constraint == null) {
            throw new NullPointerException("Cannot add a null constraint to this query");
        }
        if (constraint instanceof PathConstraintSubclass) {
            throw new IllegalArgumentException("Cannot associate a code with a subclass constraint."
                    + " Use the addConstraint(PathConstraint) method instead");
        }
        if (code == null) {
            throw new NullPointerException("Cannot use a null code for a constraint in this query");
        }
        if ((code.length() != 1) || (code.charAt(0) > MAX_CODE) || (code.charAt(0) < MIN_CODE)) {
            throw new IllegalArgumentException(
                    "The constraint code must be a single plain latin " + "uppercase character");
        }
        if (constraints.containsKey(constraint)) {
            if (code.equals(constraints.get(constraint))) {
                // Trying to add an identical constraint at the same code.
                return;
            } else {
                throw new IllegalStateException(
                        "Given constraint is already associated with code " + constraints.get(constraint)
                                + " - cannot associate with code " + code + "for query " + this.toString());
            }
        }
        Set<String> usedCodes = new HashSet<String>(constraints.values());
        if (usedCodes.contains(code)) {
            throw new IllegalStateException("Given code " + code + " from constraint " + constraint
                    + " conflicts with an existing constraint for query " + this.toString());
        }
        if (logic == null) {
            logic = new LogicExpression(code);
        } else {
            logic = new LogicExpression("(" + logic.toString() + ") AND " + code);
        }
        constraints.put(constraint, code);
    }

    /**
     * Removes a constraint from this query. The PathConstraint should be a constraint that already
     * exists in this query. The constraint will also be removed from the constraint logic.
     *
     * @param constraint the PathConstraint to remove from this query
     * @throws NullPointerException if the constraint is null
     * @throws NoSuchElementException if the constraint is not present in the query
     */
    public synchronized void removeConstraint(PathConstraint constraint) {
        deVerify();
        if (constraint == null) {
            throw new NullPointerException("Cannot remove null constraint from this query");
        }
        if (constraints.containsKey(constraint)) {
            String code = constraints.remove(constraint);
            if (code != null) {
                if (logic.getVariableNames().size() > 1) {
                    logic.removeVariable(code);
                } else {
                    logic = null;
                }
            }
        } else {
            throw new NoSuchElementException("Constraint to remove is not present in query");
        }
    }

    /**
     * Replaces a constraint in the query with a different, carrying over the constraint code, and
     * preserving the constraint logic. The new PathConstraint will be attached to the path in the
     * constraint, which will have already been checked for format (no colons or square brackets),
     * but will not be verified until the verifyQuery() method is called. This method preserves the
     * order of constraints - that is, the replacement will be swapped in where the old constraint
     * was.
     *
     * @param old the old PathConstraint object
     * @param replacement the new PathConstraint object to replace it
     * @throws NullPointerException if old or replacement are null
     * @throws NoSuchElementException if the old PathConstraint is not already in the query
     * @throws IllegalArgumentException if the code from the old constraint is not appropriate to
     * the replacement constraint
     * @throws IllegalStateException if the replacement is already in the query
     */
    public synchronized void replaceConstraint(PathConstraint old, PathConstraint replacement) {
        deVerify();
        if (old == null) {
            throw new NullPointerException("Cannot replace a null constraint");
        }
        if (replacement == null) {
            throw new NullPointerException("Cannot replace a constraint with null");
        }
        if (!constraints.containsKey(old)) {
            throw new NoSuchElementException("Old constraint is not in the query");
        }
        if (constraints.containsKey(replacement)) {
            throw new IllegalStateException("Replacement constraint is already in the query");
        }
        String code = constraints.get(old);
        // Read the next line very carefully!
        if ((replacement instanceof PathConstraintSubclass) != (code == null)) {
            throw new IllegalArgumentException("Cannot replace a " + old.getClass().getSimpleName() + " with a "
                    + replacement.getClass().getSimpleName());
        }
        Map<PathConstraint, String> temp = new LinkedHashMap<PathConstraint, String>(constraints);
        constraints.clear();
        for (Map.Entry<PathConstraint, String> entry : temp.entrySet()) {
            if (old.equals(entry.getKey())) {
                constraints.put(replacement, code);
            } else {
                constraints.put(entry.getKey(), entry.getValue());
            }
        }
    }

    /**
     * Clears the entire set of constraints from this query, and resets the constraint logic.
     */
    public synchronized void clearConstraints() {
        deVerify();
        constraints.clear();
        logic = null;
    }

    /**
     * Adds a collection of constraints to this query. The PathConstraints will be attached to the
     * paths in the constraints, which will have already been checked for format (no colons or
     * square brackets), but will not be verified until the verifyQuery() method is called. The
     * constraints will all be given codes, and added to the constraint logic with the default AND
     * operator. To discover the codes, use the getConstraints() method. If there is an error with
     * any of the elements in the collection, then none of the elements will be added and the
     * query will be unchanged.
     *
     * @param constraintList the PathConstraint objects to add to this query
     * @throws NullPointerException if constraints is null, or if it contains a null element
     */
    public synchronized void addConstraints(Collection<PathConstraint> constraintList) {
        deVerify();
        if (constraintList == null) {
            throw new NullPointerException("Cannot add null collection of PathConstraints to this " + "query");
        }
        for (PathConstraint constraint : constraintList) {
            if (constraint == null) {
                throw new NullPointerException(
                        "Cannot add null PathConstraint (from collection \"" + constraintList + "\" to this query");
            }
        }
        for (PathConstraint constraint : constraintList) {
            addConstraint(constraint);
        }
    }

    /**
     * Adds a group of constraints to this query. The PathConstraints will be attached to the paths
     * in the constraints, which will have already been checked for format (no colons or square
     * brackets), but will not be verified until the verifyQuery() method is called. The constraints
     * will all be given codes, and added to the constraint logic with the default AND operator. To
     * discover the codes, use the getConstraints() method. If there is an error with any of the
     * elements in the array/varargs, then none of the elements will be added and the query will be
     * unchanged.
     *
     * @param constraintList the PathConstraint objects to add to this query
     * @throws NullPointerException if constraints is null, or if it contains a null element
     */
    public synchronized void addConstraints(PathConstraint... constraintList) {
        deVerify();
        if (constraintList == null) {
            throw new NullPointerException("Cannot add null array of PathConstraints to this " + "query");
        }
        for (PathConstraint constraint : constraintList) {
            if (constraint == null) {
                throw new NullPointerException(
                        "Cannot add null PathConstraint (from array \"" + constraintList + "\" to this query");
            }
        }
        for (PathConstraint constraint : constraintList) {
            addConstraint(constraint);
        }
    }

    /**
     * Returns a Map of all the constraints in this query, from PathConstraint to the constraint
     * code used in the constraint logic. This returns an unmodifiable copy of the data in the
     * query at the moment this method is executed, so further changes to the query are not
     * reflected in the returned value.
     *
     * @return a Map from PathConstraint to String constraint code (a single character)
     */
    public synchronized Map<PathConstraint, String> getConstraints() {
        Map<PathConstraint, String> retval = new LinkedHashMap<PathConstraint, String>(constraints);
        return retval;
    }

    public synchronized Map<PathConstraint, String> getRelevantConstraints() {
        return getConstraints(); // Simple alias. All constraints are relevant.
    }

    /**
     * Returns the PathConstraint associated with a given code.
     *
     * @param code a single uppercase character
     * @return a PathConstraint object
     * @throws NullPointerException if code is null
     * @throws NoSuchElementException if there is no PathConstraint for that code
     */
    public synchronized PathConstraint getConstraintForCode(String code) {
        for (Map.Entry<PathConstraint, String> entry : constraints.entrySet()) {
            if (code.equals(entry.getValue())) {
                return entry.getKey();
            }
        }
        throw new NoSuchElementException(
                "No constraint is associated with code " + code + ", valid codes are " + constraints.values());
    }

    /**
     * Returns a list of PathConstraints applied to a given path or an empty list.
     *
     * @param path the path to fetch constraints for
     * @return a List of PathConstraints or an empty list
     */
    public synchronized List<PathConstraint> getConstraintsForPath(String path) {
        List<PathConstraint> retval = new ArrayList<PathConstraint>();
        for (PathConstraint con : constraints.keySet()) {
            if (con.getPath().equals(path)) {
                retval.add(con);
            }
        }
        return Collections.unmodifiableList(retval);
    }

    /**
     * Return the constraint codes used in the query, some constraint types (subclasses) don't
     * get assigned a code, these are not included.  This method returns all of the codes that
     * should be involved in the logic expression of the query.
     * @return the constraint codes used in this query
     */
    public synchronized Set<String> getConstraintCodes() {
        Set<String> codes = new HashSet<String>();
        for (String code : constraints.values()) {
            if (code != null) {
                codes.add(code);
            }
        }
        return codes;
    }

    // ------------------- Constraint Logic Control -------------------

    /**
     * Returns the current constraint logic. The logic is returned in groups, according to the outer
     * join layout of the query. Two codes in separate groups can only be combined with an AND
     * operation and OR operation.
     *
     * @return the current constraint logic
     */
    public synchronized String getConstraintLogic() {
        return (logic == null ? "" : logic.toString());
    }

    public synchronized LogicExpression getLogicExpression() {
        return logic;
    }

    /**
     * Sets the current constraint logic.
     *
     * @param logic the constraint logic
     */
    public synchronized void setConstraintLogic(String logic) {
        // TODO method does not work properly allowing (A and B) or C on Outer Joins
        deVerify();
        if (constraints.isEmpty()) {
            this.logic = null;
        } else {
            this.logic = new LogicExpression(logic);
            for (String code : constraints.values()) {
                if (!this.logic.getVariableNames().contains(code)) {
                    this.logic = new LogicExpression("(" + this.logic.toString() + ") and " + code);
                }
            }
            this.logic.removeAllVariablesExcept(constraints.values());
        }
    }

    // --------------------- Outer Joined-ness Control --------------------

    /**
     * Returns the outer join status of the last part of a given path in this query. The given path
     * expression should not contain any colons to represent outer joins, and should not contain
     * any square brackets to represent subclass constraints.
     *
     * @param path a String path to check
     * @return an OuterJoinStatus object, or null if no information is held
     * @throws NullPointerException if path is null
     * @throws IllegalArgumentException if the path String contains colons or square brackets, or is
     * otherwise in a bad format
     */
    public synchronized OuterJoinStatus getOuterJoinStatus(String path) {
        checkPathFormat(path);
        return outerJoinStatus.get(path);
    }

    /**
     * Sets the outer join status of the last part of a given path in this query. The given path
     * expression should not contain any colons to represent outer joins, and should not contain
     * any square brackets to represent subclass constraints. To remove outer join status from a
     * path, call this method with a null status.
     *
     * @param path a String path to set
     * @param status an OuterJoinStatus object
     * @throws NullPointerException if path is null
     * @throws IllegalArgumentException if the path String contains colons or square brackets, or is
     * otherwise in a bad format
     */
    public synchronized void setOuterJoinStatus(String path, OuterJoinStatus status) {
        deVerify();
        checkPathFormat(path);
        if (status == null) {
            outerJoinStatus.remove(path);
        } else {
            outerJoinStatus.put(path, status);
        }
    }

    /**
     * Returns an unmodifiable Map which is a copy of the current outer join status of this query at
     * the time of execution of this method. Further changes to this object will not be reflected in
     * the object that was returned from this method.
     *
     * @return a Map from String path to OuterJoinStatus
     */
    public synchronized Map<String, OuterJoinStatus> getOuterJoinStatus() {
        return Collections.unmodifiableMap(new LinkedHashMap<String, OuterJoinStatus>(outerJoinStatus));
    }

    /**
     * Clears all outer join status data from this query.
     */
    public synchronized void clearOuterJoinStatus() {
        deVerify();
        outerJoinStatus.clear();
    }

    /**
     * Returns a Map from path to TRUE for all paths that are outer joined. That is, if the path is
     * an outer join (not referring to its parents - use isCompletelyInner() for that), then it is
     * present in this map mapped onto the value TRUE.
     *
     * @return a Map from String to Boolean TRUE
     */
    public synchronized Map<String, Boolean> getOuterMap() {
        Map<String, Boolean> retval = new HashMap<String, Boolean>();
        for (Map.Entry<String, OuterJoinStatus> stat : outerJoinStatus.entrySet()) {
            if (OuterJoinStatus.OUTER.equals(stat.getValue())) {
                retval.put(stat.getKey(), Boolean.TRUE);
            }
        }
        return retval;
    }

    // -------------------- Path Description Control --------------------

    /**
     * Returns the description for a given path, or null if no description is registered. The given
     * path expression should not contain any colons to represent outer joins, and should not
     * contain any square brackets to represent subclass constraints.
     *
     * @param path a String path to check
     * @return a String description
     * @throws NullPointerException if path is null
     * @throws IllegalArgumentException if the path String contains colons or square brackets, or is
     * otherwise in a bad format
     */
    public synchronized String getDescription(String path) {
        checkPathFormat(path);
        return descriptions.get(path);
    }

    /**
     * Sets the description for a given path. The given path expression should not contain any
     * colons to represent outer joins, and should not contain any square brackets to represent
     * subclass constraints. To clear the description on a path, call this method with a null
     * description.
     *
     * @param path the String path to set
     * @param description a String description or null
     * @throws NullPointerException if path is null
     * @throws IllegalArgumentException if the path String contains colons or square brackets, or is
     * otherwise in a bad format
     */
    public synchronized void setDescription(String path, String description) {
        deVerify();
        checkPathFormat(path);
        if (description == null) {
            descriptions.remove(path);
        } else {
            descriptions.put(path, description);
        }
    }

    /**
     * Returns an unmodifiable Map which is a copy of the current set of path descriptions of this
     * query at the time of execution of this method. Further changes to this object will not
     * be reflected in the object that was returned from this method.
     *
     * @return a Map from String path to description
     */
    public synchronized Map<String, String> getDescriptions() {
        return Collections.unmodifiableMap(new LinkedHashMap<String, String>(descriptions));
    }

    /**
     * Removes all path descriptions from this query.
     */
    public synchronized void clearDescriptions() {
        deVerify();
        descriptions.clear();
    }

    /**
     * Returns the path description for the given path. The description is computed from the set
     * descriptions of parent classes.
     *
     * @param path a String path with no square brackets or colons
     * @return a String description
     * @throws NullPointerException is path is null
     * @throws IllegalArgumentException if the path String contains colons or square brackets, or is
     * otherwise in a bad format
     */
    public synchronized String getGeneratedPathDescription(String path) {
        checkPathFormat(path);
        String retval = descriptions.get(path);
        if (retval == null) {
            int lastDot = path.lastIndexOf('.');
            if (lastDot == -1) {
                return path;
            } else {
                return getGeneratedPathDescription(path.substring(0, lastDot)) + " > "
                        + path.substring(lastDot + 1);
            }
        } else {
            return retval;
        }
    }

    /**
     * Returns the paths descriptions for the view.
     * @param pq
     * @return A list of column names
     */
    public List<String> getColumnHeaders() {
        List<String> columnNames = new ArrayList<String>();
        for (String viewString : getView()) {
            columnNames.add(getGeneratedPathDescription(viewString));
        }
        return columnNames;
    }

    // -------------------- Query description control --------------------
    // The two attributes description and title are used for display
    // in various queries contexts.

    /**
     * Sets the description for this PathQuery.
     *
     * @param description the new description, or null for none
     */
    public synchronized void setDescription(String description) {
        deVerify();
        this.description = description;
    }

    /**
     * Gets the description for this PathQuery.
     *
     * @return description
     */
    public synchronized String getDescription() {
        return description;
    }

    /**
     * Gets the title for this query.
     * @return The title of the query
     */
    public String getTitle() {
        return title;
    }

    /**
     * Sets the name of the query.
     * @param title the new title, or null for none.
     */
    public void setTitle(String title) {
        deVerify();
        this.title = title;
    }

    // -------------------- Removals --------------------

    /**
     * Removes everything under a given path from the query, such that if the query was valid
     * before, it will be valid after this method.
     *
     * @param path everything under this path will be removed from the query
     * @throws NullPointerException is path is null
     * @throws IllegalArgumentException if the path String contains colons or square brackets, or is
     * otherwise in a bad format
     */
    public synchronized void removeAllUnder(String path) {
        checkPathFormat(path);
        deVerify();
        for (String v : getView()) {
            if (isPathUnder(path, v)) {
                removeView(v);
            }
        }
        for (OrderElement order : getOrderBy()) {
            if (isPathUnder(path, order.getOrderPath())) {
                removeOrderBy(order.getOrderPath());
            }
        }
        for (PathConstraint con : getConstraints().keySet()) {
            if (isPathUnder(path, con.getPath())) {
                removeConstraint(con);
            }
        }
        for (String join : getOuterJoinStatus().keySet()) {
            if (isPathUnder(path, join)) {
                setOuterJoinStatus(join, null);
            }
        }
        for (String desc : getDescriptions().keySet()) {
            if (isPathUnder(path, desc) && !isAnyViewWithPathUnder(desc)) {
                setDescription(desc, null);
            }
        }
    }

    private static boolean isPathUnder(String parent, String child) {
        if (parent.equals(child)) {
            return true;
        }
        return child.startsWith(parent + ".");
    }

    private boolean isAnyViewWithPathUnder(String parent) {
        for (String v : getView()) {
            if (parent.equals(v) || v.startsWith(parent + ".")) {
                return true;
            }
        }
        return false;
    }

    /**
     * Removes everything from this query that is irrelevant, and therefore making the query
     * invalid. If the query is invalid for other reasons, then this method will either throw an
     * exception or ignore that part of the query, depending on the error, however the query is
     * unlikely to be made valid.
     *
     * @throws PathException if the query is invalid for a reason other than irrelevance
     */
    public synchronized void removeAllIrrelevant() throws PathException {
        deVerify();
        List<String> problems = new ArrayList<String>();
        // Validate subclass constraints and build subclass constraint map
        buildSubclassMap(problems);
        rootClass = null;
        Set<String> validMainPaths = new LinkedHashSet<String>();
        // Validate view paths
        validateView(problems, validMainPaths);
        // Validate constraints
        validateConstraints(problems, validMainPaths);
        // Now the validMainPaths set contains all the main (ie class) paths that are permitted in
        // this query.
        if (!(problems.isEmpty() || Arrays.asList(NO_VIEW_ERROR).equals(problems))) {
            throw new PathException(problems.toString(), null);
        }
        for (OrderElement order : getOrderBy()) {
            Path path = new Path(model, order.getOrderPath(), subclasses);
            if (path.endIsAttribute()) {
                path = path.getPrefix();
            }
            if (!validMainPaths.contains(path.getNoConstraintsString())) {
                removeOrderBy(order.getOrderPath());
            }
        }
        for (String join : getOuterJoinStatus().keySet()) {
            Path path = new Path(model, join, subclasses);
            if (path.endIsAttribute()) {
                path = path.getPrefix();
            }
            if (!validMainPaths.contains(path.getNoConstraintsString())) {
                setOuterJoinStatus(join, null);
            }
        }
        for (String desc : getDescriptions().keySet()) {
            Path path = new Path(model, desc, subclasses);
            if (path.endIsAttribute()) {
                path = path.getPrefix();
            }
            if (!validMainPaths.contains(path.getNoConstraintsString())) {
                setDescription(desc, null);
            }
        }
    }

    /**
     * Fixes up the order by list and the constraint logic, given the arrangement of outer joins in
     * the query.
     *
     * @return a List of messages about the changes that this method has made to the query
     * @throws PathException if the query is invalid in any way other than that which this method
     * will fix.
     */
    public synchronized List<String> fixUpForJoinStyle() throws PathException {
        deVerify();
        List<String> problems = new ArrayList<String>();
        // Validate subclass constraints and build subclass constraint map
        buildSubclassMap(problems);
        rootClass = null;
        Set<String> validMainPaths = new LinkedHashSet<String>();
        // Validate view paths
        validateView(problems, validMainPaths);
        // Validate constraints
        validateConstraints(problems, validMainPaths);
        // Now the validMainPaths set contains all the main (ie class) paths that are permitted in
        // this query.
        validateOuterJoins(problems, validMainPaths);
        calculateConstraintGroups(problems);
        if (!problems.isEmpty()) {
            throw new PathException(problems.toString(), null);
        }
        List<String> messages = new ArrayList<String>();
        for (OrderElement order : getOrderBy()) {
            // We cannot rely on the query being valid, as we are trying to make it valid!
            String orderString = order.getOrderPath();
            boolean outer = false;
            while (orderString.contains(".")) {
                if (OuterJoinStatus.OUTER.equals(outerJoinStatus.get(orderString))) {
                    outer = true;
                    break;
                }
                orderString = orderString.substring(0, orderString.lastIndexOf('.'));
            }
            if (outer) {
                removeOrderBy(order.getOrderPath());
                messages.add("Removed path " + order.getOrderPath() + " from ORDER BY because of " + "outer joins");
            }
        }
        if (logic != null) {
            List<Set<String>> groups = new ArrayList<Set<String>>(constraintGroups.values());
            try {
                logic.split(groups);
            } catch (IllegalArgumentException e) {
                // The logic is invalid - we need to straighten it up.
                String oldLogic = logic.toString();
                logic = logic.validateForGroups(groups);
                messages.add("Changed constraint logic from " + oldLogic + " to " + logic.toString()
                        + " because of outer joins");
            }
        }
        return messages;
    }

    /**
     * Removes a subclass from the query, and removes any parts of the query that relied on it.
     * Returns a list of messages related to the extra things that had to be removed.
     *
     * @param path the path of the subclass constraint to remove
     * @return a list of messages
     * @throws PathException if the query is already invalid
     * @throws NullPointerException is path is null
     * @throws IllegalArgumentException if the path String contains colons or square brackets, or is
     * otherwise in a bad format
     */
    public synchronized List<String> removeSubclassAndFixUp(String path) throws PathException {
        checkPathFormat(path);
        List<String> problems = verifyQuery();
        if (!problems.isEmpty()) {
            throw new PathException("Query does not verify: " + problems, null);
        }
        deVerify();
        List<String> messages = new ArrayList<String>();
        PathConstraint toRemove = null;
        for (PathConstraint con : getConstraints().keySet()) {
            if (con instanceof PathConstraintSubclass) {
                if (con.getPath().equals(path)) {
                    toRemove = con;
                    break;
                }
            }
        }
        if (toRemove == null) {
            return messages;
        }
        removeConstraint(toRemove);
        buildSubclassMap(problems);
        // Remove things from view
        for (String viewPath : getView()) {
            try {
                @SuppressWarnings("unused")
                Path viewPathObj = new Path(model, viewPath, subclasses);
            } catch (PathException e) {
                // This one is now invalid. Remove
                removeView(viewPath);
                messages.add("Removed path " + viewPath + " from view, because you removed the "
                        + "subclass constraint that it depended on.");
            }
        }
        for (PathConstraint con : getConstraints().keySet()) {
            try {
                @SuppressWarnings("unused")
                Path constraintPath = new Path(model, con.getPath(), subclasses);
                if (con instanceof PathConstraintLoop) {
                    try {
                        @SuppressWarnings("unused")
                        Path loopPath = new Path(model, ((PathConstraintLoop) con).getLoopPath(), subclasses);
                    } catch (PathException e) {
                        removeConstraint(con);
                        messages.add("Removed constraint " + con + " because you removed the "
                                + "subclass constraint it depended on.");
                    }
                }
            } catch (PathException e) {
                removeConstraint(con);
                messages.add("Removed constraint " + con + " because you removed the "
                        + "subclass constraint it depended on.");
            }
        }
        for (OrderElement order : getOrderBy()) {
            try {
                @SuppressWarnings("unused")
                Path orderPath = new Path(model, order.getOrderPath(), subclasses);
            } catch (PathException e) {
                removeOrderBy(order.getOrderPath());
                messages.add("Removed path " + order.getOrderPath() + " from ORDER BY, because you "
                        + "removed the subclass constraint it depended on.");
            }
        }
        for (String join : getOuterJoinStatus().keySet()) {
            try {
                @SuppressWarnings("unused")
                Path joinPath = new Path(model, join, subclasses);
            } catch (PathException e) {
                setOuterJoinStatus(join, null);
            }
        }
        for (String desc : getDescriptions().keySet()) {
            try {
                @SuppressWarnings("unused")
                Path descPath = new Path(model, desc, subclasses);
            } catch (PathException e) {
                setDescription(desc, null);
                messages.add("Removed description on path " + desc + ", because you removed the "
                        + "subclass constraint it depended on.");
            }
        }
        removeAllIrrelevant();

        return messages;
    }

    // -------------------- Other assorted stuff --------------------

    /**
     * Returns a deep copy of this object. The resulting object may be modified without impacting
     * this object.
     *
     * @return a PathQuery
     */
    @Override
    public PathQuery clone() {
        try {
            PathQuery retval = (PathQuery) super.clone();
            retval.view = new ArrayList<String>(retval.view);
            retval.orderBy = new ArrayList<OrderElement>(retval.orderBy);
            retval.constraints = new LinkedHashMap<PathConstraint, String>(retval.constraints);
            if (retval.logic != null) {
                retval.logic = new LogicExpression(retval.logic.toString());
            }
            retval.outerJoinStatus = new LinkedHashMap<String, OuterJoinStatus>(retval.outerJoinStatus);
            retval.descriptions = new LinkedHashMap<String, String>(retval.descriptions);
            return retval;
        } catch (CloneNotSupportedException e) {
            throw new Error("Should never happen", e);
        }
    }

    /**
     * Produces a Path object from the given path String, using subclass information from the query.
     * Note that this method does not verify the query, but merely attempts to extract as much sane
     * subclass information as possible to construct the Path.
     *
     * @param path the String path
     * @return a Path object
     * @throws PathException if something goes wrong, or if the path is in an invalid format
     */
    public synchronized Path makePath(String path) throws PathException {
        Map<String, String> lSubclasses = new HashMap<String, String>();
        for (PathConstraint subclass : constraints.keySet()) {
            if (subclass instanceof PathConstraintSubclass) {
                lSubclasses.put(subclass.getPath(), ((PathConstraintSubclass) subclass).getType());
            }
        }
        return new Path(model, path, lSubclasses);
    }

    public synchronized void deVerify() {
        isVerified = false;
    }

    /**
     * Returns true if the query verifies correctly.
     *
     * @return a boolean
     */
    public boolean isValid() {
        return verifyQuery().isEmpty();
    }

    /**
     * Verifies the contents of this query against the model, and for internal integrity. Returns
     * a list of String problems that would need to be rectified for this query to pass validation
     * and be executed. If the return value is an empty List, then the query is valid.
     * <BR>
     * This method validates a few important characteristics about the query:
     * <UL><LI>All subclass constraints must be subclasses of the class they would otherwise be</LI>
     *     <LI>All paths must validate against the model</LI>
     *     <LI>All paths need to extend from the same root class</LI>
     *     <LI>Paths in the order by list, the outer join status, and the descriptions, must all be
     * attached to classes already defined by the view list and the constraints. Otherwise, it would
     * be possible to change the number of rows by changing the order</LI>
     *     <LI>All elements of the view list and the order by list must be attributes, and all
     * paths for outer join status and subclass constraints must not be attributes</LI>
     *     <LI>Subclass constraints cannot be on the root class of the query</LI>
     *     <LI>Loop constraints cannot cross an outer join</LI>
     *     <LI>Check constraint values against their types in the model and specific
     * characteristics</LI>
     *     <LI>Check constraint logic for sanity and that it can be split into separate ANDed outer
     * join sections</LI>
     * </UL>
     *
     * @return a List of problems
     */
    public synchronized List<String> verifyQuery() {
        List<String> problems = new ArrayList<String>();
        if (isVerified) {
            // Query is already verified correctly. Return no problems
            return problems;
        }
        // Validate subclass constraints and build subclass constraint map
        buildSubclassMap(problems);
        rootClass = null;
        Set<String> validMainPaths = new LinkedHashSet<String>();
        // Validate view paths
        validateView(problems, validMainPaths);
        // Validate constraints
        validateConstraints(problems, validMainPaths);
        // Now the validMainPaths set contains all the main (ie class) paths that are permitted in
        // this query.

        // Validate subclass constraints against validMainPaths
        for (PathConstraint constraint : constraints.keySet()) {
            if (constraint instanceof PathConstraintSubclass) {
                try {
                    Path path = new Path(model, constraint.getPath(), subclasses);
                    if (path.endIsAttribute()) {
                        // Should have already caught this problem above. Ignore and suppress
                        continue;
                    }
                } catch (PathException e) {
                    // Should have already been caught above. Ignore, and suppress further checking
                    continue;
                }
                if (!validMainPaths.contains(constraint.getPath())) {
                    problems.add("Subclass constraint on path " + constraint.getPath()
                            + " is not relevant to the query");
                }
            }
        }

        // Validate outer join paths
        validateOuterJoins(problems, validMainPaths);

        // Validate description paths
        for (String descPath : descriptions.keySet()) {
            try {
                Path path = new Path(model, descPath, subclasses);
                if (path.endIsAttribute()) {
                    path = path.getPrefix();
                }
                if (!validMainPaths.contains(path.getNoConstraintsString())) {
                    problems.add("Description on path " + descPath + " is not relevant to the query");
                    continue;
                }
            } catch (PathException e) {
                problems.add("Path " + descPath + " for description is not in the model");
                continue;
            }
        }

        // Validate order by paths
        for (OrderElement orderPath : orderBy) {
            try {
                Path path = new Path(model, orderPath.getOrderPath(), subclasses);
                if (!path.endIsAttribute()) {
                    problems.add(
                            "Path " + orderPath.getOrderPath() + " in order by list must be " + "an attribute");
                    continue;
                }
                if (!validMainPaths.contains(path.getPrefix().toStringNoConstraints())) {
                    problems.add("Order by element for path " + orderPath.getOrderPath()
                            + " is not relevant to the query");
                    continue;
                }
                if (!rootClass.equals(outerJoinGroups.get(path.getPrefix().getNoConstraintsString()))) {
                    problems.add("Order by element " + orderPath + " is not in the root outer join group");
                }
            } catch (PathException e) {
                problems.add("Path " + orderPath.getOrderPath() + " in order by list is not in the model");
                continue;
            }
        }

        calculateConstraintGroups(problems);

        if (logic != null) {
            if (!doNotVerifyLogic) {
                try {
                    logic.split(new ArrayList<Set<String>>(constraintGroups.values()));
                } catch (IllegalArgumentException e) {
                    problems.add("Logic expression is not compatible with outer join status: " + e.getMessage());
                }
            }
        }

        if (problems.isEmpty()) {
            isVerified = true;
        }
        return problems;
    }

    private void calculateConstraintGroups(List<String> problems) {
        doNotVerifyLogic = false;
        // Put all constraints into groups
        constraintGroups = new LinkedHashMap<String, Set<String>>();
        for (Map.Entry<PathConstraint, String> constraintEntry : constraints.entrySet()) {
            if (constraintEntry.getValue() != null) {
                try {
                    Path path = new Path(model, constraintEntry.getKey().getPath(), subclasses);
                    if (path.getStartClassDescriptor().getUnqualifiedName().equals(rootClass)) {
                        if (path.endIsAttribute()) {
                            path = path.getPrefix();
                        }
                        String groupPath = outerJoinGroups.get(path.getNoConstraintsString());
                        if (groupPath != null) {
                            Set<String> group = constraintGroups.get(groupPath);
                            if (group == null) {
                                group = new HashSet<String>();
                                constraintGroups.put(groupPath, group);
                            }
                            group.add(constraintEntry.getValue());
                        }
                    } else {
                        doNotVerifyLogic = true;
                    }
                } catch (PathException e) {
                    // If this happens, then we have already noted the problem. Ignore.
                    doNotVerifyLogic = true;
                }
            }
            if (constraintEntry.getKey() instanceof PathConstraintLoop) {
                PathConstraintLoop loop = (PathConstraintLoop) constraintEntry.getKey();
                String aGroup = outerJoinGroups.get(loop.getPath());
                String bGroup = outerJoinGroups.get(loop.getLoopPath());
                // If one of these is null, then we must have recorded a problem above. Ignore.
                if ((aGroup != null) && (bGroup != null)) {
                    if (!aGroup.equals(bGroup)) {
                        problems.add("Loop constraint " + loop + " crosses an outer join");
                        continue;
                    }
                }
            }
        }
        for (String group : new HashSet<String>(outerJoinGroups.values())) {
            if (!constraintGroups.containsKey(group)) {
                constraintGroups.put(group, new HashSet<String>());
            }
        }
    }

    private void validateOuterJoins(List<String> problems, Set<String> validMainPaths) {
        for (String joinPath : outerJoinStatus.keySet()) {
            try {
                Path path = new Path(model, joinPath, subclasses);
                if (path.endIsAttribute()) {
                    problems.add("Outer join status on path " + joinPath + " must not be on an attribute");
                    continue;
                }
                if (path.isRootPath()) {
                    problems.add("Outer join status cannot be set on root path " + joinPath);
                    continue;
                }
            } catch (PathException e) {
                problems.add("Path " + joinPath + " for outer join status is not in the model");
                continue;
            }
            if (!validMainPaths.contains(joinPath)) {
                problems.add("Outer join status path " + joinPath + " is not relevant to the " + "query");
            }
        }
        // Calculate outer join groups from the validMainPaths list of paths
        outerJoinGroups = new LinkedHashMap<String, String>();
        for (String validPath : validMainPaths) {
            try {
                Path path = new Path(model, validPath, subclasses);
                while (isInner(path)) {
                    path = path.getPrefix();
                }
                outerJoinGroups.put(validPath, path.getNoConstraintsString());
            } catch (PathException e) {
                // Should never happen, as we have already checked in validateOuterJoins
                throw new Error(e);
            }
        }
    }

    private void validateConstraints(List<String> problems, Set<String> validMainPaths) {
        existingLoops = new HashSet<String>();
        // Validate constraint paths
        for (PathConstraint constraint : constraints.keySet()) {
            try {
                Path path = new Path(model, constraint.getPath(), subclasses);
                if (rootClass == null) {
                    rootClass = path.getStartClassDescriptor().getUnqualifiedName();
                } else {
                    String newRootClass = path.getStartClassDescriptor().getUnqualifiedName();
                    if (!rootClass.equals(newRootClass)) {
                        problems.add("Multiple root classes in query: " + rootClass + " and " + newRootClass);
                        continue;
                    }
                }
                if (path.endIsAttribute()) {
                    addValidPaths(validMainPaths, path.getPrefix());
                } else {
                    addValidPaths(validMainPaths, path);
                }
                if (constraint instanceof PathConstraintAttribute) {
                    if (!path.endIsAttribute()) {
                        problems.add("Constraint " + constraint + " must be on an attribute");
                        continue;
                    }
                    Class<?> valueType = path.getEndType();
                    try {
                        TypeUtil.stringToObject(valueType, ((PathConstraintAttribute) constraint).getValue());
                    } catch (Exception e) {
                        problems.add("Value in constraint " + constraint + " is not in correct "
                                + "format for type of " + DynamicUtil.getFriendlyName(valueType));
                        continue;
                    }
                } else if (constraint instanceof PathConstraintNull) {
                    if (path.isRootPath()) {
                        problems.add("Constraint " + constraint + " cannot be applied to the root path");
                        continue;
                    }
                    // TODO - make IS NULL work on references and collections.
                    //if (constraint.getOp().equals(ConstraintOp.IS_NULL)) {
                    //    if (!path.endIsAttribute()) {
                    //        problems.add("Constraint " + constraint
                    //                + " is invalid - can only set IS NULL on an attribute");
                    //        continue;
                    //    }
                    //}
                } else if (constraint instanceof PathConstraintBag) {
                    // We do not check that the bag exists here. Call getBagNames() and check
                    // elsewhere.
                    if (path.endIsAttribute()) {
                        problems.add("Constraint " + constraint + " must not be on an attribute");
                        continue;
                    }
                } else if (constraint instanceof PathConstraintIds) {
                    if (path.endIsAttribute()) {
                        problems.add("Constraint " + constraint + " must not be on an attribute");
                        continue;
                    }
                } else if (constraint instanceof PathConstraintMultitype) {
                    if (path.endIsAttribute()) {
                        problems.add("Constraint " + constraint + " must be on a class or reference");
                        continue;
                    }
                    for (String typeName : ((PathConstraintMultitype) constraint).getValues()) {
                        ClassDescriptor cd = model.getClassDescriptorByName(typeName);
                        if (cd == null) {
                            problems.add(String.format("Type '%s' named in [%s] is not in the model", typeName,
                                    constraint));
                        } else if (!cd.getAllSuperDescriptors().contains(path.getEndClassDescriptor())) {
                            problems.add(String.format("%s is not a subtype of %s, as required by %s", typeName,
                                    path.getEndClassDescriptor(), constraint));
                        }
                    }
                } else if (constraint instanceof PathConstraintRange) {
                    // Cannot verify these constraints until we try and make the query in the MainHelper.
                } else if (constraint instanceof PathConstraintMultiValue) {
                    if (!path.endIsAttribute()) {
                        problems.add("Constraint " + constraint + " must be on an attribute");
                        continue;
                    }
                    Class<?> valueType = path.getEndType();
                    for (String value : ((PathConstraintMultiValue) constraint).getValues()) {
                        try {
                            TypeUtil.stringToObject(valueType, value);
                        } catch (Exception e) {
                            problems.add("Value (" + value + ") in list in constraint " + constraint
                                    + " is not in correct format for type of "
                                    + DynamicUtil.getFriendlyName(valueType));
                            continue;
                        }
                    }
                } else if (constraint instanceof PathConstraintLoop) {
                    if (path.endIsAttribute()) {
                        problems.add("Constraint " + constraint + " must not be on an attribute");
                        continue;
                    }
                    String loopPathString = ((PathConstraintLoop) constraint).getLoopPath();
                    try {
                        Path loopPath = new Path(model, loopPathString, subclasses);
                        if (loopPath.endIsAttribute()) {
                            problems.add("Loop path in constraint " + constraint + " must not be an attribute");
                            continue;
                        }
                        String newRootClass = loopPath.getStartClassDescriptor().getUnqualifiedName();
                        if (!rootClass.equals(newRootClass)) {
                            problems.add("Multiple root classes in query: " + rootClass + " and " + newRootClass);
                            continue;
                        }
                        addValidPaths(validMainPaths, loopPath);
                        if (constraint.getPath().equals(loopPathString)) {
                            problems.add("Path " + constraint.getPath() + " may not be looped back on itself");
                            continue;
                        }
                        if (model.isGeneratedClassesAvailable()) {
                            Class<?> aClass = path.getEndType();
                            Class<?> bClass = loopPath.getEndType();
                            if (!(aClass.isAssignableFrom(bClass) || bClass.isAssignableFrom(aClass))) {
                                problems.add("Loop constraint " + constraint + " must loop between similar types");
                                continue;
                            }
                        }
                        String loop = ((PathConstraintLoop) constraint).getDescriptiveString();
                        if (existingLoops.contains(loop)) {
                            problems.add("Cannot have two loop constraints between paths " + constraint.getPath()
                                    + " and " + loopPathString);
                            continue;
                        }
                        existingLoops.add(loop);
                    } catch (PathException e) {
                        problems.add("Path " + loopPathString + " in loop constraint from " + constraint.getPath()
                                + " is not in the model");
                        continue;
                    }
                } else if (constraint instanceof PathConstraintLookup) {
                    if (path.endIsAttribute()) {
                        problems.add("Constraint " + constraint + " must not be on an attribute");
                    }
                } else if (constraint instanceof PathConstraintSubclass) {
                    // Do nothing
                } else {
                    problems.add("Unrecognised constraint type " + constraint.getClass().getName());
                    continue;
                }
            } catch (PathException e) {
                if (!(constraint instanceof PathConstraintSubclass)) {
                    problems.add("Path " + constraint.getPath() + " in constraint is not in the model");
                }
            }
        }
    }

    private Set<String> validateView(List<String> problems, Set<String> validMainPaths) {
        if (view.isEmpty()) {
            problems.add(NO_VIEW_ERROR);
        } else {
            for (String viewPath : view) {
                try {
                    Path path = new Path(model, viewPath, subclasses);
                    if (!path.endIsAttribute()) {
                        problems.add("Path " + viewPath + " in view list must be an attribute");
                        continue;
                    }
                    if (rootClass == null) {
                        rootClass = path.getStartClassDescriptor().getUnqualifiedName();
                    } else {
                        String newRootClass = path.getStartClassDescriptor().getUnqualifiedName();
                        if (!rootClass.equals(newRootClass)) {
                            problems.add("Multiple root classes in query: " + rootClass + " and " + newRootClass);
                            continue;
                        }
                    }
                    addValidPaths(validMainPaths, path.getPrefix());
                } catch (PathException e) {
                    problems.add("Path " + viewPath + " in view list is not in the model");
                }
            }
        }
        return validMainPaths;
    }

    private void buildSubclassMap(List<String> problems) {
        List<PathConstraintSubclass> subclassConstraints = new ArrayList<PathConstraintSubclass>();
        for (PathConstraint constraint : constraints.keySet()) {
            if (constraint instanceof PathConstraintSubclass) {
                subclassConstraints.add((PathConstraintSubclass) constraint);
            }
        }
        PathConstraintSubclass[] subclassConstraintArray = subclassConstraints
                .toArray(new PathConstraintSubclass[0]);
        Arrays.sort(subclassConstraintArray, new Comparator<PathConstraintSubclass>() {
            @Override
            public int compare(PathConstraintSubclass o1, PathConstraintSubclass o2) {
                return o1.getPath().length() - o2.getPath().length();
            }
        });
        // subclassConstraintArray should now be in order of increasing length of path string, so it
        // should be fine to just build the subclass constraints map
        subclasses = new LinkedHashMap<String, String>();
        for (PathConstraintSubclass subclass : subclassConstraintArray) {
            if (subclasses.containsKey(subclass.getPath())) {
                problems.add("Cannot have multiple subclass constraints on path " + subclass.getPath());
                continue;
            }
            Path subclassPath = null;
            try {
                subclassPath = new Path(model, subclass.getPath(), subclasses);
            } catch (PathException e) {
                problems.add("Path " + subclass.getPath() + " (from subclass constraint) is not in" + " the model");
                continue;
            }
            if (subclassPath.isRootPath()) {
                problems.add("Root node " + subclass.getPath() + " may not have a subclass constraint");
                continue;
            }
            if (subclassPath.endIsAttribute()) {
                problems.add(
                        "Path " + subclass.getPath() + " (from subclass constraint) must not " + "be an attribute");
                continue;
            }

            ClassDescriptor subclassDesc = model.getClassDescriptorByName(subclass.getType());
            if (model.isGeneratedClassesAvailable()) {
                Class<?> parentClassType = subclassPath.getEndClassDescriptor().getType();
                Class<?> subclassType = (subclassDesc == null ? null : subclassDesc.getType());
                if (subclassType == null) {
                    problems.add("Subclass " + subclass.getType() + " (for path " + subclass.getPath()
                            + ") is not in the model");
                    continue;
                }
                if (!parentClassType.isAssignableFrom(subclassType)) {
                    problems.add("Subclass constraint on path " + subclass.getPath() + " (type "
                            + DynamicUtil.getFriendlyName(parentClassType) + ") restricting to type "
                            + DynamicUtil.getFriendlyName(subclassType) + " is not possible, as it is "
                            + "not a subclass");
                    continue;
                }
            }
            subclasses.put(subclass.getPath(), subclass.getType());
        }
    }

    /**
     * Returns the root path for this query, if the query verifies correctly.
     *
     * @return a String path which is the root class
     * @throws PathException if the query does not verify
     */
    public synchronized String getRootClass() throws PathException {
        List<String> problems = verifyQuery();
        // For the purposes of this method, we will permit empty views.
        if (problems.isEmpty() || Arrays.asList(NO_VIEW_ERROR).equals(problems)) {
            return rootClass;
        }
        throw new PathException("Query does not verify: " + problems, null);
    }

    /**
     * Returns the subclass Map for this query, if the query verifies correctly.
     *
     * @return a Map from path String to subclass name, for all PathConstraintSubclass objects
     * @throws PathException if the query does not verify
     */
    public synchronized Map<String, String> getSubclasses() throws PathException {
        List<String> problems = verifyQuery();
        if (problems.isEmpty() || Arrays.asList(NO_VIEW_ERROR).equals(problems)) {
            return Collections.unmodifiableMap(new LinkedHashMap<String, String>(subclasses));
        }
        throw new PathException("Query does not verify: " + problems, null);
    }

    /**
     * Returns true if the query has no features yet.
     * @return whether or not this query is empty.
     */
    public synchronized boolean isEmpty() {
        return view.isEmpty() && constraints.isEmpty();
    }

    /**
     * Returns all bag names used in constraints on this query.
     *
     * @return the bag names used in this query or an empty set
     */
    public synchronized Set<String> getBagNames() {
        Set<String> bagNames = new HashSet<String>();
        for (PathConstraint constraint : constraints.keySet()) {
            if (constraint instanceof PathConstraintBag) {
                bagNames.add(((PathConstraintBag) constraint).getBag());
            }
        }
        return bagNames;
    }

    /**
     * Returns the outer join groups map for this query, if the query verifies correctly. This is a
     * Map from all the class paths in the query to the outer join group, represented by the path of
     * the root of the group.
     *
     * @return a Map from path String to the outer join group it is in
     * @throws PathException if the query does not verify
     */
    public synchronized Map<String, String> getOuterJoinGroups() throws PathException {
        List<String> problems = verifyQuery();
        if (problems.isEmpty() || Arrays.asList(NO_VIEW_ERROR).equals(problems)) {
            return Collections.unmodifiableMap(new LinkedHashMap<String, String>(outerJoinGroups));
        }
        throw new PathException("Query does not verify: " + problems, null);
    }

    /**
     * Returns the set of loop constraint descriptive strings, for the purpose of checking for
     * uniqueness.
     *
     * @return a Set of Strings
     * @throws PathException if the query does not verify
     */
    public synchronized Set<String> getExistingLoops() throws PathException {
        List<String> problems = verifyQuery();
        if (problems.isEmpty() || Arrays.asList(NO_VIEW_ERROR).equals(problems)) {
            return Collections.unmodifiableSet(new HashSet<String>(existingLoops));
        }
        throw new PathException("Query does not verify: " + problems, null);
    }

    /**
     * Returns the outer join group that the given path is in.
     *
     * @param stringPath a pathString
     * @return a String representing the outer join group that the path is in
     * @throws NullPointerException if pathString is null
     * @throws PathException if the query is invalid or the path is invalid
     * @throws NoSuchElementException is the path is not in the query
     */
    public String getOuterJoinGroup(String stringPath) throws PathException {
        if (stringPath == null) {
            throw new NullPointerException("stringPath is null");
        }
        Map<String, String> groups = getOuterJoinGroups();
        Path path = makePath(stringPath);
        if (path.endIsAttribute()) {
            path = path.getPrefix();
        }
        if (!groups.containsKey(path.getNoConstraintsString())) {
            throw new NoSuchElementException("Path " + stringPath + " is not in the query");
        }
        return groups.get(path.getNoConstraintsString());
    }

    /**
     * Returns true if a path string is in the root outer join group of this query.
     *
     * @param stringPath a path String
     * @return true if the given path is in the root outer join group, false if it contains outer
     * joins
     * @throws NullPointerException if pathString is null
     * @throws PathException if the query is invalid or the path is invalid
     * @throws NoSuchElementException if the path is not in the query
     */
    public boolean isPathCompletelyInner(String stringPath) throws PathException {
        String root = getRootClass();
        return root.equals(getOuterJoinGroup(stringPath));
    }

    /**
     * Returns the set of paths that could feasibly be loop constrained onto the given path, given
     * the current outer join situation. A candidate path must be a class path, of the same type,
     * and in the same outer join group. It must also not be already looped onto this path.
     *
     * @param stringPath a path String
     * @return a Set of path strings that could be looped onto the given path
     * @throws NullPointerException if stringPath is null
     * @throws IllegalArgumentException if stringPath refers to an attribute
     * @throws PathException if the query is invalid or stringPath is invalid
     */
    public synchronized Set<String> getCandidateLoops(String stringPath) throws PathException {
        if (stringPath == null) {
            throw new NullPointerException("stringPath is null");
        }
        Path path = makePath(stringPath);
        if (path.endIsAttribute()) {
            throw new IllegalArgumentException("stringPath \"" + stringPath + "\" is an attribute, not a class");
        }
        String lRootClass = getRootClass();
        String rootOfStringPath = path.getStartClassDescriptor().getUnqualifiedName();
        if ((lRootClass != null) && (!lRootClass.equals(rootOfStringPath))) {
            throw new NoSuchElementException("Path " + stringPath + " is not in the query");
        }
        if (lRootClass == null) {
            outerJoinGroups.put(rootOfStringPath, rootOfStringPath);
        }
        Map<String, String> groups = new HashMap<String, String>(getOuterJoinGroups());
        Path groupPath = path;
        Set<String> toAdd = new HashSet<String>();
        while (!(groups.containsKey(groupPath.getNoConstraintsString()))) {
            toAdd.add(groupPath.toStringNoConstraints());
            if (groupPath.isRootPath()) {
                break;
            }
            groupPath = groupPath.getPrefix();
        }
        String group = groups.get(groupPath.getNoConstraintsString());
        for (String toAddElement : toAdd) {
            groups.put(toAddElement, group);
        }
        Class<?> type = path.getEndType();
        Set<String> lExistingLoops = getExistingLoops();
        Set<String> retval = new HashSet<String>();
        for (Map.Entry<String, String> entry : groups.entrySet()) {
            if (!entry.getKey().equals(stringPath)) {
                Path entryPath = makePath(entry.getKey());
                if (type.isAssignableFrom(entryPath.getEndType())
                        || entryPath.getEndType().isAssignableFrom(type)) {
                    if (group != null && group.equals(entry.getValue())) {
                        String desc = stringPath.compareTo(entry.getKey()) > 0
                                ? entry.getKey() + " -- " + stringPath
                                : stringPath + " -- " + entry.getKey();
                        if (!lExistingLoops.contains(desc)) {
                            retval.add(entry.getKey());
                        }
                    }
                }
            }
        }
        return retval;
    }

    /**
     * Returns the outer join constraint codes groups map for this query, if the query verifies
     * correctly.
     *
     * @return a Map from outer join group to the Set of constraint codes in the group
     * @throws PathException if the query does not verify
     */
    public synchronized Map<String, Set<String>> getConstraintGroups() throws PathException {
        List<String> problems = verifyQuery();
        if (problems.isEmpty() || Arrays.asList(NO_VIEW_ERROR).equals(problems)) {
            return Collections.unmodifiableMap(new LinkedHashMap<String, Set<String>>(constraintGroups));
        }
        throw new PathException("Query does not verify: " + problems, null);
    }

    /**
     * Returns a List of logic Strings according to the different outer join sections of the query.
     *
     * @return a List of String
     * @throws PathException if the query does not verify
     */
    public synchronized List<String> getGroupedConstraintLogic() throws PathException {
        if (logic == null) {
            return Collections.emptyList();
        }
        Map<String, Set<String>> groups = getConstraintGroups();
        List<LogicExpression> grouped = logic.split(new ArrayList<Set<String>>(groups.values()));
        List<String> retval = new ArrayList<String>();
        for (LogicExpression group : grouped) {
            if (group != null) {
                retval.add(group.toString());
            }
        }
        return retval;
    }

    /**
     * Returns the constraint logic for the given outer join group, if the query verifies correctly.
     *
     * @param group an outer join group
     * @return the constraint logic for the constraints in that outer join group
     * @throws PathException if the query does not verify
     * @throws IllegalArgumentException if the group is not present in this query
     */
    public synchronized LogicExpression getConstraintLogicForGroup(String group) throws PathException {
        List<String> problems = verifyQuery();
        if (problems.isEmpty()) {
            if (logic == null) {
                return null;
            } else {
                Set<String> codes = constraintGroups.get(group);
                if (codes == null) {
                    throw new IllegalArgumentException("Outer join group " + group
                            + " does not seem to be in this query. Valid inputs are " + constraintGroups.keySet());
                }
                if (codes.isEmpty()) {
                    return null;
                } else {
                    return logic.getSection(codes);
                }
            }
        }
        throw new PathException("Query does not verify: " + problems, null);
    }

    /**
     * Adds all the parts of a Path to a Set. Call this with only a non-attribute Path.
     *
     * @param validMainPaths a Set of Strings to add to
     * @param path a Path object
     */
    private static void addValidPaths(Set<String> validMainPaths, Path path) {
        Path pathToAdd = path;
        while (!pathToAdd.isRootPath()) {
            validMainPaths.add(pathToAdd.toStringNoConstraints());
            pathToAdd = pathToAdd.getPrefix();
        }
        validMainPaths.add(pathToAdd.toStringNoConstraints());
    }

    /**
     * Returns true if the given Path object represents a path that is inner-joined onto the parent
     * path in this query. This will return false for the root class. Do not call this method with
     * a Path that is an attribute.
     *
     * @param path a Path object
     * @return true if the join is inner, not outer and not the root
     * @throws IllegalArgumentException if the path is an attribute
     */
    private boolean isInner(Path path) {
        if (path.isRootPath()) {
            return false;
        }
        if (path.endIsAttribute()) {
            throw new IllegalArgumentException("Cannot call isInner() with a path that is an " + "attribute");
        }
        OuterJoinStatus status = getOuterJoinStatus(path.getNoConstraintsString());
        if (OuterJoinStatus.INNER.equals(status)) {
            return true;
        } else if (OuterJoinStatus.OUTER.equals(status)) {
            return false;
        }
        // Fall back on defaults
        return true;
    }

    private static final Pattern PATH_MATCHER = Pattern.compile("([a-zA-Z0-9]+\\.)*[a-zA-Z0-9]+");

    /**
     * Verifies the format of a path for a query. Paths must fully match the regular expression
     * "([a-zA-Z0-9]+\.)*[a-zA-Z0-9]+"
     *
     * @param path a String path
     * @throws NullPointerException if path is null
     * @throws IllegalArgumentException if path contains colons or square brackets, or is otherwise
     * in a bad format
     */
    public static void checkPathFormat(String path) {
        if (path == null) {
            throw new NullPointerException("Path must not be null");
        }
        if (!PATH_MATCHER.matcher(path).matches()) {
            throw new IllegalArgumentException("Path \"" + path + "\" does not match regular "
                    + "expression \"([a-zA-Z0-9]+\\.)*[a-zA-Z0-9]+\"");
        }
    }

    /**
     * Get the PathQuery that should be executed.  This should be called by code creating an
     * ObjectStore query from a PathQuery.  For PathQuery the method returns this, subclasses can
     * override.  TemplateQuery removes optional constraints that have been switched off in the
     * returned query.
     * @return a version of the query to execute
     */
    public PathQuery getQueryToExecute() {
        return this;
    }

    /**
     * A method to sort constraints by a given lists, provided to allow TemplateQuery to set a
     * specific sort order that will be preserved in a round-trip to XML.  A list of constraints
     * is provided, the constraints map is updated to reflect that order.  The list does not need
     * to contain all constraints in the query - TemplateQuery only needs to order the editable
     * constraints.
     * @param listToSortBy a list to define the new constraint order
     */
    protected synchronized void sortConstraints(List<PathConstraint> listToSortBy) {
        ConstraintComparator comparator = new ConstraintComparator(listToSortBy);
        TreeMap<PathConstraint, String> orderedConstraints = new TreeMap<PathConstraint, String>(comparator);
        orderedConstraints.putAll(constraints);
        constraints = new LinkedHashMap<PathConstraint, String>(orderedConstraints);
    }

    private class ConstraintComparator implements Comparator<PathConstraint> {
        private final List<PathConstraint> listToSortBy;

        public ConstraintComparator(List<PathConstraint> listToSortBy) {
            this.listToSortBy = listToSortBy;
        }

        @Override
        public int compare(PathConstraint c1, PathConstraint c2) {
            // if neither in list we don't care how they compare, but want a consistent order
            if (!listToSortBy.contains(c1) && !listToSortBy.contains(c2)) {
                return -1;
            }
            // otherwise put lowest index first, if not in list indexOf() will return -1 so
            // constraints not in list will move to start
            return (listToSortBy.indexOf(c1) < listToSortBy.indexOf(c2)) ? -1 : 1;
        }
    }

    /**
     * Converts this object into a rudimentary String format, containing all the data.
     *
     * {@inheritDoc}
     */
    @Override
    public synchronized String toString() {
        return "PathQuery( view: " + view + ", orderBy: " + orderBy + ", constraints: " + constraints + ", logic: "
                + logic + ", outerJoinStatus: " + outerJoinStatus + ", descriptions: " + descriptions
                + ", description: " + description + ")";
    }

    /**
     * Convert a PathQuery to XML, using the default value of PathQuery.USERPROFILE_VERSION
     * @return This query as xml
     */
    public synchronized String toXml() {
        return this.toXml(PathQuery.USERPROFILE_VERSION);
    }

    protected void addJsonProperty(StringBuffer sb, String key, Object value) {
        if (value != null) {
            if (!sb.toString().endsWith("{")) {
                sb.append(",");
            }
            sb.append(formatKVPair(key, value));
        }
    }

    protected String formatKVPair(String key, Object value) {
        if (value instanceof List) {
            StringBuffer sb = new StringBuffer("[");
            boolean needsSep = false;
            for (Object obj : (List<?>) value) {
                if (needsSep) {
                    sb.append(",");
                }
                sb.append("\"" + StringEscapeUtils.escapeJava(obj.toString()) + "\"");
                needsSep = true;
            }
            sb.append("]");
            return "\"" + key + "\":" + sb.toString();
        } else if (value instanceof String) {
            String newValue = StringEscapeUtils.escapeJava((String) value);
            return "\"" + key + "\":\"" + newValue + "\"";
        }
        throw new IllegalArgumentException(value + " must be either String or a list of strings");
    }

    /**
     * toJson synonym for JSPs.
     *
     * @return This query as json.
     */
    public synchronized String getJson() {
        return toJson();
    }

    protected Map<String, Object> getHeadAttributes() {
        Map<String, Object> ret = new LinkedHashMap<String, Object>();
        ret.put("title", getTitle());
        ret.put("description", getDescription());
        ret.put("select", getView());

        // LOGIC - only if there is some. Just logic = A is dumb.
        String constraintLogic = getConstraintLogic();
        if (constraintLogic != null && constraintLogic.length() > 1) {
            ret.put("constraintLogic", constraintLogic);
        }

        return ret;
    }

    /**
     * Convert this PathQuery to a JSON serialisation.
     *
     * @return This query as json.
     */
    public synchronized String toJson() {
        StringBuffer sb = new StringBuffer("{");

        sb.append(String.format("\"model\":{\"name\":\"%s\"}", model.getName()));

        for (Entry<String, Object> attr : getHeadAttributes().entrySet()) {
            addJsonProperty(sb, attr.getKey(), attr.getValue());
        }

        // SORT ORDER
        List<OrderElement> order = getOrderBy();
        if (!order.isEmpty()) {
            sb.append(",\"orderBy\":[");
            for (Iterator<OrderElement> it = order.iterator(); it.hasNext();) {
                OrderElement oe = it.next();
                sb.append(String.format("{\"%s\":\"%s\"}", oe.getOrderPath(), oe.getDirection()));
                if (it.hasNext()) {
                    sb.append(",");
                }
            }
            sb.append("]");
        }

        // JOINS
        Map<String, OuterJoinStatus> ojs = getOuterJoinStatus();
        if (!ojs.isEmpty()) {
            StringBuilder sb2 = new StringBuilder();
            for (Iterator<Entry<String, OuterJoinStatus>> it = ojs.entrySet().iterator(); it.hasNext();) {
                Entry<String, OuterJoinStatus> pair = it.next();
                if (pair.getValue() == OuterJoinStatus.OUTER) {
                    if (sb2.length() > 0) {
                        sb2.append(",");
                    }
                    sb2.append("\"" + pair.getKey() + "\"");
                }
            }
            if (sb2.length() != 0) {
                sb.append(",\"joins\":[" + sb2.toString() + "]");
            }
        }

        // CONSTRAINTS
        Map<PathConstraint, String> cons = getRelevantConstraints();
        if (!cons.isEmpty()) {
            sb.append(",\"where\":[");
            Iterator<Entry<PathConstraint, String>> it = cons.entrySet().iterator();
            while (it.hasNext()) {
                Entry<PathConstraint, String> pair = it.next();

                sb.append(constraintToJson(pair.getKey(), pair.getValue()));
                if (it.hasNext()) {
                    sb.append(",");
                }
            }
            sb.append("]");
        }
        sb.append("}");

        return sb.toString();
    }

    protected String typeConstraintToJson(final PathConstraint constraint) {
        String path = constraint.getPath();
        String type = PathConstraint.getType(constraint);
        return String.format("{\"path\":\"%s\",\"type\":\"%s\"}", path, type);
    }

    protected String getCommonJsonConstraintPrefix(String code, PathConstraint constraint) {
        String path = constraint.getPath();
        String op = constraint.getOp().toString();

        return "{\"path\":\"" + path + "\",\"op\":\"" + op + "\",\"code\":\"" + code + "\"";
    }

    protected String valueConstraintToJson(final String code, final PathConstraint constraint) {

        String commonPrefix = getCommonJsonConstraintPrefix(code, constraint);
        StringBuilder conb = new StringBuilder(commonPrefix);

        Collection<String> values = PathConstraint.getValues(constraint); // Serialise the Multi-Value list
        Collection<Integer> ids = PathConstraint.getIds(constraint); // Serialise the ID list.
        if (ids != null) {
            conb.append(",\"ids\":[");
            Iterator<Integer> it = ids.iterator();
            while (it.hasNext()) {
                conb.append(String.valueOf(it.next()));
                if (it.hasNext()) {
                    conb.append(",");
                }
            }
            conb.append("]");
        } else if (values != null) {
            Iterator<String> it = values.iterator();
            conb.append(",\"values\":[");
            while (it.hasNext()) {
                conb.append("\"" + StringEscapeUtils.escapeJava(it.next()) + "\"");
                if (it.hasNext()) {
                    conb.append(",");
                }
            }
            conb.append("]");
        } else {
            String value = PathConstraint.getValue(constraint);
            String extraValue = PathConstraint.getExtraValue(constraint);

            if (value != null) {
                conb.append(",\"value\":\"" + StringEscapeUtils.escapeJava(value) + "\"");
            }
            if (extraValue != null) {
                conb.append(",\"extraValue\":\"" + StringEscapeUtils.escapeJava(extraValue) + "\"");
            }
        }
        conb.append("}");
        return conb.toString();
    }

    private String constraintToJson(PathConstraint constraint, String code) {
        if (PathConstraint.getType(constraint) != null) { // Would be nice to test code instead...
            return typeConstraintToJson(constraint);
        } else {
            return valueConstraintToJson(code, constraint);
        }
    }

    /**
     * Convert a PathQuery to XML.
     *
     * @param version the version number of the XML format
     * @return this template query as XML.
     */
    public synchronized String toXml(int version) {
        StringWriter sw = new StringWriter();
        XMLOutputFactory factory = XMLOutputFactory.newInstance();

        try {
            XMLStreamWriter writer = factory.createXMLStreamWriter(sw);
            PathQueryBinding.marshal(this, "query", model.getName(), writer, version);
        } catch (XMLStreamException e) {
            throw new RuntimeException(e);
        }

        return sw.toString();
    }

    @Override
    public boolean equals(Object other) {
        if (other == null) {
            return false;
        }
        if (other instanceof PathQuery) {
            return ((PathQuery) other).toXml().equals(this.toXml());
        }
        return false;
    }

    @Override
    public int hashCode() {
        return toXml().hashCode();
    }
}