/* * RHQ Management Platform * Copyright (C) 2010 Red Hat, Inc. * All rights reserved. * * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation version 2 of the License. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; if not, write to the Free Software * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. */ package org.rhq.enterprise.server.search.execution; import java.io.PrintStream; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.List; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.rhq.core.domain.auth.Subject; import org.rhq.core.domain.criteria.SavedSearchCriteria; import org.rhq.core.domain.search.SavedSearch; import org.rhq.core.domain.search.SearchSubsystem; import org.rhq.core.domain.search.SearchSuggestion; import org.rhq.core.domain.search.SearchSuggestion.Kind; import org.rhq.core.domain.util.PageList; import org.rhq.core.domain.util.PageOrdering; import org.rhq.enterprise.server.search.SavedSearchManagerLocal; import org.rhq.enterprise.server.search.assist.SearchAssistant; import org.rhq.enterprise.server.search.assist.SearchAssistantFactory; import org.rhq.enterprise.server.util.CriteriaQuery; import org.rhq.enterprise.server.util.CriteriaQueryExecutor; import org.rhq.enterprise.server.util.LookupUtil; /** * @author Joseph Marques */ public class SearchAssistManager { private static final Log LOG = LogFactory.getLog(SearchAssistManager.class); private SavedSearchManagerLocal savedSearchManager = LookupUtil.getSavedSearchManager(); private static List<String> stringComparisonOperators = Arrays.asList("!==", "!=", "==", "="); private static List<String> numericComparisonOperators = Arrays.asList("<", ">", "!=", "="); private static List<String> enumComparisonOperators = Arrays.asList("!=", "="); private static List<String> allComparisonOperators = Arrays.asList("!==", "!=", "==", "=", "<", ">"); private static List<String> booleanOperators = Arrays.asList("|"); private Subject subject; private SearchSubsystem searchSubsystem; /* * states: * * empty expression --> suggest contexts with empty filter * incomplete context --> suggest contexts with passed filter, suffixed with open bracket for parameterization * complete context --> suggest comparison operators with empty filter * begin parameterizations --> suggest params with empty filter * incomplete parameterization --> suggest params with passed filter * complete parameterization --> suggest comparison operators with empty filter * incomplete operator --> suggest comparison operators with passed filter * complete operator --> suggest values with empty filter * incomplete value --> suggest values with passed filter * otherwise assume complete previous expression --> suggest boolean operators at the top * --> suggest contexts with empty filter below that */ static class SearchTermAssistant { private static final String CARET = "@@@"; String expression; List<String> terms; int currentTermIndex; int indexWithinTerm; public SearchTermAssistant(String expression, int caretPos) { // insert CARET token into expression at caretPos String before = expression.substring(0, caretPos); String after = expression.substring(caretPos); this.expression = before + CARET + after; tokenize(); for (int i = 0; i < terms.size(); i++) { String term = terms.get(i); int index = term.indexOf("@@@"); if (index != -1) { String replaced = term.replace("@@@", ""); terms.set(i, replaced); currentTermIndex = i; indexWithinTerm = index; break; } } } private void tokenize() { List<String> fragments = tokenizeIntoFragments(expression); this.terms = joinIntoTerms(fragments); } private List<String> tokenizeIntoFragments(String expression) { List<String> fragments = new ArrayList<String>(); char quoteChar = 0; StringBuilder term = new StringBuilder(); for (char nextChar : expression.toCharArray()) { if (quoteChar != 0) { // accept space inside quoted strings term.append(nextChar); if (nextChar == quoteChar) { // look for end quote quoteChar = 0; // i just left a quoted string } } else { // ignore spaces outside quoted string if (Character.isWhitespace(nextChar)) { // spaces delimit terms outside quoted strings if (term.length() > 0) { fragments.add(term.toString()); term = new StringBuilder(); } } else if (nextChar == '(' || nextChar == ')') { // completed ignore parentheses outside quoted strings } else { term.append(nextChar); if (nextChar == '\'' || nextChar == '"') { quoteChar = nextChar; // entering a quoted string } } } } if (term.length() > 0) { fragments.add(term.toString()); } return fragments; } private List<String> joinIntoTerms(List<String> fragments) { if (fragments.size() < 3) { return fragments; } List<String> terms = new ArrayList<String>(); int i = 1; while (i < fragments.size() - 1) { String before = fragments.get(i - 1); String term = fragments.get(i); if (allComparisonOperators.contains(term)) { String after = fragments.get(i + 1); terms.add(before + term + after); i += 3; // a triple of terms were processed } else { terms.add(before); i++; // only one term processed } } // if the last three terms weren't a valid triple, there will be two left over if (i < fragments.size()) { String nextToLast = fragments.get(fragments.size() - 2); String last = fragments.get(fragments.size() - 1); if (allComparisonOperators.contains(last)) { // last couple was an incomplete term terms.add(nextToLast + last); } else { terms.add(nextToLast); // there are unrelated terms, possibly simple text matches terms.add(last); } } else if (i == fragments.size()) { // found a triple just before last fragment String last = fragments.get(fragments.size() - 1); terms.add(last); } return terms; } public List<String> getTerms() { return terms; } public String getPreviousToken() { return terms.get(currentTermIndex - 1); } public String getCurrentToken() { return terms.get(currentTermIndex); } public String getFragmentBeforeCaret() { return getCurrentToken().substring(0, indexWithinTerm); } public void report(PrintStream output) { output.println("Expression: " + expression); List<String> fragments = tokenizeIntoFragments(expression); List<String> terms = joinIntoTerms(fragments); int counter = 0; for (String result : terms) { output.println("Token[" + (++counter) + "]: " + result); } output.println(); } public String toString() { return null; } public String getExpressionWithReplacement(String replacement) { StringBuilder builder = new StringBuilder(); for (int i = 0; i < terms.size(); i++) { if (i == currentTermIndex) { builder.append(replacement); } else { builder.append(terms.get(i)); } builder.append(' '); } return builder.toString(); } } static class ParsedContext { public enum State { CONTEXT, PARAM, OPERATOR, VALUE; } public enum Type { SIMPLE, ADVANCED; } public final String context; public final String param; public final String operator; public final String value; public final State state; public final Type type; private ParsedContext(String context, String param, String operator, String value) { this.state = computeState(context, param, operator, value); this.type = computeType(param, operator); this.context = (this.type == Type.SIMPLE) ? stripQuotes(context) : context; this.param = param == null ? "" : param; // ensure non-null this.operator = operator; this.value = value == null ? "" : value; // ensure non-null } private String stripQuotes(String data) { if (data.length() == 0) { return ""; } char first = data.charAt(0); char last = data.charAt(data.length() - 1); if (first == '\'' || first == '"') { if (data.length() == 1) { return ""; } data = data.substring(1); } if (last == '\'' || last == '"') { if (data.length() == 1) { return ""; } data = data.substring(0, data.length() - 1); } return data; } private State computeState(String context, String param, String operator, String value) { if (value != null) { return State.VALUE; } if (operator != null) { return State.OPERATOR; } if (param != null) { return State.PARAM; } return State.CONTEXT; } public static ParsedContext get(String term) { int index = 0; char[] expr = term.toCharArray(); StringBuilder buffer = new StringBuilder(); while (index < expr.length && expr[index] != '[' && expr[index] != '!' && expr[index] != '=') { // read up until beginning of param or operator buffer.append(expr[index]); index++; } String context = buffer.toString(); // either beginning of param, beginning of operator, or end of expression buffer = new StringBuilder(); if (index == expr.length || (expr[index] != '[' && expr[index] != '!' && expr[index] != '=')) { // if not beginning of param or operator, return return new ParsedContext(context, null, null, null); } String param = null; if (expr[index] == '[') { // parameterized context index++; // skip over '[' while (index < expr.length && expr[index] != ']') { // read up until end of param buffer.append(expr[index]); index++; } param = buffer.toString(); buffer = new StringBuilder(); if (index == expr.length || expr[index] != ']') { // if not end of param, incomplete param so return return new ParsedContext(context, param, null, null); } index++; // skip over ']' } while (index < expr.length && (expr[index] == '!' || expr[index] == '=')) { // read up until end of operator chars buffer.append(expr[index]); index++; } String operator = buffer.toString(); if (index == expr.length) { // return if end of expression return new ParsedContext(context, param, operator, null); } String value = term.substring(index); // any remain characters are the value if (value.length() > 0) { if (value.charAt(0) == '\'' || value.charAt(0) == '"') { value = value.substring(1); } } return new ParsedContext(context, param, operator, value); } private Type computeType(String param, String operator) { if (operator != null) { return Type.ADVANCED; // non-null operator implies an incomplete, advanced term } if (param != null) { return Type.ADVANCED; // non-null operator implies an incomplete, advanced term } /* * otherwise it's still possible that the user wants a simple text completion. * * note: it should not be necessary to check for non-null 'value' because operation/param would have * had to be non-null first, which we've already verified by getting to here. */ return Type.SIMPLE; } public String toString() { StringBuilder buffer = new StringBuilder(); buffer.append(getClass().getSimpleName()).append("["); buffer.append("state=").append(state); buffer.append(", context(").append(context); buffer.append("), param(").append(param); buffer.append("), operator(").append(operator); buffer.append("), value(").append(value).append(")]"); return buffer.toString(); } } public SearchAssistManager(Subject subject, SearchSubsystem searchSubsystem) { this.subject = subject; this.searchSubsystem = searchSubsystem; } protected SearchAssistant getSearchAssistant() { return SearchAssistantFactory.getAssistant(subject, searchSubsystem); } protected SearchAssistant getTabAwareSearchAssistant(String tab) { return SearchAssistantFactory.getTabAwareAssistant(subject, searchSubsystem, tab); } private List<String> getAllContexts() { List<String> results = new ArrayList<String>(getSearchAssistant().getSimpleContexts()); for (String parameterized : getSearchAssistant().getParameterizedContexts()) { results.add(parameterized + "["); } return results; } public List<SearchSuggestion> getSuggestions(String expression, int caretPos) { return getTabAwareSuggestions(expression, caretPos, null); } public List<SearchSuggestion> getTabAwareSuggestions(String expression, int caretPos, String tab) { List<SearchSuggestion> results = new ArrayList<SearchSuggestion>(); if (expression == null) { expression = ""; } // make sure caretPos is at a valid index prior to parsing it for suggestions if (caretPos > expression.length()) { caretPos = expression.length(); } List<SearchSuggestion> simple = getSimpleSuggestions(expression, caretPos, tab); List<SearchSuggestion> advanced = getAdvancedSuggestions(expression, caretPos, tab); List<SearchSuggestion> userSavedSearches = getUserSavedSearchSuggestions(expression); //List<SearchSuggestion> globalSavedSearches = getGlobalSavedSearchSuggestions(expression); results.addAll(simple); results.addAll(advanced); results.addAll(userSavedSearches); //results.addAll(globalSavedSearches); Collections.sort(results); return results; } public List<SearchSuggestion> getSimpleSuggestions(String expression, int caretPos, String tab) { SearchAssistant completor = getTabAwareSearchAssistant(tab); LOG.debug("getSimpleSuggestions: START"); SearchTermAssistant assistant = new SearchTermAssistant(expression, caretPos); String beforeCaret = assistant.getFragmentBeforeCaret(); ParsedContext parsed = ParsedContext.get(beforeCaret); if (parsed.type != ParsedContext.Type.SIMPLE) { return Collections.emptyList(); } /* * if we know the ParsedContext object may represent a simple search term, then the 'context' attribute * would hold it's current value, which in this case we'll extract and use as the simple value match */ String parsedTerm = parsed.context; String primarySimpleContext = completor.getPrimarySimpleContext(); if (LOG.isDebugEnabled()) { LOG.debug("getSimpleSuggestions: suggesting value completions for a simple context [" + primarySimpleContext + "]"); } List<String> valueSuggestions = padWithQuotes(beforeCaret, completor.getValues(primarySimpleContext, null, parsedTerm)); List<SearchSuggestion> suggestions = convert(valueSuggestions, parsed, parsedTerm, Kind.Simple); return suggestions; } public List<SearchSuggestion> getAdvancedSuggestions(String expression, int caretPos, String tab) { SearchAssistant completor = getTabAwareSearchAssistant(tab); boolean isDebugEnabled = LOG.isDebugEnabled(); LOG.debug("getAdvancedSuggestions: START"); SearchTermAssistant assistant = new SearchTermAssistant(expression, caretPos); String[] tokens = assistant.getTerms().toArray(new String[0]); if (isDebugEnabled) { LOG.debug("" + tokens.length + " tokens are " + Arrays.asList(tokens)); } if (tokens.length == 0 || caretPos == 0) { LOG.debug("getAdvancedSuggestions: no terms"); return convert(getAllContexts()); // no terms yet defined } String beforeCaret = assistant.getFragmentBeforeCaret(); if (isDebugEnabled) { LOG.debug("getAdvancedSuggestions: beforeCaret is '" + beforeCaret + "'"); } String pad = getQuotePadding(beforeCaret); if (isDebugEnabled) { LOG.debug("getAdvancedSuggestions: padding is ~" + pad + "~"); } if (beforeCaret.startsWith("'") || beforeCaret.startsWith("\"")) { return Collections.emptyList(); } ParsedContext parsed = ParsedContext.get(beforeCaret); if (isDebugEnabled) { LOG.debug("getAdvancedSuggestions: parsed is " + parsed); } switch (parsed.state) { case CONTEXT: if (parsed.context.equals("")) { LOG.debug("getAdvancedSuggestions: empty term, suggesting all contexts"); return convert(getAllContexts()); /* if (tokens.length == 1) { debug("getAdvancedSuggestions: no terms yet, suggesting contexts"); return convert(getAllContexts()); } else if (isBooleanTerm(assistant.getPreviousToken())) { debug("getAdvancedSuggestions: previous term was boolean, suggesting contexts"); return convert(getAllContexts()); } else { debug("getAdvancedSuggestions: previous term was not boolean, suggesting boolean"); return convert(booleanOperators); } */ } else if (isBooleanTerm(parsed.context)) { LOG.debug("getAdvancedSuggestions: beforeCaret is whole boolean operator"); return convert(getAllContexts()); // TODO: should we tell user to type a space first? } else { // check if this context is complete or not if (completor.getSimpleContexts().contains(parsed.context)) { LOG.debug("getAdvancedSuggestions: search term is simple context, wants operator"); List<String> contextComparisonOperators = getComparisonOperatorsForContext(parsed.context, completor); return convert(pad(parsed.context, contextComparisonOperators, ""), parsed, parsed.context); } if (completor.getParameterizedContexts().contains(parsed.context)) { LOG.debug("getAdvancedSuggestions: search term is parameterized context, wants open bracket"); return convert(Arrays.asList(parsed.context + "["), parsed, parsed.context); } LOG.debug("getAdvancedSuggestions: search term wants context completion"); List<String> startsWithContexts = new ArrayList<String>(); String lowerCaseParsedContext = parsed.context.toLowerCase(); // contexts should already be lower-cased for (String context : completor.getSimpleContexts()) { if (context.indexOf(lowerCaseParsedContext) != -1) { startsWithContexts.add(context); } } for (String context : completor.getParameterizedContexts()) { if (context.indexOf(lowerCaseParsedContext) != -1) { startsWithContexts.add(context + "["); } } return convert(startsWithContexts, parsed, parsed.context); } case PARAM: LOG.debug("getAdvancedSuggestions: param state"); return convert(pad(parsed.context + "[", completor.getParameters(parsed.context, parsed.param), "]"), parsed, parsed.param); case OPERATOR: LOG.debug("getAdvancedSuggestions: operator state"); if (allComparisonOperators.contains(parsed.operator)) { LOG.debug("search term is complete operator, suggesting values instead"); List<String> valueSuggestions = padWithQuotes(beforeCaret, completor.getValues(parsed.context, parsed.param, "")); if (completor.getSimpleContexts().contains(parsed.context)) { LOG.debug("getAdvancedSuggestions: suggesting value completions for a simple context"); return convert(pad(parsed.context + parsed.operator, valueSuggestions, "")); } else { LOG.debug("getAdvancedSuggestions: suggesting value completions for a parameterized context"); return convert(pad(parsed.context + "[" + parsed.param + "]" + parsed.operator, valueSuggestions, "")); } } List<String> operatorSuggestions = new ArrayList<String>(); List<String> contextComparisonOperators = getComparisonOperatorsForContext(parsed.context, completor); for (String op : contextComparisonOperators) { if (op.startsWith(parsed.operator)) { operatorSuggestions.add(op); } } LOG.debug("getAdvancedSuggestions: providing suggestions for comparison operators"); if (completor.getSimpleContexts().contains(parsed.context)) { return convert(pad(parsed.context, operatorSuggestions, "")); } else { return convert(pad(parsed.context + "[" + parsed.param + "]", operatorSuggestions, "")); } case VALUE: LOG.debug("getAdvancedSuggestions: value state"); List<String> valueSuggestions = padWithQuotes(beforeCaret, completor.getValues(parsed.context, parsed.param, parsed.value)); if (completor.getSimpleContexts().contains(parsed.context)) { LOG.debug("getAdvancedSuggestions: suggesting value completions for a simple context"); return convert(pad(parsed.context + parsed.operator, valueSuggestions, ""), parsed, parsed.value); } else { LOG.debug("getAdvancedSuggestions: suggesting value completions for a parameterized context"); return convert(pad(parsed.context + "[" + parsed.param + "]" + parsed.operator, valueSuggestions, ""), parsed, parsed.value); } default: return Collections.emptyList(); } } private List<String> getComparisonOperatorsForContext(String context, SearchAssistant completor) { if (completor.isNumericalContext(context)) { return numericComparisonOperators; } else if (completor.isNumericalContext(context)) { return enumComparisonOperators; } else { return stringComparisonOperators; } } public List<SearchSuggestion> getUserSavedSearchSuggestions(String expression) { if (null == subject) { return new ArrayList<SearchSuggestion>(); } expression = expression.trim().toLowerCase().replaceAll("\\s+", " "); SavedSearchCriteria criteria = new SavedSearchCriteria(); criteria.addFilterSubjectId(subject.getId()); criteria.addFilterSearchSubsystem(searchSubsystem); if (expression.equals("") == false) { criteria.addFilterName(expression); } criteria.setCaseSensitive(false); criteria.addSortName(PageOrdering.ASC); //Use CriteriaQuery to automatically chunk/page through criteria query results CriteriaQueryExecutor<SavedSearch, SavedSearchCriteria> queryExecutor = new CriteriaQueryExecutor<SavedSearch, SavedSearchCriteria>() { @Override public PageList<SavedSearch> execute(SavedSearchCriteria criteria) { return savedSearchManager.findSavedSearchesByCriteria(subject, criteria); } }; CriteriaQuery<SavedSearch, SavedSearchCriteria> savedSearchResults = new CriteriaQuery<SavedSearch, SavedSearchCriteria>( criteria, queryExecutor); List<SearchSuggestion> results = new ArrayList<SearchSuggestion>(); for (SavedSearch next : savedSearchResults) { String label = next.getName(); if (next.getResultCount() != null) { label += " (" + next.getResultCount() + ")"; } String value = next.getName(); int index = next.getName().toLowerCase().indexOf(expression); SearchSuggestion suggestion = new SearchSuggestion(Kind.UserSavedSearch, label, value, next.getPattern(), index, expression.length()); results.add(suggestion); } return results; } public List<SearchSuggestion> getGlobalSavedSearchSuggestions(String expression) { expression = expression.trim().toLowerCase().replaceAll("\\s+", " "); SavedSearchCriteria criteria = new SavedSearchCriteria(); criteria.addFilterGlobal(true); criteria.addFilterSearchSubsystem(searchSubsystem); if (expression.equals("") == false) { criteria.addFilterName(expression); } criteria.setCaseSensitive(false); criteria.addSortName(PageOrdering.ASC); //Use CriteriaQuery to automatically chunk/page through criteria query results CriteriaQueryExecutor<SavedSearch, SavedSearchCriteria> queryExecutor = new CriteriaQueryExecutor<SavedSearch, SavedSearchCriteria>() { @Override public PageList<SavedSearch> execute(SavedSearchCriteria criteria) { return savedSearchManager.findSavedSearchesByCriteria(subject, criteria); } }; CriteriaQuery<SavedSearch, SavedSearchCriteria> savedSearchResults = new CriteriaQuery<SavedSearch, SavedSearchCriteria>( criteria, queryExecutor); List<SearchSuggestion> results = new ArrayList<SearchSuggestion>(); for (SavedSearch next : savedSearchResults) { String label = next.getName(); if (next.getResultCount() != null) { label += " (" + next.getResultCount() + ")"; } String value = next.getName(); int index = next.getName().toLowerCase().indexOf(expression); SearchSuggestion suggestion = new SearchSuggestion(Kind.GlobalSavedSearch, label, value, next.getPattern(), index, expression.length()); results.add(suggestion); } return results; } private boolean isBooleanTerm(String term) { for (String op : booleanOperators) { if (op.equals(term)) { return true; } } return false; } private List<SearchSuggestion> convert(List<String> suggestions) { return convert(suggestions, null, "", Kind.Advanced); } private List<SearchSuggestion> convert(List<String> suggestions, ParsedContext parsed, String term) { return convert(suggestions, parsed, term, Kind.Advanced); } private List<SearchSuggestion> convert(List<String> suggestions, ParsedContext parsed, String term, Kind kind) { int startIndex = getStartIndex(parsed); LOG.debug("convert(suggestions.size()=" + suggestions.size() + ", " + parsed + ", " + term + ", " + kind + ")"); term = term.toLowerCase(); List<SearchSuggestion> results = new ArrayList<SearchSuggestion>(suggestions.size()); for (String suggestion : suggestions) { boolean startBounded = term.startsWith("^"); boolean endBounded = term.endsWith("$"); if (startBounded && endBounded) { term = term.substring(1, term.length() - 1); } else if (startBounded) { term = term.substring(1); } else if (endBounded) { term = term.substring(0, term.length() - 1); } int index = suggestion.toLowerCase().indexOf(term, startIndex); results.add(new SearchSuggestion(kind, suggestion, index, term.length())); } return results; } private int getStartIndex(ParsedContext parsed) { if (parsed == null) { return 0; } switch (parsed.state) { case PARAM: return parsed.context.length(); case OPERATOR: return parsed.context.length() + (parsed.param != null ? parsed.param.length() : 0); case VALUE: return parsed.context.length() + (parsed.param != null ? parsed.param.length() : 0) + parsed.operator.length(); } return 0; // case CONTEXT } private List<String> pad(String leftPad, List<String> data, String rightPad) { List<String> results = new ArrayList<String>(); for (String next : data) { results.add(leftPad + next + rightPad); } return results; } private List<String> padWithQuotes(String beforeCaret, List<String> data) { String defaultPad = getQuotePadding(beforeCaret); List<String> results = new ArrayList<String>(); for (String next : data) { if (next == null) { results.add("null"); // null search comparisons should never be quoted continue; } boolean hasWhiteSpace = next.matches(".*\\s.*"); if (hasWhiteSpace == false) { // don't pad things that don't need padding results.add(next); continue; } // we do have whitespace, let's also see if we have quotes boolean hasSingleQuote = next.indexOf("'") != -1; boolean hasDoubleQuote = next.indexOf('"') != -1; if (hasSingleQuote && hasDoubleQuote) { // don't pad if suggestion has both single- and double-quotes // instead, treat suggestion as individual terms, otherwise parser will bomb results.add(next); continue; } String pad = null; if (hasSingleQuote) { pad = "\""; // pad with double-quotes } else if (hasDoubleQuote) { pad = "'"; // pad with single-quotes } else { // otherwise respect the user-chosen padding pad = defaultPad; } results.add(pad + next + pad); } return results; } private String getQuotePadding(String beforeCaret) { if (beforeCaret.equals("")) { return "\""; } // if not empty, it has at least one char char first = beforeCaret.charAt(0); if (first == '\'') { return "'"; } else /* if (first == '"') */{ return "\""; } } }