/*******************************************************************************
* Copyright (c) 2013, Perforce Software
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
*
* Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package com.perforce.search.model.impl;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import com.perforce.search.exception.ErrorModelException;
import com.perforce.search.manager.SchemaKey;
import com.perforce.search.model.SearchField;
import com.perforce.search.model.SearchModel;
public class SearchModelImpl implements SearchModel {
private String userId;
private String queryRaw;
private List<String> paths;
private String ticket;
private SearchType searchType;
private ResultFormat resultFormat;
private List<SearchField> searchFields = new ArrayList<SearchField>();
private int startRow = 0;
private int rowCount = 100;
// TODO: make this configurable
private int fileProximity = 10;
private int contentProximity = 100;
private boolean boostByModifiedTime = true;
private String querySolr;
public SearchModelImpl() {
}
public SearchModelImpl(final SearchModel other) {
this.userId = other.getUserId();
this.queryRaw = other.getQueryRaw();
this.paths = other.getPaths();
this.ticket = other.getTicket();
this.searchType = other.getSearchType();
this.resultFormat = other.getResultFormat();
if (other.getSearchFields() != null) {
this.searchFields.addAll(Arrays.asList(other.getSearchFields()));
}
this.startRow = other.getStartRow();
this.rowCount = other.getRowCount();
}
public static SearchModel createSearchModel(final List<String> paths,
final String query, final String userId, final String ticket,
final List<SearchField> searchFields, final SearchType searchType,
final ResultFormat format, final int startRow, final int rowCount) {
SearchModelImpl impl = new SearchModelImpl();
impl.userId = userId;
impl.queryRaw = query;
impl.paths = paths;
impl.ticket = ticket;
impl.searchType = searchType;
impl.resultFormat = format;
impl.searchFields.addAll(searchFields);
impl.startRow = startRow;
impl.rowCount = rowCount;
return impl;
}
@Override
public String getUserId() {
return userId;
}
@Override
public String getQueryProcessed() {
if (querySolr != null) {
return querySolr;
}
// can we build it?
return parseQueryType();
}
@Override
public List<String> getPaths() {
if (paths == null) {
paths = new ArrayList<String>();
paths.add("//...");
}
return paths;
}
@Override
public String getTicket() {
return this.ticket;
}
public void setTicket(final String ticket) {
this.ticket = ticket;
}
public void setQuery(final String query) {
this.queryRaw = query;
}
public void setPaths(final List<String> paths) {
this.paths = paths;
}
public static void validate(final SearchModel model)
throws ErrorModelException {
// TODO: better validation
// minimum: query, userid?
final String userId = model.getUserId();
if (userId == null || userId.length() == 0) {
throw new ErrorModelException(ErrorModels.Error_InvalidQueryParams);
}
// query or fields or paths must have a value
if ((model.getQueryRaw() == null || model.getQueryRaw().length() == 0)
&& (model.getPaths() == null || model.getPaths().size() == 0)
&& (model.getSearchFields() == null || model.getSearchFields().length == 0)) {
throw new ErrorModelException(ErrorModels.Error_InvalidQueryParams);
}
}
/*
* private members start here
*/
/**
* Build the query string based on some heuristics
*
* @return
*/
private String parseQueryType() {
/* @formatter:off */
/*
* queries break into 3 components:
* 1) general search: (filename:"words"^N OR "words"~M)
* 2) path search: (id:path0* OR id:path1* OR ...)
* 3) field/value search: (field0: value0 AND field1: value1 AND ...)
*
* Once these parts are built you can "AND" them together
*/
String general = null;
/*
* f|files: (filename:"words"~N)
* c|content: ("words"~M)
* l|literal: passed through without modification
* (search both): (filesearch OR contentsearch)
*
* All of these include other fields (id:path*) and random other fields
*/
/* @formatter:on */
// build the base query
if (queryRaw.startsWith("f:") || queryRaw.startsWith("files:")) {
general = getFilesOnlyQuery(true);
} else if (queryRaw.startsWith("c:") || queryRaw.startsWith("content:")) {
general = getContentOnlyQuery(true);
} else if (queryRaw.startsWith("l:") || queryRaw.startsWith("literal:")) {
querySolr = getLiteralQuery(true);
return querySolr;
} else {
general = getGeneralSearch();
}
// add the optional path and other indexed fields
String path = getPathQuery();
String fields = getFieldsQuery();
// build the whole string
StringBuilder sb = new StringBuilder();
sb = appendQuery(general, sb);
sb = appendQuery(path, sb);
sb = appendQuery(fields, sb);
querySolr = sb.toString();
// prepend the time booster
if (boostByModifiedTime && querySolr.length() > 0) {
querySolr = getModifiedTimeBooster() + querySolr;
}
return querySolr;
}
public static String getModifiedTimeBooster() {
return "{!boost b=recip(ms(NOW," + SchemaKey.MODIFIED_TIME
+ "),3.16e-11,1,1)}";
}
private StringBuilder appendQuery(final String query, final StringBuilder sb) {
if (query == null) {
return sb;
}
if (sb.length() > 0) {
sb.append(" AND ");
}
return sb.append(query);
}
// (id:path0 OR id:path1 OR ...)
private String getPathQuery() {
if (paths == null || paths.size() == 0) {
return null;
}
StringBuilder query = new StringBuilder("(");
for (int i = 0; i < paths.size(); ++i) {
query.append(SchemaKey.ID).append(":")
.append(solrPathQuery(paths.get(i).trim()));
if (i < paths.size() - 1) {
query.append(" OR ");
}
}
return query.append(")").toString();
}
private String getFieldsQuery() {
if (searchFields == null || searchFields.size() == 0) {
return null;
}
StringBuilder sb = new StringBuilder("(");
for (int i = 0; i < searchFields.size(); ++i) {
SearchField f = searchFields.get(i);
// do not escape "date" fields, they can have a special format for
// ranges, etc.
// TODO: configurable/read from schema
String value = f.getValue().trim();
if (!f.getField().equals(SchemaKey.MODIFIED_TIME)
&& !f.getField().equals(SchemaKey.LAST_MODIFIED)) {
value = escapeSolrStringNoStars(value);
}
sb.append(escapeSolrString(f.getField())).append(":").append(value);
if (i < searchFields.size() - 1) {
sb.append(" AND ");
}
}
return sb.append(")").toString();
}
private String getGeneralSearch() {
if (queryRaw == null || queryRaw.length() == 0) {
return null;
}
// query as (filesearch OR contentsearch)
StringBuilder sb = new StringBuilder();
return sb.append("(").append(getFilesOnlyQuery(false)).append(" OR ")
.append(getContentOnlyQuery(false)).append(")").toString();
}
private String getLiteralQuery(final boolean stripHeader) {
return ((!stripHeader) ? this.queryRaw : this.queryRaw
.substring(this.queryRaw.indexOf(":") + 1)).trim();
}
private String getContentOnlyQuery(final boolean stripHeader) {
// [c|content:]("query"~proximity)
String stripped = ((!stripHeader) ? this.queryRaw : this.queryRaw
.substring(this.queryRaw.indexOf(":") + 1)).trim();
StringBuilder sb = new StringBuilder();
return sb.append("(\"").append(escapeSolrString(stripped))
.append("\"~").append(this.contentProximity).append(")")
.toString();
}
private String getFilesOnlyQuery(final boolean stripHeader) {
// [f|files:](filename:"words")
String stripped = escapeSolrString(((!stripHeader) ? this.queryRaw
: this.queryRaw.substring(this.queryRaw.indexOf(":") + 1))
.trim());
StringBuilder sb = new StringBuilder();
// for a single word (no spaces), use this format:
// (filename:word OR filename:*word*)
if (stripped.indexOf(" ") < 0) {
sb.append("(").append(SchemaKey.FILENAME).append(":")
.append(stripped).append(" OR ").append(SchemaKey.FILENAME)
.append(":").append("*").append(stripped).append("*)");
} else {
// use multi-word with proximity
sb.append("(").append(SchemaKey.FILENAME).append(":");
sb.append("\"").append(stripped).append("\"~")
.append(this.fileProximity).append(")");
}
return sb.toString();
}
public void setResultsFormat(final ResultFormat format) {
this.resultFormat = format;
}
@Override
public int getStartRow() {
return this.startRow;
}
@Override
public int getRowCount() {
return this.rowCount;
}
@Override
public SearchField[] getSearchFields() {
return this.searchFields == null ? null : this.searchFields
.toArray(new SearchField[searchFields.size()]);
}
@Override
public SearchType getSearchType() {
return this.searchType;
}
@Override
public ResultFormat getResultFormat() {
return this.resultFormat == null ? ResultFormat.SIMPLE
: this.resultFormat;
}
public void setUserId(final String userId) {
this.userId = userId;
}
public void setQueryRaw(final String queryRaw) {
this.queryRaw = queryRaw;
}
public void setSearchType(final SearchType searchType) {
this.searchType = searchType;
}
public void setResultFormat(final ResultFormat resultFormat) {
this.resultFormat = resultFormat;
}
public void setSearchFields(final List<SearchField> searchFields) {
this.searchFields.addAll(searchFields);
}
public void setStartRow(final int startRow) {
this.startRow = startRow;
}
public void setRowCount(final int rowCount) {
this.rowCount = rowCount;
}
@Override
public String getQueryRaw() {
return this.queryRaw;
}
public void addSearchField(final SearchField field) {
if (this.searchFields == null) {
this.searchFields = new ArrayList<SearchField>();
}
this.searchFields.add(field);
}
/*
* helper functions, probably move these somewhere
*/
public static String escapeSolrStringNoStars(final String str) {
// escape: + - && || ! ( ) { } [ ] ^ " ~ * ? : \ /
// do not include escaping spaces; when they occur it
// is meant as a search term
final String[] escapes = { "\\", "+", "-", "&&", "||", "!", "(", ")",
"{", "}", "[", "]", "^", "\"", "~", "?", ":", "/" };
String retString = str;
for (String s : escapes) {
retString = retString.replace(s, "\\" + s);
}
return retString;
}
public static String escapeSolrString(final String str) {
// escape: with *'s
return escapeSolrStringNoStars(str).replace("*", "\\*");
}
private static String solrPathQuery(final String path) {
final String ellipses = "...";
String retPath = path;
if (retPath.endsWith(ellipses)) {
retPath = retPath.substring(0, path.length()
- (ellipses.length() + 1));
}
if (!retPath.endsWith("/")) {
retPath += "/";
}
return escapeSolrString(retPath) + "*";
}
public void addHeadRevisionField() {
// scan the fields for headrevision, if absent add headrevision:true
if (searchFields == null) {
searchFields = new ArrayList<SearchField>();
}
for (SearchField sf : searchFields) {
if (sf.getField().equals(SchemaKey.HEADREV)) {
return;
}
}
searchFields.add(new SearchFieldImpl(SchemaKey.HEADREV, "true"));
}
}