/* * Copyright (c) 2009-2010 Lockheed Martin Corporation * * 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.eurekastreams.commons.search; import java.util.ArrayList; import java.util.List; import javax.persistence.EntityManager; import javax.persistence.PersistenceContext; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.apache.lucene.queryParser.ParseException; import org.apache.lucene.queryParser.QueryParser; import org.apache.lucene.search.Query; import org.hibernate.search.FullTextQuery; import org.hibernate.search.jpa.FullTextEntityManager; import org.hibernate.search.jpa.Search; import org.hibernate.transform.ResultTransformer; /** * Hibernate Search request wrapper class. This class makes Hibernate Search/Lucene requests a little bit easier to * create. * * Note: This is a work in progress for research and may be removed or heavily refactored. Don't spent too much time * understand what's going on here. */ public class ProjectionSearchRequestBuilder { /** * Logger. */ private Log log = LogFactory.getLog(ProjectionSearchRequestBuilder.class); /** * Log text to report which entities we're searching. */ private String entityNames; /** * The EntityManager to operate on. */ private EntityManager entityManager; /** * Set the entity manager. * * @param newEntityManager * the entity manager. */ @PersistenceContext public void setEntityManager(final EntityManager newEntityManager) { this.entityManager = newEntityManager; } /** * Default types of Entities to return - can be overridden per search. */ private Class< ? >[] resultTypes; /** * Formatter of the string. */ private String searchStringFormat; /** * Tranformer to use to convert projection to use. */ private ResultTransformer resultTransformer; /** * The Query Parser to use to parse the Lucene Queries. */ private QueryParserBuilder queryParserBuilder; /** * Query Parser for advanced searches. */ private QueryParserBuilder advancedQueryParserBuilder; /** * Which fields to return in the search, space-separated. */ private List<String> resultFields; /** * Empty constructor - if used, make sure all required properties are set. This is useful for Spring configuration * using abstract & child beans. */ public ProjectionSearchRequestBuilder() { } /** * Build and return the search query, parsing the input native search string, adding extra parameters needed under * the hood including the search score and explanation (if explanation is requested -- it's expensive), the managed * entity (if requested - it'll need to go to the database to load that, and setting the projection. * * @param nativeSearchString * the native search string to parse and use * @return the FullTextQuery object, preloaded with the parsed query, all the parameters passed into this class' * constructor, and other information we'll need later. */ public org.hibernate.search.jpa.FullTextQuery buildQueryFromNativeSearchString(final String nativeSearchString) { if (log.isInfoEnabled()) { log.info("User attempting native lucene search: " + nativeSearchString); } return prepareQuery(queryParserBuilder.buildQueryParser(), nativeSearchString); } /** * Build and return the search query, building a native search format using the format string and the input query * text, adding extra parameters needed under the hood including the search score and explanation (if explanation is * requested -- it's expensive), the managed entity (if requested - it'll need to go to the database to load that, * and setting the projection. * * @param searchText * text to search for * @return the FullTextQuery object, preloaded with the parsed query, all the parameters passed into this class' * constructor, and other information we'll need later. */ public org.hibernate.search.jpa.FullTextQuery buildQueryFromSearchText(final String searchText) { return buildQueryFromSearchText(searchText, ""); } /** * Build and return the search query, building a native search format using the format string and the input query * text, adding extra parameters needed under the hood including the search score and explanation (if explanation is * requested -- it's expensive), the managed entity (if requested - it'll need to go to the database to load that, * and setting the projection. * * @param constraints * an additional native search string. It is appended to the native search string generated for the * searchText. * @param searchText * text to search for * @return the FullTextQuery object, preloaded with the parsed query, all the parameters passed into this class' * constructor, and other information we'll need later. */ public org.hibernate.search.jpa.FullTextQuery buildQueryFromSearchText(final String searchText, final String constraints) { String nativeSearchString; QueryParser theQueryParser; if (containsAdvancedSearchCharacters(searchText)) { if (log.isInfoEnabled()) { log.info("User attempting advanced search: " + searchText); } nativeSearchString = escape(searchText) + constraints; theQueryParser = advancedQueryParserBuilder.buildQueryParser(); } else { if (log.isInfoEnabled()) { log.info("User attempting standard search: " + searchText); } nativeSearchString = String.format(searchStringFormat, escape(searchText)) + constraints; theQueryParser = queryParserBuilder.buildQueryParser(); } return prepareQuery(theQueryParser, nativeSearchString); } /** * Prepare the input luceneQuery. * * @param inQueryParser * the QueryParser to use * @param nativeSearchString * the query to prepare * @return the fully prepared FullTextQuery */ protected org.hibernate.search.jpa.FullTextQuery prepareQuery(final QueryParser inQueryParser, final String nativeSearchString) { Query luceneQuery; try { luceneQuery = inQueryParser.parse(nativeSearchString); } catch (ParseException e) { String message = "Unable to parse query: '" + nativeSearchString + "'"; log.error(message); throw new RuntimeException(message); } if (log.isInfoEnabled()) { log.info("Lucene query: " + nativeSearchString); log.info("Lucene query parsed as: " + luceneQuery.toString()); log.info("Querying for objects of type: " + getEntityNames()); } // get the FullTextQuery FullTextEntityManager ftem = getFullTextEntityManager(); // wrap the FullTextQuery so we have more control over the control flow ProjectionFullTextQuery projectionFullTextQuery = new ProjectionFullTextQuery(ftem.createFullTextQuery( luceneQuery, resultTypes)); // set the result format to projection List<String> parameters = buildFieldList(); projectionFullTextQuery.setProjection(parameters.toArray(new String[parameters.size()])); // set the transformer projectionFullTextQuery.setResultTransformer(resultTransformer); return projectionFullTextQuery; } /** * Set the paging on the input query (zero-based). * * @param query * the query to set the paging on * @param from * the starting (zero-based) index * @param to * the ending (zero-based) index * @return the input query */ public org.hibernate.search.jpa.FullTextQuery setPaging(final org.hibernate.search.jpa.FullTextQuery query, final int from, final int to) { query.setFirstResult(from); query.setMaxResults(to - from + 1); return query; } /** * Get a FullTextEntityManager from the entityManager. * * @return a FullTextEntityManager from the entityManager. */ protected FullTextEntityManager getFullTextEntityManager() { return Search.getFullTextEntityManager(entityManager); } /** * Build the list of fields to retrieve. * * @return the list of fields to retrieve */ protected List<String> buildFieldList() { // Set the projection List<String> parameters = new ArrayList<String>(); if (resultFields != null) { parameters.addAll(resultFields); } // always load the ID, object class, and search score parameters.add(FullTextQuery.ID); parameters.add(FullTextQuery.OBJECT_CLASS); return parameters; } /** * Determine whether the input searchText is considered an advanced search. * * @param searchText * the search text * @return whether the input searchText is considered an advanced search. */ protected boolean containsAdvancedSearchCharacters(final String searchText) { return searchText.indexOf('(') > -1 || searchText.indexOf(')') > -1 || searchText.indexOf('+') > -1 || searchText.indexOf('-') > -1 || searchText.indexOf('?') > -1 || searchText.indexOf('*') > -1 || searchText.indexOf("\"") > -1; } /** * Escape special characters that are used for grouping keywords, still allowing wildcard, but removing any bare * wildcards. * * @param s * the search text to escape * @return the input search text with all but wildcard characters escaped */ public String escapeAllButWildcardCharacters(final String s) { final int negTwo = -2; StringBuffer sb = new StringBuffer(); int stringLength = s.length(); int lastGoodCharIndex = negTwo; for (int i = 0; i < stringLength; i++) { char c = s.charAt(i); // see if it's a bare wildcard if ((c == '*' || c == '?') && (lastGoodCharIndex < (i - 1) || isWhitespaceChar(s.charAt(i - 1)))) { // this is a wildcard char with nothing before it throw an error letting the user know continue; } // These characters are part of the query syntax and must be escaped if (c == '\\' || c == ':' || c == '^' || c == '|' || c == '&' || c == '"' || c == '+' || c == '-' || c == '!' || c == '(' || c == ')') { sb.append('\\'); } sb.append(c); lastGoodCharIndex = i; } if (log.isDebugEnabled()) { log.debug("Escaping '" + s + "' as '" + sb.toString() + "'"); } return sb.toString(); } /** * Return whether the input char is a whitespace char. * * @param c * the char to check * @return whether the input char is a whitespace char. */ private boolean isWhitespaceChar(final char c) { return c == '\r' || c == '\n' || c == '\t' || c == ' '; } /** * Modified version of QueryString.escape to escape characters that this query builder does not allow. Allow: ", +, * -, !, (, ), ~, *, ?. * * The allowed characters permit the advanced user to search ranges, exclude terms, mandate terms, use fuzzy and * wildcard search. * * @param s * the text to escape * @return a cleaned-up version of the input String */ public String escape(final String s) { StringBuffer sb = new StringBuffer(); for (int i = 0; i < s.length(); i++) { char c = s.charAt(i); // These characters are part of the query syntax and must be escaped if (c == '\\' || c == ':' || c == '^' || c == '|' || c == '&') { sb.append('\\'); } sb.append(c); } if (log.isDebugEnabled()) { log.debug("Escaping '" + s + "' as '" + sb.toString() + "'"); } return sb.toString(); } /** * Set the types of domain entity classes to search for. * * @param theResultTypes * the types of domain entity classes to search for. */ public void setResultTypes(final Class< ? >[] theResultTypes) { this.resultTypes = theResultTypes; // get the log text entityNames = ""; for (Class< ? > clazz : theResultTypes) { if (entityNames.length() > 0) { entityNames += ", "; } String className = clazz.getName(); entityNames += className.substring(className.lastIndexOf('.') + 1); } } /** * Set the search string format, a String.format mask that the user's search terms are applied to as the first * parameter. * * @param theSearchStringFormat * the search string format, a String.format mask that the user's search terms are applied to as the * first parameter. */ public void setSearchStringFormat(final String theSearchStringFormat) { this.searchStringFormat = theSearchStringFormat; } /** * Set the result transformer, responsible for transforming the property/alias arrays into useful objects. * * @param theResultTransformer * the result transformer, responsible for transforming the property/alias arrays into useful objects. */ public void setResultTransformer(final ResultTransformer theResultTransformer) { this.resultTransformer = theResultTransformer; } /** * Set the QueryParser to use to parse the formatted query string. * * @param inQueryParserBuilder * the QueryParserBuilder to use to build the QueryParser to parse the formatted query string. */ public void setQueryParserBuilder(final QueryParserBuilder inQueryParserBuilder) { this.queryParserBuilder = inQueryParserBuilder; } /** * Set which result fields are to be returned by the search. * * @param theResultFields * which result fields are to be returned by the search */ public void setResultFields(final List<String> theResultFields) { this.resultFields = theResultFields; } /** * QueryParserBuilder to use for advanced searches. * * @param inAdvancedQueryParserBuilder * the advancedQueryParserBuilder to set */ public void setAdvancedQueryParserBuilder(final QueryParserBuilder inAdvancedQueryParserBuilder) { advancedQueryParserBuilder = inAdvancedQueryParserBuilder; } /** * Get the entity names for logging. * * @return the entity names for logging */ public String getEntityNames() { return entityNames; } }