001/**
002 * Copyright 2014 Tampere University of Technology, Pori Department
003 * 
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 * 
008 *   http://www.apache.org/licenses/LICENSE-2.0
009 * 
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 */
016package core.tut.pori.dao;
017
018import java.util.ArrayList;
019import java.util.HashSet;
020import java.util.Iterator;
021import java.util.List;
022import java.util.Map;
023import java.util.Set;
024
025import org.apache.commons.lang3.StringUtils;
026import org.apache.log4j.Logger;
027import org.apache.solr.client.solrj.SolrQuery;
028import org.apache.solr.client.solrj.SolrQuery.ORDER;
029import org.apache.solr.client.solrj.util.ClientUtils;
030import org.apache.solr.common.params.CommonParams;
031
032import core.tut.pori.dao.SQLSelectBuilder.OrderDirection;
033import core.tut.pori.dao.filter.AbstractQueryFilter;
034import core.tut.pori.http.parameters.Limits;
035import core.tut.pori.http.parameters.QueryParameter;
036import core.tut.pori.http.parameters.SortOptions;
037import core.tut.pori.http.parameters.SortOptions.Option;
038
039/**
040 * Helper class, which can be used to build a SOLR query.
041 *
042 */
043public class SolrQueryBuilder {
044  /** Separator for solr fields and values */
045  public static final char SEPARATOR_SOLR_FIELD_VALUE = ':';
046  /** Solr query selector for all items */
047  public static final String QUERY_ALL = "*:*";
048  /** Solr wild card */
049  public static final char SOLR_WILD_CARD = '*';
050  private static final Limits DEFAULT_MAX_LIMITS = new Limits(0, SolrDAO.MAX_DOCUMENT_COUNT);
051  private static final Logger LOGGER = Logger.getLogger(SolrQueryBuilder.class);
052  private static final String SOLR_SELECT = "/select";
053  private static final String SOLR_QUERY = "/query";
054  private static final String SOLR_SUGGEST = "/suggest";
055  private List<AbstractQueryFilter> _customFilters = null;
056  private Map<String, String> _elementFieldMap = null;
057  private Limits _limits = null;
058  private QueryParameter _queryParameter = null;
059  private SortOptions _sortOptions = null;
060  private boolean _useExtendedDismax = false;
061  private Set<String> _fieldList = null;
062
063  /**
064   * Type of the request handler.
065   */
066  public enum RequestHandlerType{
067    /** select */
068    SELECT(SOLR_SELECT),
069    /** query */
070    QUERY(SOLR_QUERY),
071    /** suggest */
072    SUGGEST(SOLR_SUGGEST);
073    
074    private String _handler;
075    
076    /**
077     * 
078     * @param handler
079     */
080    private RequestHandlerType(String handler){
081      _handler = handler;
082    }
083    
084    /**
085     * 
086     * @return the type as a string
087     */
088    public String toHandlerString(){
089      return _handler;
090    }
091  } // enum RequestHandlerType
092  
093  /**
094   * Note: the given map is ASSUMED to be escaped and to contain only valid element names and field names
095   * 
096   * @param elementFieldMap map of element-solr field relations
097   */
098  public SolrQueryBuilder(Map<String,String> elementFieldMap){
099    _elementFieldMap = elementFieldMap;
100  }
101  
102  /**
103   * same as calling the overloaded constructor with parameter null (without relation map)
104   */
105  public SolrQueryBuilder(){
106    // nothing needed
107  }
108
109  /**
110   * @param limits the limits to set
111   */
112  public void setLimits(Limits limits) {
113    _limits = limits;
114  }
115  
116  /**
117   * 
118   * @param option
119   */
120  public void addSortOption(Option option){
121    if(_sortOptions == null){
122      _sortOptions = new SortOptions();
123    }
124    _sortOptions.addSortOption(option);
125  }
126  
127  /**
128   * Add field to be selected in the response.
129   * 
130   * @param field
131   */
132  public void addField(String field){
133    if(_fieldList == null){
134      _fieldList = new HashSet<>();
135    }
136    _fieldList.add(ClientUtils.escapeQueryChars(field));
137  }
138  
139  /**
140   * Add a list of fields to be selected in the response
141   * 
142   * @param fields
143   */
144  public void addFields(String ...fields){
145    for(int i=0;i<fields.length; ++i){
146      addField(fields[i]);
147    }
148  }
149
150  /**
151   * @param queryParameter the query to set
152   */
153  public void setQueryParameter(QueryParameter queryParameter) {
154    _queryParameter = queryParameter;
155  }
156
157  /**
158   * @param sortOptions the sortOptions to set
159   */
160  public void setSortOptions(SortOptions sortOptions) {
161    _sortOptions = sortOptions;
162  }
163
164  /**
165   * 
166   * @return true if extended dismax is enabled
167   */
168  public boolean isUseExtendedDismax() {
169    return _useExtendedDismax;
170  }
171
172  /**
173   * Setter for the use of enabling Extended Query Parser (edismax). Setting this true will be using the more user friendly query parser.
174   * @param useExtendedDismax
175   */
176  public void setUseExtendedDismax(boolean useExtendedDismax) {
177    _useExtendedDismax = useExtendedDismax;
178  }
179
180  /**
181   * Add new filter query.
182   * 
183   * @param filter
184   */
185  public void addCustomFilter(AbstractQueryFilter filter){
186    if(filter == null){
187      LOGGER.warn("Ignored null filter.");
188      return;
189    }
190    if(_customFilters == null){
191      _customFilters = new ArrayList<>();
192    }
193    _customFilters.add(filter);
194  }
195  
196  /**
197   * clear the list of currently set custom filters
198   */
199  public void clearCustomFilters(){
200    _customFilters = null;
201  }
202
203  /**
204   * Get the query using a specified type parameter for sort and limit operations.
205   * 
206   * @param type the type or null
207   * @return new query created with the given parameters using type information
208   * @throws IllegalArgumentException on bad query value
209   */
210  public SolrQuery toSolrQuery(String type) throws IllegalArgumentException{
211    SolrQuery query = new SolrQuery();
212
213    if(_customFilters != null){
214      Iterator<AbstractQueryFilter> pIter = _customFilters.iterator();
215      StringBuilder filter = new StringBuilder();
216      pIter.next().toFilterString(filter);
217      while(pIter.hasNext()){
218        AbstractQueryFilter fq = pIter.next();
219        filter.append(fq.getQueryType().toTypeString());
220        fq.toFilterString(filter);
221      }
222      query.addFilterQuery(filter.toString());
223    }
224
225    boolean querySet = false;
226    Set<String> queryParams = QueryParameter.getValues(_queryParameter, type);
227    if(queryParams != null){
228      int paramCount = queryParams.size();
229      if(paramCount != 1){
230        throw new IllegalArgumentException("Only a single query parameter is accepted, found: "+paramCount);
231      }
232      querySet = true;
233
234      String q = queryParams.iterator().next();
235      if(_useExtendedDismax){
236        LOGGER.debug("Using edismax query parser and raw query");
237        query.set("defType", "edismax");  //activate edismax query parser
238        query.set("q.alt", QUERY_ALL);  //setter for alternative query to retrieve everything if the regular query is not given.
239        query.setQuery(q);
240      }else if(_elementFieldMap == null){
241        LOGGER.debug("No element-field map provided, using the raw query.");
242        query.setQuery(q);
243      }else{
244        StringBuilder solr = new StringBuilder(q.length()); // estimate size to minimize re-allocations
245        boolean inParenthesis = false;
246        boolean inElement = false;
247        StringBuilder elementName = new StringBuilder();
248        for(int i = q.length()-1;i>=0;--i){ // go in reverse
249          char c = q.charAt(i);
250          if(c == '"'){
251            inParenthesis = !inParenthesis;
252          }else if(!inParenthesis && c == SEPARATOR_SOLR_FIELD_VALUE){  // not in parenthesis, and this is the start of a new element name
253            if(inElement){  // an error, cannot start a new element and already be inside an element
254              throw  new IllegalArgumentException("Bad query string, duplicate field separator at: "+i);
255            }
256            inElement = true;
257          }else if(inElement){
258            if(c == SOLR_WILD_CARD){
259              if(elementName.length() > 0){ // there is a wild card in the element name
260                LOGGER.debug("Wild card detected inside element name. Skipping element-field mapping...");
261                solr.append(elementName); // dump whatever is in the element name in back into the query string
262                elementName.setLength(0); // clear the element
263              }else{  // probably *:SOMETHING
264                inElement = false;
265              }
266            }else if(c < 'A' || c > 'z'){ // end of element
267              solr.append(getReversedFieldName(elementName));
268              elementName.setLength(0);
269              inElement = false;
270            }else{
271              elementName.append(c);
272              continue;
273            }
274          }
275          solr.append(c); // append in the query string
276        } // for
277        if(inElement){
278          solr.append(getReversedFieldName(elementName));
279        }
280        query.setQuery(solr.reverse().toString());  // the query was processed from end to beginning, we need to reverse this first
281      } // else
282    }
283
284    if(!querySet){
285      if(_useExtendedDismax){
286        LOGGER.debug("Extended Dismax enabled, query parameter can be null");
287        query.set("defType", "edismax");  //activate edismax query parser
288        query.set("q.alt", QUERY_ALL);  //setter for alternative query to retrieve everything if the regular query is not given.
289      }else{
290        query.setQuery(QUERY_ALL);
291      }
292    }
293
294    if(_limits == null){
295      query.setStart(DEFAULT_MAX_LIMITS.getStartItem());
296      query.setRows(DEFAULT_MAX_LIMITS.getMaxItems());
297    }else{
298      int start = _limits.getStartItem(type);
299      query.setStart(_limits.getStartItem(type));
300      int rows = _limits.getMaxItems(type);
301      long total = (long)start+(long)rows;
302      if(total > DEFAULT_MAX_LIMITS.getMaxItems()){ // solr server throws array out of bounds exception if total row count (start+rows) exceeds INTEGER.MAX
303        rows = DEFAULT_MAX_LIMITS.getMaxItems()-start;
304        LOGGER.debug("Max items exceeds the maximum document count, capping to: "+rows);
305      }
306      query.setRows(rows);
307    }
308
309    Set<Option> options = SortOptions.getSortOptions(_sortOptions, type);
310    if(options != null){
311      for(Option o : options){
312        if(_elementFieldMap == null){
313          LOGGER.debug("No element-field map provided, using the element name directly.");
314          query.addSort(o.getElementName(), fromOrderDirection(o.getOrderDirection()));
315        }else{
316          String field = _elementFieldMap.get(o.getElementName());
317          if(field == null){
318            LOGGER.warn("Ignored unknown sort element: "+o.getElementName());
319          }else{
320            query.addSort(field, fromOrderDirection(o.getOrderDirection()));
321          }
322        }
323      }
324    }
325    if(_fieldList != null){
326      query.setFields(_fieldList.toArray(new String[_fieldList.size()]));
327    }
328
329    return query;
330  }
331  
332  /**
333   * Helper method to reverse element name
334   * @param reversedElementName
335   * @return reversed field name mapped to the given reversed element name
336   */
337  private String getReversedFieldName(StringBuilder reversedElementName){
338    String eName = reversedElementName.reverse().toString(); // the query is processed from end-to-beginning, so we need to reverse the element name
339    String fieldName = _elementFieldMap.get(eName); // check if it matches a known field name
340    if(fieldName == null){
341      throw new IllegalArgumentException("Bad element name: "+eName);
342    }
343    return StringUtils.reverse(fieldName);
344  }
345
346  /**
347   * get an executable SolrQuery from the builder
348   * 
349   * @return new query created with the given parameters
350   */
351  public SolrQuery toSolrQuery(){
352    return toSolrQuery(null);
353  }
354
355  /**
356   * Can be used to convert SQL OrderDirection to SOLR ORDER clause.
357   * 
358   * @param direction
359   * @return the given sql order direction converted to solr order direction
360   * @throws UnsupportedOperationException 
361   */
362  public static ORDER fromOrderDirection(OrderDirection direction) throws UnsupportedOperationException{
363    switch(direction){
364      case ASCENDING:
365        return ORDER.asc;
366      case DESCENDING:
367        return ORDER.desc;
368      default:
369        throw new UnsupportedOperationException("Unhandeled direction: "+direction.name());
370    }
371  }
372  
373  /**
374   * Helper method to set the correct path for different kinds of request handlers (mainly for suggest-handler)
375   * @param query
376   * @param type
377   * @return the passed query
378   */
379  public static SolrQuery setRequestHandler(SolrQuery query, RequestHandlerType type) {
380    if(query != null){
381      switch(type){
382        case SUGGEST: 
383          query.setParam(CommonParams.QT, type.toHandlerString());
384          break;
385        case QUERY: // default is OK
386        case SELECT: // default is OK
387          LOGGER.debug("Using default query handler.");
388          break;
389        default:
390          throw new UnsupportedOperationException("Unhandeled "+RequestHandlerType.class.toString()+" : "+type.name());
391      }
392    }
393    return query;
394  }
395}