/* * Copyright 2013 Gordon Burgett and individual contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package org.xflatdb.xflat.query; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.Comparator; import java.util.List; import org.hamcrest.Description; import org.hamcrest.Matcher; import org.hamcrest.Matchers; import org.hamcrest.SelfDescribing; import org.hamcrest.StringDescription; import org.hamcrest.TypeSafeMatcher; import org.jdom2.Attribute; import org.jdom2.Element; import org.jdom2.filter.AttributeFilter; import org.jdom2.xpath.XPathExpression; import org.jdom2.xpath.XPathFactory; import org.xflatdb.xflat.XFlatConstants; import org.xflatdb.xflat.convert.ConversionException; import org.xflatdb.xflat.convert.ConversionService; import org.xflatdb.xflat.util.XPathExpressionEqualityMatcher; /** * Represents a query in the XPath Query Language. * <p/> * Queries are constructed using XPath expressions associated to values. * The XPath expressions select elements or attributes in the row data which * are convertible to the query's value type, and then the converted value * type is compared to the given value to see if the row matches the query. * <p/> * The row is matched to the query if any one of the nodes selected by * the XPath expression match the query. This means that an Equals query * which selects the individual item elements of a list will match if any item * in the list matches the query. * @author Gordon */ public class XPathQuery { //<editor-fold desc="properties" > private XPathExpression<?> selector; /** * Gets the XPath expression that is the selector for this query. * AND and OR queries have null here, their subqueries have this populated. * @return the selecting expression for this query. */ public XPathExpression<?> getSelector(){ return selector; } private Matcher<Element> rowMatcher; /** * Gets the Hamcrest matcher that matches database rows. This is used to determine * if a row is a match for a query. * @return The database row hamcrest matcher. */ public Matcher<Element> getRowMatcher(){ return rowMatcher; } private Object value; /** * Gets the value to which the element selected by the {@link #getSelector() selector} * should be compared, according to this query's type. <br/> * AND and OR queries have null here, their subqueries have this populated. * @return the value that will be compared to the selected element. */ public Object getValue(){ return value; } private Class<?> valueType; /** * Gets the type of {@link #getValue() the value}, to which the selected element * should be convertible so it can be compared to the value. * @return The type of the value, or null if the value is null. */ public Class<?> getValueType(){ return valueType; } private QueryType queryType; /** * Gets the type of this query, ie. EQ, NE, AND, OR, etc. * @return The type of query. */ public QueryType getQueryType(){ return queryType; } /** * Gets the sub queries of this query. This is only populated for * AND and OR queries. * @return A list of all the sub queries, or an empty list if this is not an AND * or OR query. */ public List<XPathQuery> getSubQueries(){ return queryChain == null ? Collections.EMPTY_LIST : Collections.unmodifiableList(queryChain); } private List<XPathQuery> queryChain; //</editor-fold> //<editor-fold desc="dependencies"> private ConversionService conversionService; /** * Sets the conversion service for the entire query chain. Necessary for * matchers that match against non-JDOM types. * <p/> * JDOM Engines will set this automatically when they execute the query. * @param service The conversion service to use to convert selected data * to the comparable value type. */ public void setConversionService(ConversionService service){ this.conversionService = service; if(this.queryChain != null){ for(XPathQuery q : this.queryChain){ q.setConversionService(service); } } } private XPathExpression<Object> alternateIdExpression = null; /** * Sets an expression that represents the DOM node where the object's ID is stored. * Some engines can make use of queries on ID for indexing, so they need to * know the alternate ID expression in case the user queries on that rather than * {@link #Id}. * <p/> * This is automatically populated by {@link org.xflatdb.xflat.db.ConvertingTable}. * @param expression The expression which represents an alternate way to select the ID. */ public void setAlternateIdExpression(XPathExpression<Object> expression){ this.alternateIdExpression = expression; } //</editor-fold> //<editor-fold desc="constructors" > private XPathQuery(XPathExpression<?> selector, QueryType type, Object value, Class<?> valueType, Matcher<?> valueMatcher) { this.selector = selector; this.rowMatcher = new ValueMatcher(selector, valueType, valueMatcher); this.queryType = type; this.value = value; this.valueType = valueType; } private XPathQuery(QueryType type, Matcher<Element> rowMatcher, XPathQuery... queries){ this.queryType = type; this.rowMatcher = rowMatcher; this.queryChain = new ArrayList<>(); this.queryChain.addAll(Arrays.asList(queries)); } private XPathQuery(){ } //</editor-fold> //<editor-fold desc="methods"> /** * A special overload of dissect that is based on an ID index. Takes advantage * of an {@link #setAlternateIdExpression(org.jdom2.xpath.XPathExpression) alternate ID expression} * if it exists. * @param <U> * @param comparer A comparer comparing instances of idClass. * @param idClass The class to which the ID is convertible. * @return An IntervalSet representing the values on the index to search. * @throws InvalidQueryException if the query expects the ID to be of a different class. */ public <U> IntervalSet<U> dissectId(Comparator<U> comparer, Class<U> idClass){ Matcher<XPathExpression> index = new XPathExpressionEqualityMatcher(Id); if(this.alternateIdExpression != null){ index = org.hamcrest.Matchers.anyOf(index, new XPathExpressionEqualityMatcher(this.alternateIdExpression)); } return this.dissect(index, comparer, idClass); } /** * Dissects the query based on the given index. This returns the Intervals on the index to which * this query should be applied. If the query is invalid for the index applied, an {@link InvalidQueryException} is thrown. * @param <U> * @param index The XPathExpression describing an index on a table. * @param comparer A Comparator for the expected values of the index. * @param indexClass The class to which the value selected by the index is expected to be convertible. * @return An IntervalSet representing the values on the index to search. * @throws InvalidQueryException if the query is not valid for the given index and index class. */ public <U> IntervalSet<U> dissect(XPathExpression index, Comparator<U> comparer, Class<U> indexClass){ return dissect(new XPathExpressionEqualityMatcher(index), comparer, indexClass); } /** * Dissects the query based on the given index. This returns the Intervals on the index to which * this query should be applied. If the query is invalid for the index applied, an {@link InvalidQueryException} is thrown. * @param <U> * @param index A matcher matching an XPathExpression describing an index on a table. * @param comparer A Comparator for the expected values of the index. * @param indexClass The class to which the value selected by the index is expected to be convertible. * @return An IntervalSet representing the values on the index to search. * @throws InvalidQueryException if the query is not valid for the given index and index class. */ public <U> IntervalSet<U> dissect(Matcher<XPathExpression> index, Comparator<U> comparer, Class<U> indexClass){ if(this.selector != null && !index.matches(this.selector)){ //we don't care about this part of the query, as far as it's concerned a full table scan is in order. return IntervalSet.all(); } U convertedValue; if(this.value != null && !indexClass.isAssignableFrom(this.valueType)){ try { //is it convertible? convertedValue = this.conversionService.convert(value, indexClass); } catch (ConversionException ex) { //nope throw new InvalidQueryException(this, indexClass); } } else{ //it's assignable, go ahead and assign it. convertedValue = (U)value; } switch(this.queryType){ case EQ: if(this.value == null){ //we want one that doesn't exist. Maybe this index is sparse? //if so none of the values in this index will contain what we want. return IntervalSet.none(); } return IntervalSet.eq(convertedValue); case NE: if(this.value == null){ //we just want one that exists. Maybe this index is sparse? //if so all the values in this index are fair game. return IntervalSet.all(); } return IntervalSet.ne(convertedValue); case LT: return IntervalSet.lt(convertedValue); case LTE: return IntervalSet.lte(convertedValue); case GT: return IntervalSet.gt(convertedValue); case GTE: return IntervalSet.gte(convertedValue); case EXISTS: case MATCHES: case ANY: //always a full table scan return IntervalSet.all(); case AND: IntervalSet ret = null; for(XPathQuery q : this.queryChain){ if(ret == null){ ret = q.dissect(index, comparer, indexClass); } else{ //AND means intersection ret = ret.intersection(q.dissect(index, comparer, indexClass), comparer); } } return ret; case OR: ret = null; for(XPathQuery q : this.queryChain){ if(ret == null){ ret = q.dissect(index, comparer, indexClass); } else{ //OR means union ret = ret.union(q.dissect(index, comparer, indexClass), comparer); } } return ret; default: throw new UnsupportedOperationException("Unknown query type " + this.queryType); } } //</editor-fold> //<editor-fold desc="constants"> /** * An XPath expression selecting the database ID of a row. * This can be used to build queries matching the row's ID. * <p/> * Currently this is the expression "@db:id". * */ public static final XPathExpression<Attribute> Id = XPathFactory.instance().compile("@db:id", new AttributeFilter(), null, XFlatConstants.xFlatNs); //</editor-fold> //<editor-fold desc="builders"> /** * Creates an XPath query that matches the single element at the selector to the value. * @param <U> the type of the value * @param selector The xpath selector to query * @param object The object that the result of the xpath selection should be equal to. * @return An XpathQuery object */ public static <U> XPathQuery eq(XPathExpression<?> selector, U object){ Matcher<U> eq = Matchers.equalTo(object); Class<?> valueType = null; if(object != null) valueType = object.getClass(); return new XPathQuery(selector, QueryType.EQ, object, valueType, eq); } /** * Creates an XPath query that matches when the single element at the selector * does not exist or is not equal to the value. * @param <U> the type of the value * @param selector The xpath selector to query * @param object The object that the result of the xpath selection should not be equal to. * @return An XpathQuery object */ public static <U> XPathQuery ne(XPathExpression<?> selector, U object){ Matcher<U> ne = Matchers.not(Matchers.equalTo(object)); Class<?> valueType = null; if(object != null) valueType = object.getClass(); return new XPathQuery(selector, QueryType.NE, object, valueType, ne); } /** * Creates an XPath query that matches when the single element at the selector * has a value that is less than the given value. * @param <U> the type of the value * @param selector The xpath selector to query * @param object The object that the result of the xpath selection should be less than. * @return An XpathQuery object */ public static <U extends Comparable<U>> XPathQuery lt(XPathExpression<?> selector, U object){ if(object == null) throw new IllegalArgumentException("object cannot be null"); Class<?> valueType = null; if(object != null) valueType = object.getClass(); Matcher<U> lt = Matchers.lessThan(object); return new XPathQuery(selector, QueryType.LT, object, valueType, lt); } /** * Creates an XPath query that matches when the single element at the selector * has a value that is less than or equal to the given value. * @param <U> the type of the value * @param selector The xpath selector to query * @param object The object that the result of the xpath selection should be less than or equal to. * @return An XpathQuery object */ public static <U extends Comparable<U>> XPathQuery lte(XPathExpression<?> selector, U object){ if(object == null) throw new IllegalArgumentException("object cannot be null"); Class<?> valueType = null; if(object != null) valueType = object.getClass(); Matcher<U> lte = Matchers.lessThanOrEqualTo(object); return new XPathQuery(selector, QueryType.LTE, object, valueType, lte); } /** * Creates an XPath query that matches when the single element at the selector * has a value that is greater than the given value. * @param <U> the type of the value * @param selector The xpath selector to query * @param object The object that the result of the xpath selection should be greater than. * @return An XpathQuery object */ public static <U extends Comparable<U>> XPathQuery gt(XPathExpression<?> selector, U object){ if(object == null) throw new IllegalArgumentException("object cannot be null"); Class<?> valueType = null; if(object != null) valueType = object.getClass(); Matcher<U> gt = Matchers.greaterThan(object); return new XPathQuery(selector, QueryType.GT, object, valueType, gt); } /** * Creates an XPath query that matches when the single element at the selector * has a value that is greater than or equal to the given value. * @param <U> the type of the value * @param selector The xpath selector to query * @param object The object that the result of the xpath selection should be greater than or equal to. * @return An XpathQuery object */ public static <U extends Comparable<U>> XPathQuery gte(XPathExpression<?> selector, U object){ if(object == null) throw new IllegalArgumentException("object cannot be null"); Class<?> valueType = null; if(object != null) valueType = object.getClass(); Matcher<U> gte = Matchers.greaterThanOrEqualTo(object); return new XPathQuery(selector, QueryType.GTE, object, valueType, gte); } /** * Create an XPath query that matches when all of the provided queries match. * @param queries The queries that all must match. * @return an XpathQuery object */ public static XPathQuery and(XPathQuery... queries){ if(queries.length < 2){ throw new IllegalArgumentException("AND requires at least 2 queries"); } List<Matcher<? super Element>> ms = new ArrayList<>(); for(XPathQuery q : queries){ ms.add(q.getRowMatcher()); } Matcher<Element> ret = Matchers.allOf(ms); return new XPathQuery(QueryType.AND, ret, queries); } /** * Create an XpathQuery that matches when any of the provided queries match. * @param queries the queries, one of which must match. * @return an XpathQuery object. */ public static XPathQuery or(XPathQuery... queries){ if(queries.length < 2){ throw new IllegalArgumentException("OR requires at least 2 queries"); } List<Matcher<? super Element>> ms = new ArrayList<>(); for(XPathQuery q : queries){ ms.add(q.getRowMatcher()); } Matcher<Element> ret = Matchers.anyOf(ms); return new XPathQuery(QueryType.OR, ret, queries); } /** * Create an XpathQuery that matches when the given matcher matches the value * selected by the Xpath selector. * @param <U> The type of the value expected at the end of the selector. * @param selector The Xpath expression that selects a value in a row. * @param matcher The matcher that decides whether this row is a match. * @param clazz The type of the value expected at the end of the selector. * @return an XpathQuery object. */ public static <U> XPathQuery matches(XPathExpression<?> selector, Matcher<U> matcher, Class<U> clazz){ return new XPathQuery(selector, QueryType.MATCHES, matcher, clazz, matcher); } /** * functions the same as ne(selector, null), but does not invoke the conversion * service. * @param selector The selector to test whether it exists. * @return An XpathQuery object. */ public static XPathQuery exists(XPathExpression<?> selector){ Matcher<Object> m = Matchers.notNullValue(); return new XPathQuery(selector, QueryType.EXISTS, true, Object.class, m); } /** * An XPathQuery that matches any and all rows regardless of content. * useful for find all or delete all. * @return A new XPathQuery that will match all rows. */ public static XPathQuery any(){ return new XPathQuery(QueryType.ANY, Matchers.anyOf(Matchers.nullValue(), Matchers.any(Element.class))); } //</editor-fold> //<editor-fold desc="inner classes"> /** * The type of the query. * Can be used by engines to inspect the query in order to generate an * execution plan. */ public enum QueryType { /** Represents an equals query. */ EQ, /** Represents a not equals query. */ NE, /** Represents a less than query. */ LT, /** Represents a less than or equals query. */ LTE, /** Represents a greater than query. */ GT, /** Represents a greater than or equals query. */ GTE, /** Represents the intersection of several sub-queries. */ AND, /** Represents the union of several sub-queries. */ OR, /** Represents an exists query. */ EXISTS, /** Represents a matches query. */ MATCHES, /** Represents an Any query, which matches all rows. */ ANY } /** * The Hamcrest Matcher that matches a row by evaluating the Xpath expression, * and invoking the matcher for the result of the Xpath expression. * @param <T> The type of the expected value at the end of the Xpath expression. */ private class ValueMatcher<T> extends TypeSafeMatcher<Element>{ private XPathExpression<?> selector; private Class<T> expectedType; private Matcher<T> subMatcher; public ValueMatcher(XPathExpression<?> selector, Class<T> expectedType, Matcher<T> subMatcher){ super(Element.class); this.selector = selector; this.expectedType = expectedType; this.subMatcher = subMatcher; } @Override protected boolean matchesSafely(Element item) { boolean anyMatches = false; for(Object selected : selector.evaluate(item)){ anyMatches = true; if(expectedType != null){ if(!expectedType.isAssignableFrom(selected.getClass())){ //need to convert if(conversionService != null && conversionService.canConvert(selected.getClass(), expectedType)){ try{ selected = conversionService.convert(selected, expectedType); }catch(ConversionException ex){ //if we can't convert then the data is in the wrong format //and probably was not intended to be selected continue; } } else{ continue; } } } if(subMatcher.matches(selected)){ return true; } } if(anyMatches){ //we didn't match this row return false; } //not a single match in all the selected nodes, //check to see if we are matching null (as in, not exists) if(expectedType != null && conversionService != null && conversionService.canConvert(null, expectedType)){ try { return subMatcher.matches(conversionService.convert(null, expectedType)); } catch (ConversionException ex) { //if we can't convert then the data is in the wrong format //and probably was not intended to be selected return false; } } return subMatcher.matches(null); } @Override public void describeTo(Description description) { description.appendText("a value at ") .appendText(this.selector.getExpression()) .appendText(" that is ") .appendDescriptionOf(this.subMatcher); } } //</editor-fold> //<editor-fold desc="utility"> /** * Returns the string representation of this query. */ @Override public String toString(){ StringBuilder str = new StringBuilder(); this.toString(str); return str.toString(); } private void toString(StringBuilder str){ str.append("{"); if(this.queryChain != null && this.queryChain.size() > 0){ str.append(this.queryType.toString()); str.append(": ["); boolean first = true; for(XPathQuery q : queryChain){ if(first) first = false; else str.append(", "); q.toString(str); } str.append("]"); } else{ str.append(" '") .append(this.getSelector().getExpression()) .append("': {") .append(this.queryType).append(": "); if(this.queryType == QueryType.MATCHES){ StringDescription d = new StringDescription(); ((SelfDescribing)this.getValue()).describeTo(d); str.append(d.toString()); } else if(String.class.equals(this.valueType)){ str.append("'").append(this.value).append("'"); } else str.append(this.value); str.append(" }"); } str.append("}"); } //</editor-fold> }