diff options
author | Jon Bratseth <bratseth@yahoo-inc.com> | 2016-06-15 23:09:44 +0200 |
---|---|---|
committer | Jon Bratseth <bratseth@yahoo-inc.com> | 2016-06-15 23:09:44 +0200 |
commit | 72231250ed81e10d66bfe70701e64fa5fe50f712 (patch) | |
tree | 2728bba1131a6f6e5bdf95afec7d7ff9358dac50 /container-search/src/main/java/com/yahoo/search/query |
Publish
Diffstat (limited to 'container-search/src/main/java/com/yahoo/search/query')
106 files changed, 11947 insertions, 0 deletions
diff --git a/container-search/src/main/java/com/yahoo/search/query/Model.java b/container-search/src/main/java/com/yahoo/search/query/Model.java new file mode 100644 index 00000000000..588580dda4d --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/Model.java @@ -0,0 +1,521 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.query; + +import com.yahoo.language.Language; +import com.yahoo.language.Linguistics; +import com.yahoo.language.LocaleFactory; +import com.yahoo.prelude.query.CompositeItem; +import com.yahoo.prelude.query.Item; +import com.yahoo.prelude.query.TaggableItem; +import com.yahoo.prelude.query.textualrepresentation.TextualQueryRepresentation; +import com.yahoo.processing.request.CompoundName; +import com.yahoo.search.Query; +import com.yahoo.search.query.parser.Parsable; +import com.yahoo.search.query.parser.Parser; +import com.yahoo.search.query.parser.ParserEnvironment; +import com.yahoo.search.query.parser.ParserFactory; +import com.yahoo.search.query.profile.types.FieldDescription; +import com.yahoo.search.query.profile.types.QueryProfileType; +import com.yahoo.search.searchchain.Execution; + +import java.util.*; + +import static com.yahoo.text.Lowercase.toLowerCase; + +/** + * The parameters defining the recall of a query. + * + * @author <a href="mailto:arnebef@yahoo-inc.com">Arne Bergene Fossaa</a> + * @author <a href="mailto:bratseth@yahoo-inc.com">Jon Bratseth</a> + */ +public class Model implements Cloneable { + + /** The type representing the property arguments consumed by this */ + private static final QueryProfileType argumentType; + private static final CompoundName argumentTypeName; + + public static final String MODEL = "model"; + public static final String PROGRAM = "program"; + public static final String QUERY_STRING = "queryString"; + public static final String TYPE = "type"; + public static final String FILTER = "filter"; + public static final String DEFAULT_INDEX = "defaultIndex"; + public static final String LANGUAGE = "language"; + public static final String ENCODING = "encoding"; + public static final String SOURCES = "sources"; + public static final String SEARCH_PATH = "searchPath"; + public static final String RESTRICT = "restrict"; + + static { + argumentType =new QueryProfileType(MODEL); + argumentType.setStrict(true); + argumentType.setBuiltin(true); + //argumentType.addField(new FieldDescription(PROGRAM, "string", "yql")); // TODO: Custom type + argumentType.addField(new FieldDescription(QUERY_STRING, "string", "query")); + argumentType.addField(new FieldDescription(TYPE, "string", "type")); + argumentType.addField(new FieldDescription(FILTER, "string","filter")); + argumentType.addField(new FieldDescription(DEFAULT_INDEX, "string", "default-index def-idx defidx")); + argumentType.addField(new FieldDescription(LANGUAGE, "string", "language lang")); + argumentType.addField(new FieldDescription(ENCODING, "string", "encoding")); + argumentType.addField(new FieldDescription(SOURCES, "string", "sources search")); + argumentType.addField(new FieldDescription(SEARCH_PATH, "string", "searchpath")); + argumentType.addField(new FieldDescription(RESTRICT, "string", "restrict")); + argumentType.freeze(); + argumentTypeName=new CompoundName(argumentType.getId().getName()); + } + + public static QueryProfileType getArgumentType() { return argumentType; } + + /** The name of the query property used for generating hit count estimate queries. */ + public static final CompoundName ESTIMATE = new CompoundName("hitcountestimate"); + + private String encoding = null; + private String queryString = ""; + private String filter = null; + private Language language = null; + private Locale locale = null; + private QueryTree queryTree = null; // The actual query. This is lazily created from the program + private String defaultIndex = null; + private Query.Type type = Query.Type.ALL; + private Query parent; + private Set<String> sources=new LinkedHashSet<>(); + private Set<String> restrict=new LinkedHashSet<>(); + private String searchPath; + private String documentDbName = null; + private Execution execution=new Execution(new Execution.Context(null, null, null, null, null)); + + public Model(Query query) { + setParent(query); + } + + /** + * Creates trace a message of language detection results into this Model + * instance's parent query. Do note this will give bogus results if the + * Execution instance is not set correctly. This is done automatically + * inside {@link Execution#search(Query)}. If tracing the same place as + * creating the query instance, {@link #setExecution(Execution)} has to be + * invoked first with the same Execution instance the query is intended to + * be run by. + */ + public void traceLanguage() { + if (getParent().getTraceLevel()<2) return; + if (language != null) { + getParent().trace("Language " + getLanguage() + " specified directly as a parameter", false, 2); + } + else { + Language l = getParsingLanguage(); + // Don't include the query, it will trigger query parsing + getParent().trace("Detected language: " + l, false, 2); + getParent().trace("Language " + l + " determined by " + + (Language.fromEncoding(encoding) != Language.UNKNOWN ? "query encoding" : + "the characters in the terms") + ".", false, 2); + } + } + + /** + * Gets the language to use for parsing. If this is explicitly set, that language is returned, otherwise + * it is guessed from the query string. If this does not yield an actual language, English is + * returned as the default. + * + * @return the language determined, never null + */ + public Language getParsingLanguage() { + Language language = getLanguage(); + if (language != null) { + return language; + } + language = Language.fromEncoding(encoding); + if (language != Language.UNKNOWN) { + return language; + } + Linguistics linguistics = execution.context().getLinguistics(); + if (linguistics != null) { + language = linguistics.getDetector().detect(queryString, null).getLanguage(); + } + if (language != Language.UNKNOWN) { + return language; + } + return Language.ENGLISH; + } + + /** Returns the explicitly set parsing language of this query model, or null if none */ + public Language getLanguage() { return language; } + + /** Explicitly sets the language to be used during parsing */ + public void setLanguage(Language language) { this.language = language; } + + /** + * <p>Explicitly sets the language to be used during parsing. The argument is first normalized by replacing + * underscores with hyphens (to support locale strings being used as RFC 5646 language tags), and then forwarded to + * {@link #setLocale(String)} so that the Locale information of the tag is preserved.</p> + * + * @param language The language string to parse. + * @see #getLanguage() + * @see #setLocale(String) + */ + public void setLanguage(String language) { + setLocale(language.replace("_", "-")); + } + + /** + * <p>Returns the explicitly set parsing locale of this query model, or null if none.</p> + * + * @return The locale of this. + * @see #setLocale(Locale) + */ + public Locale getLocale() { + return locale; + } + + /** + * <p>Explicitly sets the locale to be used during parsing. This method also calls {@link #setLanguage(Language)} + * with the corresponding {@link Language} instance.</p> + * + * @param locale The locale to set. + * @see #getLocale() + * @see #setLanguage(Language) + */ + public void setLocale(Locale locale) { + this.locale = locale; + setLanguage(Language.fromLocale(locale)); + } + + /** + * <p>Explicitly sets the locale to be used during parsing. This creates a Locale instance from the given language + * tag, and passes that to {@link #setLocale(Locale)}.</p> + * + * @param languageTag The language tag to parse. + * @see #setLocale(Locale) + */ + public void setLocale(String languageTag) { + setLocale(LocaleFactory.fromLanguageTag(languageTag)); + } + + /** Returns the encoding used in the query as a lowercase string */ + public String getEncoding() { return encoding; } + + /** Sets the encoding which was used in the received query string */ + public void setEncoding(String encoding) { + this.encoding = toLowerCase(encoding); + } + + /** Set the path for which backend nodes to forward the search too. */ + public void setSearchPath(String searchPath) { this.searchPath = searchPath; } + + public String getSearchPath() { return searchPath; } + + /** + * Set the query from a string. This will not be parsed into a query tree until that tree is attempted accessed. + * Note that setting this will clear the current query tree. Usually, this should <i>not</i> be modified - + * changes to the query should be implemented as modifications on the query tree structure. + * <p> + * Passing null causes this to be set to an empty string. + */ + public void setQueryString(String queryString) { + if (queryString==null) queryString=""; + this.queryString = queryString; + queryTree=null; // Cause parsing of the new query string next time the tree is accessed + } + + /** + * Returns the query string which caused the original query tree of this model to come about. + * Note that changes to the query tree are <b>not</b> reflected in this query string. + * + * @return the original (or reassigned) query string - never null + */ + public String getQueryString() { return queryString; } + + /** + * Returns the query as an object structure. + * This causes parsing of the query string if it has changed since this was last called + * (i.e query parsing is lazy) + */ + public QueryTree getQueryTree() { + if (queryTree == null) { + Parser parser = ParserFactory.newInstance(type, ParserEnvironment.fromExecutionContext(execution.context())); + queryTree = parser.parse(Parsable.fromQueryModel(this)); + if (parent.getTraceLevel() >= 2) { + parent.trace("Query parsed to: " + parent.yqlRepresentation(), 2); + } + } + return queryTree; + } + + /** + * Returns the filter string set for this query. + * The filter is included in the query tree at the time the query tree is parsed + */ + public String getFilter() { return filter; } + + /** + * Sets the filter string set for this query. + * The filter is included in the query tree at the time the query tree is parsed. + * Setting this does <i>not</i> cause the query to be reparsed. + */ + public void setFilter(String filter) { this.filter = filter; } + + /** + * Returns the default index for this query. + * The default index is taken into account at the time the query tree is parsed. + */ + public String getDefaultIndex() { return defaultIndex; } + + /** + * Sets the default index for this query. + * The default index is taken into account at the time the query tree is parsed. + * Setting this does <i>not</i> cause the query to be reparsed. + */ + public void setDefaultIndex(String defaultIndex) { this.defaultIndex = defaultIndex; } + + /** + * Sets the query type of for this query. + * The type is taken into account at the time the query tree is parsed. + */ + public Query.Type getType() { return type; } + + /** + * Sets the query type of for this query. + * The type is taken into account at the time the query tree is parsed. + * Setting this does <i>not</i> cause the query to be reparsed. + */ + public void setType(Query.Type type) { this.type = type; } + + /** + * Sets the query type of for this query. + * The type is taken into account at the time the query tree is parsed. + * Setting this does <i>not</i> cause the query to be reparsed. + */ + public void setType(String typeString) { this.type = Query.Type.getType(typeString); } + + public boolean equals(Object o) { + if ( ! (o instanceof Model)) return false; + + Model other = (Model) o; + if ( ! ( + QueryHelper.equals(other.encoding, this.encoding) && + QueryHelper.equals(other.language, this.language) && + QueryHelper.equals(other.searchPath, this.searchPath) && + QueryHelper.equals(other.sources, this.sources) && + QueryHelper.equals(other.restrict, this.restrict) && + QueryHelper.equals(other.defaultIndex, this.defaultIndex) && + QueryHelper.equals(other.type, this.type) )) + return false; + + if (other.queryTree == null && this.queryTree == null) // don't cause query parsing + return QueryHelper.equals(other.queryString, this.queryString) && + QueryHelper.equals(other.filter, this.filter); + else // make sure we compare a parsed variant of both + return QueryHelper.equals(other.getQueryTree(), this.getQueryTree()); + } + + @Override + public int hashCode() { + return getClass().hashCode() + + QueryHelper.combineHash(encoding,filter,language,getQueryTree(),sources,restrict,defaultIndex,type,searchPath); + } + + + public Object clone() { + try { + Model clone = (Model) super.clone(); + if (queryTree != null) + clone.queryTree = this.queryTree.clone(); + if (sources !=null) + clone.sources = new LinkedHashSet<>(this.sources); + if (restrict !=null) + clone.restrict = new LinkedHashSet<>(this.restrict); + return clone; + } + catch (CloneNotSupportedException e) { + throw new RuntimeException("Someone inserted a noncloneable superclass",e); + } + } + + public Model cloneFor(Query q) { + Model model = (Model) this.clone(); + model.setParent(q); + return model; + } + + /** returns the query owning this, never null */ + public Query getParent() { return parent; } + + /** Assigns the query owning this */ + public void setParent(Query parent) { + if (parent==null) throw new NullPointerException("A query models owner cannot be null"); + this.parent = parent; + } + + /** Sets the set of sources this query will search from a comma-separated string of source names */ + public void setSources(String sourceString) { + setFromString(sourceString,sources); + } + + /** + * Returns the set of sources this query will search. + * This set can be modified to change the set of sources. If all sources are to be searched, this returns + * an empty set + * + * @return the set of sources to search, never null + */ + public Set<String> getSources() { return sources; } + + /** + * Sets the set of types (document type or search definition names) this query will search from a + * comma-separated string of type names. This is useful to narrow a search to just a subset of the types available + * from a sources + */ + public void setRestrict(String restrictString) { + setFromString(restrictString,restrict); + } + + /** + * Returns the set of types this query will search. + * This set can be modified to change the set of types. If all types are to be searched, this returns + * an empty set. + * + * @return the set of types to search, never null + */ + public Set<String> getRestrict() { return restrict; } + + /** Sets the execution working on this. For internal use. */ + public void setExecution(Execution execution) { + if (execution==this.execution) return; + + // If not already coupled, bind the trace of the new execution into the existing execution trace + if (execution.trace().traceNode().isRoot() + && execution.trace().traceNode() != this.execution.trace().traceNode().root()) { + this.execution.trace().traceNode().add(execution.trace().traceNode()); + } + + this.execution = execution; + } + + /** Sets the document database this will search - a document type */ + public void setDocumentDb(String documentDbName) { + this.documentDbName = documentDbName; + } + + /** Returns the name of the document db this should search, or null if not set. */ + public String getDocumentDb() { return documentDbName; } + + /** Returns the Execution working on this, or a null execution if none. For internal use. */ + public Execution getExecution() { return execution; } + + private void setFromString(String string,Set<String> set) { + set.clear(); + for (String item : string.split(",")) + set.add(item.trim()); + } + + public static Model getFrom(Query q) { + return (Model)q.properties().get(argumentTypeName); + } + + public @Override String toString() { + return "query representation [queryTree: " + queryTree + ", filter: " + filter + "]"; + } + + /** Prepares this for binary serialization. For internal use. */ + public void prepare(Ranking ranking) { + prepareRankFeaturesFromModel(ranking); + } + + private void prepareRankFeaturesFromModel(Ranking ranking) { + Item root = getQueryTree().getRoot(); + if (root != null) { + List<Item> tagged = setUniqueIDs(root); + addLabels(tagged, ranking); + addConnectivityRankProperties(tagged, ranking); + addSignificances(tagged, ranking); + } + } + + private List<Item> setUniqueIDs(Item root) { + List<Item> items = new ArrayList<>(); + collectTaggableItems(root, items); + int id = 1; + for (Item i : items) { + TaggableItem t = (TaggableItem) i; + t.setUniqueID(id++); + } + return items; + } + + private void addLabels(List<Item> candidates, Ranking ranking) { + for (Item candidate : candidates) { + String label = candidate.getLabel(); + if (label != null) { + String name = "vespa.label." + label + ".id"; + TaggableItem t = (TaggableItem) candidate; + ranking.getProperties().put(name, String.valueOf(t.getUniqueID())); + } + } + } + + private void addConnectivityRankProperties(List<Item> connectedItems, Ranking ranking) { + for (Item link : connectedItems) { + TaggableItem t = (TaggableItem) link; + Item connectedTo = t.getConnectedItem(); + if (connectedTo != null && strictContains(connectedTo, connectedItems)) { + TaggableItem t2 = (TaggableItem) connectedTo; + String name = "vespa.term." + t.getUniqueID() + ".connexity"; + ranking.getProperties().put(name, String.valueOf(t2.getUniqueID())); + ranking.getProperties().put(name, String.valueOf(t.getConnectivity())); + } + } + } + + private void addSignificances(List<Item> candidates, Ranking ranking) { + for (Item candidate : candidates) { + TaggableItem t = (TaggableItem) candidate; + if ( ! t.hasExplicitSignificance()) continue; + String name = "vespa.term." + t.getUniqueID() + ".significance"; + ranking.getProperties().put(name, String.valueOf(t.getSignificance())); + } + } + + private void collectTaggableItems(Item root, List<Item> terms) { + if (root == null) return; + + if (root instanceof TaggableItem) { + // This is tested before descending, as phrases are viewed + // as leaf nodes in the ranking code in the backend + terms.add(root); + } else if (root instanceof CompositeItem) { + CompositeItem c = (CompositeItem) root; + for (Iterator<Item> i = c.getItemIterator(); i.hasNext();) { + collectTaggableItems(i.next(), terms); + } + } else {} // nop + } + + private boolean strictContains(Object needle, Collection<?> haystack) { + for (Object pin : haystack) + if (pin == needle) return true; + return false; + } + + + /** + * Set the YTrace header value to use when transmitting this model to a + * search backend (of some kind). + * + * @param next string representation of header value + * @deprecated Not use, ytrace is done + */ + @Deprecated + public void setYTraceHeaderToNext(String next) { } + + /** + * Get the YTrace header value to use when transmitting this model to a + * search backend (of some kind). Returns null if no ytrace data is not + * turned on. + * @deprecated Not use, ytrace is done + */ + @Deprecated + public String getYTraceHeaderToNext() { + return null; + } + +} diff --git a/container-search/src/main/java/com/yahoo/search/query/ParameterParser.java b/container-search/src/main/java/com/yahoo/search/query/ParameterParser.java new file mode 100644 index 00000000000..a27e1bfde55 --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/ParameterParser.java @@ -0,0 +1,88 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.query; + +import static com.yahoo.container.util.Util.quote; + +/** + * Wrapper class to avoid code duplication of common parsing requirements. + * + * @author <a href="steinar@yahoo-inc.com">Steinar Knutsen</a> + */ +public class ParameterParser { + + /** + * Tries to return the given object as a Long. If it is a Number, treat it + * as a number of seconds, i.e. get a Long representation and multiply by + * 1000. If it has a String representation, try to parse this as a floating + * point number, followed by by an optional unit (seconds and an SI prefix, + * a couple of valid examples are "s" and "ms". Only a very small subset of + * SI prefixes are supported). If no unit is given, seconds are assumed. + * + * @param value + * some representation of a number of seconds + * @param defaultValue + * returned if value is null + * @return value as a number of milliseconds + * @throws NumberFormatException + * if value is not a Number instance and its String + * representation cannot be parsed as a number followed + * optionally by time unit + */ + public static Long asMilliSeconds(Object value, Long defaultValue) { + if (value == null) { + return defaultValue; + } + if (value instanceof Number) { + Number n = (Number) value; + return Long.valueOf(n.longValue() * 1000L); + } + return parseTime(value.toString()); + } + + private static Long parseTime(String time) throws NumberFormatException { + + time = time.trim(); + try { + int unitOffset = findUnitOffset(time); + double measure = Double.valueOf(time.substring(0, unitOffset)); + double multiplier = parseUnit(time.substring(unitOffset)); + return Long.valueOf((long) (measure * multiplier)); + } catch (RuntimeException e) { + throw new IllegalArgumentException("Error parsing " + quote(time), e); + } + } + + private static int findUnitOffset(String time) { + int unitOffset = 0; + while (unitOffset < time.length()) { + char c = time.charAt(unitOffset); + if (c == '.' || (c >= '0' && c <= '9')) { + unitOffset += 1; + } else { + break; + } + } + if (unitOffset == 0) { + throw new NumberFormatException("Invalid number " + quote(time)); + } + return unitOffset; + } + + private static double parseUnit(String unit) { + unit = unit.trim(); + final double multiplier; + if ("ks".equals(unit)) { + multiplier = 1e6d; + } else if ("s".equals(unit)) { + multiplier = 1000.0d; + } else if ("ms".equals(unit)) { + multiplier = 1.0d; + } else if ("\u00B5s".equals(unit)) { + // microseconds + multiplier = 1e-3d; + } else { + multiplier = 1000.0d; + } + return multiplier; + } +} diff --git a/container-search/src/main/java/com/yahoo/search/query/Presentation.java b/container-search/src/main/java/com/yahoo/search/query/Presentation.java new file mode 100644 index 00000000000..466ddf88299 --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/Presentation.java @@ -0,0 +1,211 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.query; + +import com.google.common.base.Splitter; +import com.yahoo.collections.LazySet; +import com.yahoo.component.ComponentSpecification; +import com.yahoo.processing.request.CompoundName; +import com.yahoo.prelude.query.*; +import com.yahoo.search.Query; +import com.yahoo.search.query.profile.types.FieldDescription; +import com.yahoo.search.query.profile.types.QueryProfileType; +import com.yahoo.search.rendering.RendererRegistry; +import edu.umd.cs.findbugs.annotations.NonNull; +import edu.umd.cs.findbugs.annotations.Nullable; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + + +/** + * Parameters deciding how the result of a query should be presented + * + * @author <a href="mailto:arnebef@yahoo-inc.com">Arne Bergene Fossaa</a> + */ +public class Presentation implements Cloneable { + + /** The type representing the property arguments consumed by this */ + private static QueryProfileType argumentType; + + public static final String PRESENTATION = "presentation"; + public static final String BOLDING = "bolding"; + public static final String TIMING = "timing"; + public static final String SUMMARY = "summary"; + public static final String REPORT_COVERAGE = "reportCoverage"; + public static final String SUMMARY_FIELDS = "summaryFields"; + + /** The (short) name of the parameter holding the name of the return format to use */ + public static final String FORMAT = "format"; + + static { + argumentType=new QueryProfileType(PRESENTATION); + argumentType.setStrict(true); + argumentType.setBuiltin(true); + argumentType.addField(new FieldDescription(BOLDING, "boolean", "bolding")); + argumentType.addField(new FieldDescription(TIMING, "boolean", "timing")); + argumentType.addField(new FieldDescription(SUMMARY, "string", "summary")); + argumentType.addField(new FieldDescription(REPORT_COVERAGE, "string", "reportcoverage")); + argumentType.addField(new FieldDescription(FORMAT, "string", "format template")); + argumentType.addField(new FieldDescription(SUMMARY_FIELDS, "string", "summaryFields")); + argumentType.freeze(); + } + public static QueryProfileType getArgumentType() { return argumentType; } + + /** How the result should be highlighted */ + private Highlight highlight= null; + + /** The terms to highlight in the result (only used by BoldingSearcher, may be removed later). */ + private List<IndexedItem> boldingData = null; + + /** Whether or not to do highlighting */ + private boolean bolding = true; + + /** The summary class to be shown */ + private String summary = null; + + /** Whether coverage information (how much of the indices was searched should be included in the result */ + private boolean reportCoverage=false; + + /** The name of the renderer to use for rendering the hits. */ + private ComponentSpecification format = RendererRegistry.defaultRendererId.toSpecification(); + + /** Whether optional timing data should be rendered */ + private boolean timing = false; + + /** Set of explicitly requested summary fields, instead of summary classes */ + @NonNull + private Set<String> summaryFields = LazySet.newHashSet(); + + private static final Splitter COMMA_SPLITTER = Splitter.on(',').omitEmptyStrings().trimResults(); + + public Presentation(Query parent) { } + + /** Returns how terms in this result should be highlighted, or null if not set */ + public Highlight getHighlight() { return highlight; } + + /** Sets how terms in this result should be highlighted. Set to null to turn highlighting off */ + public void setHighlight(Highlight highlight) { this.highlight = highlight; } + + /** Returns the name of the summary class to be used to present hits from this query, or null if not set */ + public String getSummary() { return summary; } + + /** Sets the name of the summary class to be used to present hits from this query */ + public void setSummary(String summary) { this.summary = summary; } + + /** Returns whether matching query terms should be bolded in the result. Default is true. */ + public boolean getBolding() { return bolding; } + + /** Sets whether matching query terms should be bolded in the result */ + public void setBolding(boolean bolding) { this.bolding = bolding; } + + /** Returns whether coverage information should be returned in the result, if available. Default is false */ + public boolean getReportCoverage() { return reportCoverage; } + + /** Sets whether coverage information should be returned in the result, if available */ + public void setReportCoverage(boolean reportCoverage) { this.reportCoverage=reportCoverage; } + + /** Get the name of the format desired for result rendering. */ + @NonNull + public ComponentSpecification getRenderer() { return format; } + + /** Set the desired format for result rendering. If null, use the default renderer. */ + public void setRenderer(@Nullable ComponentSpecification format) { + this.format = (format != null) ? format : RendererRegistry.defaultRendererId.toSpecification(); + } + + /** + * Get the name of the format desired for result rendering. + */ + @NonNull + public String getFormat() { return format.getName(); } + + /** + * Set the desired format for result rendering. If null, use the default renderer. + */ + public void setFormat(@Nullable String format) { + setRenderer(ComponentSpecification.fromString(format)); + } + + @Override + public Object clone() { + try { + Presentation clone = (Presentation)super.clone(); + if (boldingData != null) + clone.boldingData = new ArrayList<>(boldingData); + + if (highlight != null) + clone.highlight = highlight.clone(); + + if (summaryFields != null) { + clone.summaryFields = LazySet.newHashSet(); + clone.summaryFields.addAll(this.summaryFields); + } + + return clone; + } + catch (CloneNotSupportedException e) { + throw new RuntimeException("Someone inserted a noncloneable superclass",e); + } + } + + @Override + public boolean equals(Object o) { + if (o == null || !(o instanceof Presentation)) return false; + Presentation p = (Presentation) o; + return QueryHelper.equals(bolding,p.bolding) && QueryHelper.equals(summary,p.summary); + } + + @Override + public int hashCode() { + return QueryHelper.combineHash(bolding, summary); + } + + /** + * @return whether to add optional timing data to the rendered result + */ + public boolean getTiming() { + return timing; + } + + public void setTiming(boolean timing) { + this.timing = timing; + } + + /** + * Return the set of explicitly requested fields. Returns an empty set if no + * fields are specified outside of summary classes. The returned set is + * mutable and fields may be added or removed before passing on the query. + * + * @return the set of names of requested fields, never null + */ + @NonNull + public Set<String> getSummaryFields() { + return summaryFields; + } + + /** Prepares this for binary serialization. For internal use - see {@link Query#prepare} */ + public void prepare() { + if (highlight != null) + highlight.prepare(); + } + + /** + * Parse the given string as a comma delimited set of field names and + * overwrite the set of summary fields. Whitespace will be trimmed. If you + * want to add or remove fields programmatically, use + * {@link #getSummaryFields()} and modify the returned set. + * + * @param asString + * the summary fields requested, e.g. "price,author,title" + */ + public void setSummaryFields(String asString) { + summaryFields.clear(); + for (String field : COMMA_SPLITTER.split(asString)) { + summaryFields.add(field); + } + + } + +} + diff --git a/container-search/src/main/java/com/yahoo/search/query/Properties.java b/container-search/src/main/java/com/yahoo/search/query/Properties.java new file mode 100644 index 00000000000..df3d120c337 --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/Properties.java @@ -0,0 +1,51 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.query; + +import com.yahoo.search.Query; + +/** + * Object properties keyed by name which can be looked up using default values and + * with conversion to various primitive wrapper types. + * <p> + * Multiple property implementations can be chained to provide unified access to properties + * backed by multiple sources as a Chain of Responsibility. + * <p> + * For better performance, prefer CompoundName argument constants over Strings. + * <p> + * Properties can be cloned. Cloning a properties instance returns a new instance + * which chains new instances of all chained instances. The content within each instance + * is cloned to the extent determined appropriate by that implementation. + * <p> + * This base class simply passes all access on to the next in chain. + * + * @author bratseth + */ +public abstract class Properties extends com.yahoo.processing.request.Properties { + + @Override + public Properties chained() { return (Properties)super.chained(); } + + @Override + public Properties clone() { + return (Properties)super.clone(); + } + + /** The query owning this property object. + * Only guaranteed to work if this instance is accessible as query.properties() + */ + public Query getParentQuery() { + if (chained() == null) { + throw new RuntimeException("getParentQuery should only be called on a properties instance accessible as query.properties()"); + } else { + return chained().getParentQuery(); + } + } + + /** + * Invoked during deep cloning of the parent query. + */ + public void setParentQuery(Query query) { + if (chained() != null) + chained().setParentQuery(query); + } +} diff --git a/container-search/src/main/java/com/yahoo/search/query/QueryHelper.java b/container-search/src/main/java/com/yahoo/search/query/QueryHelper.java new file mode 100644 index 00000000000..d4b6f257c11 --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/QueryHelper.java @@ -0,0 +1,27 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.query; + +/** + * @author <a href="mailto:arnebef@yahoo-inc.com">Arne Bergene Fossaa</a> + */ +class QueryHelper { + + /** Compares two objects which may be null */ + public static boolean equals(Object a,Object b) { + if (a == null) return b == null; + return a.equals(b); + } + + /** + * Helper method that finds the hashcode for a group of objects. + * Inspired by java.util.List + */ + public static int combineHash(Object... objs) { + int hash = 1; + for (Object o:objs) { + hash = 31*hash + (o == null ? 0 : o.hashCode()); + } + return hash; + } + +} diff --git a/container-search/src/main/java/com/yahoo/search/query/QueryTree.java b/container-search/src/main/java/com/yahoo/search/query/QueryTree.java new file mode 100644 index 00000000000..3a501853388 --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/QueryTree.java @@ -0,0 +1,159 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.query; + +import com.yahoo.prelude.query.*; + +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; + +/** + * The root node of a query tree. This is always present above the actual semantic root to ease query manipulation, + * especially replacing the actual semantic root, but does not have any search semantics on its own. + * + * <p>To ease recursive manipulation of the query tree, this is a composite having one child, which is the actual root. + * <ul> + * <li>Setting the root item (at position 0, either directly or though the iterator of this, works as expected. + * Setting at any other position is disallowed. + * <li>Removing the root is allowed and causes this to be a null query. + * <li>Adding an item is only allowed if this is currently a null query (having no root) + * </ul> + * + * <p>This is also the home of accessor methods which eases querying into and manipulation of the query tree.</p> + * + * @author <a href="mailto:arnebef@yahoo-inc.com">Arne Bergene Fossaa</a> + */ +public class QueryTree extends CompositeItem { + + public QueryTree(Item root) { + setRoot(root); + } + + public void setIndexName(String index) { + if (getRoot() != null) + getRoot().setIndexName(index); + } + + public ItemType getItemType() { + throw new RuntimeException("Packet type access attempted. " + + "A query tree has no packet code. This is probably a misbehaving searcher."); + } + + public String getName() { return "ROOT"; } + + public int encode(ByteBuffer buffer) { + if (getRoot() == null) return 0; + return getRoot().encode(buffer); + } + + //Lets not pollute toString() by adding "ROOT" + protected void appendHeadingString(StringBuilder sb) { + } + + /** Returns the query root. This is null if this is a null query. */ + public Item getRoot() { + if (getItemCount()==0) return null; + return getItem(0); + } + + public final void setRoot(Item root) { + if (root==this) throw new IllegalArgumentException("Cannot make a root point at itself"); + if (root == null) throw new IllegalArgumentException("Root must not be null, use NullItem instead."); + if (root instanceof QueryTree) throw new IllegalArgumentException("Do not use a new QueryTree instance as a root."); + if (this.getItemCount()==0) // initializing + super.addItem(root); + else + setItem(0,root); // replacing + } + + @Override + public boolean equals(Object o) { + if( !(o instanceof QueryTree)) return false; + return super.equals(o); + } + + /** Returns a deep copy of this */ + @Override + public QueryTree clone() { + QueryTree clone = (QueryTree) super.clone(); + fixClonedConnectivityReferences(clone); + return clone; + } + + private void fixClonedConnectivityReferences(QueryTree clone) { + // TODO! + } + + @Override + public void addItem(Item item) { + if (getItemCount()==0) + super.addItem(item); + else + throw new RuntimeException("Programming error: Cannot add multiple roots"); + } + + @Override + public void addItem(int index, Item item) { + if (getItemCount()==0 && index==0) + super.addItem(index,item); + else + throw new RuntimeException("Programming error: Cannot add multiple roots, have '" + getRoot() + "'"); + } + + /** Returns true if this represents the null query */ + public boolean isEmpty() { + return getRoot() instanceof NullItem; + } + + // -------------- Facade + + /** Modifies this query to become the current query AND the given item */ + // TODO: Make sure this is complete, unit test and make it public + private void and(Item item) { + if (isEmpty()) { + setRoot(item); + } + else if (getRoot() instanceof NotItem && item instanceof NotItem) { + throw new IllegalArgumentException("Can't AND two NOTs"); // TODO: Complete + } + else if (getRoot() instanceof NotItem){ + NotItem notItem = (NotItem)getRoot(); + notItem.addPositiveItem(item); + } + else if (item instanceof NotItem){ + NotItem notItem = (NotItem)item; + notItem.addPositiveItem(getRoot()); + setRoot(notItem); + } + else { + AndItem andItem = new AndItem(); + andItem.addItem(getRoot()); + andItem.addItem(item); + setRoot(andItem); + } + } + + /** Returns a flattened list of all positive query terms under the given item */ + public static List<IndexedItem> getPositiveTerms(Item item) { + List<IndexedItem> items = new ArrayList<>(); + getPositiveTerms(item,items); + return items; + } + + private static void getPositiveTerms(Item item, List<IndexedItem> terms) { + if (item instanceof NotItem) { + getPositiveTerms(((NotItem) item).getPositiveItem(), terms); + } else if (item instanceof PhraseItem) { + PhraseItem pItem = (PhraseItem)item; + terms.add(pItem); + } else if (item instanceof CompositeItem) { + for (Iterator<Item> i = ((CompositeItem) item).getItemIterator(); i.hasNext();) { + getPositiveTerms(i.next(), terms); + } + } else if (item instanceof TermItem) { + terms.add((TermItem)item); + } + } + +} diff --git a/container-search/src/main/java/com/yahoo/search/query/Ranking.java b/container-search/src/main/java/com/yahoo/search/query/Ranking.java new file mode 100644 index 00000000000..e543589f74d --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/Ranking.java @@ -0,0 +1,246 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.query; + +import com.yahoo.processing.request.CompoundName; +import com.yahoo.prelude.Freshness; +import com.yahoo.prelude.Location; +import com.yahoo.search.Query; +import com.yahoo.search.query.profile.types.FieldDescription; +import com.yahoo.search.query.profile.types.QueryProfileType; +import com.yahoo.search.query.ranking.MatchPhase; +import com.yahoo.search.query.ranking.RankFeatures; +import com.yahoo.search.query.ranking.RankProperties; +import com.yahoo.search.result.ErrorMessage; + +/** + * The ranking (hit ordering) settings of a query + * + * @author <a href="mailto:arnebef@yahoo-inc.com">Arne Bergene Fossaa</a> + * @author bratseth + */ +public class Ranking implements Cloneable { + + /** An alias for listing features */ + public static final com.yahoo.processing.request.CompoundName RANKFEATURES = + new com.yahoo.processing.request.CompoundName("rankfeatures"); + + /** The type representing the property arguments consumed by this */ + private static final QueryProfileType argumentType; + private static final CompoundName argumentTypeName; + + public static final String RANKING = "ranking"; + public static final String LOCATION = "location"; + public static final String PROFILE = "profile"; + public static final String SORTING = "sorting"; + public static final String LIST_FEATURES = "listFeatures"; + public static final String FRESHNESS = "freshness"; + public static final String QUERYCACHE = "queryCache"; + public static final String MATCH_PHASE = "matchPhase"; + public static final String DIVERSITY = "diversity"; + public static final String FEATURES = "features"; + public static final String PROPERTIES = "properties"; + + static { + argumentType =new QueryProfileType(RANKING); + argumentType.setStrict(true); + argumentType.setBuiltin(true); + argumentType.addField(new FieldDescription(LOCATION, "string", "location")); + argumentType.addField(new FieldDescription(PROFILE, "string", "ranking")); + argumentType.addField(new FieldDescription(SORTING, "string", "sorting sortspec")); + argumentType.addField(new FieldDescription(LIST_FEATURES, "string", RANKFEATURES.toString())); + argumentType.addField(new FieldDescription(FRESHNESS, "string", "datetime")); + argumentType.addField(new FieldDescription(QUERYCACHE, "string")); + argumentType.addField(new FieldDescription(MATCH_PHASE, "query-profile", "matchPhase")); + argumentType.addField(new FieldDescription(FEATURES, "query-profile", "rankfeature")); + argumentType.addField(new FieldDescription(PROPERTIES, "query-profile", "rankproperty")); + argumentType.freeze(); + argumentTypeName=new CompoundName(argumentType.getId().getName()); + } + public static QueryProfileType getArgumentType() { return argumentType; } + + private Query parent; + + /** The location of the query is used for distance ranking */ + private Location location = null; + + /** The name of the rank profile to use */ + private String profile = null; + + /** How the query should be sorted */ + private Sorting sorting = null; + + /** Set to true to include the value of "all" rank features in the result */ + private boolean listFeatures = false; + + private Freshness freshness; + + private boolean queryCache = false; + + private RankProperties rankProperties = new RankProperties(); + + private RankFeatures rankFeatures = new RankFeatures(); + + private MatchPhase matchPhase = new MatchPhase(); + + public Ranking(Query parent) { + this.parent = parent; + } + + /** + * Returns whether a rank profile has been explicitly set. + * + * This is only used in serializing the packet properly to FS4. + */ + public boolean hasRankProfile() { + return profile != null; + } + + /** Get the freshness search parameters associated with this query */ + public Freshness getFreshness() { + return freshness; + } + + /** Set the freshness search parameters for this query */ + public void setFreshness(String dateTime) { + try { + Freshness freshness = new Freshness(dateTime); + setFreshness(freshness); + } catch (NumberFormatException e) { + parent.errors().add(ErrorMessage.createInvalidQueryParameter("Datetime reference could not be converted from '" + + dateTime + "' to long")); + } + } + + public void setFreshness(Freshness freshness) { + this.freshness = freshness; + } + + /** + * Returns whether feature caching is turned on in the backed. + * Feature caching allows us to avoid sending the query during document summary retrieval + * and recalculate feature scores, it is typically beneficial to turn it on if + * fan-out is low or queries are large. + * <p> + * Default is false (off). + */ + public void setQueryCache(boolean queryCache) { this.queryCache = queryCache; } + + public boolean getQueryCache() { return queryCache; } + + /** Returns the location of this query, or null if none */ + public Location getLocation() { return location; } + + public void setLocation(Location location) { this.location = location; } + + /** Sets the location from a string, see {@link Location} for syntax */ + public void setLocation(String str) { this.location = new Location(str); } + + /** Returns the name of the rank profile to be used. Returns "default" if nothing is set. */ + public String getProfile() { return profile == null ? "default" : profile; } + + /** Sets the name of the rank profile to use. This cannot be set to null. */ + public void setProfile(String profile) { + if (profile==null) throw new NullPointerException("The ranking profile cannot be set to null"); + this.profile = profile; + } + + /** + * Returns the rank features of this, an empty container (never null) if none are set. + * The returned object can be modified directly to change the rank properties of this. + */ + public RankFeatures getFeatures() { + return rankFeatures; + } + + /** + * Returns the rank properties of this, an empty container (never null) if none are set. + * The returned object can be modified directly to change the rank properties of this. + */ + public RankProperties getProperties() { + return rankProperties; + } + + /** Set whether rank features should be included with the result of this query */ + public void setListFeatures(boolean listFeatures) { this.listFeatures = listFeatures; } + + /** Returns whether rank features should be dumped with the result of this query, default false */ + public boolean getListFeatures() { return listFeatures; } + + /** Returns the match phase rank settings of this. This is never null. */ + public MatchPhase getMatchPhase() { return matchPhase; } + + @Override + public Object clone() { + try { + Ranking clone = (Ranking) super.clone(); + + if (sorting != null) clone.sorting = this.sorting.clone(); + + clone.rankProperties = this.rankProperties.clone(); + clone.rankFeatures = this.rankFeatures.clone(); + clone.matchPhase = this.matchPhase.clone(); + return clone; + } + catch (CloneNotSupportedException e) { + throw new RuntimeException("Someone inserted a noncloneable superclass",e); + } + } + + @Override + public boolean equals(Object o) { + if (o == this) return true; + if( ! (o instanceof Ranking)) return false; + + Ranking other = (Ranking) o; + + if ( ! QueryHelper.equals(rankProperties, other.rankProperties)) return false; + if ( ! QueryHelper.equals(rankFeatures, other.rankFeatures)) return false; + if ( ! QueryHelper.equals(freshness, other.freshness)) return false; + if ( ! QueryHelper.equals(this.sorting, other.sorting)) return false; + if ( ! QueryHelper.equals(this.location, other.location)) return false; + if ( ! QueryHelper.equals(this.profile, other.profile)) return false; + return true; + } + + @Override + public int hashCode() { + int hash = 0; + hash += 11 * rankFeatures.hashCode(); + hash += 13 * rankProperties.hashCode(); + hash += 17 * matchPhase.hashCode(); + return Ranking.class.hashCode() + QueryHelper.combineHash(sorting,location,profile,hash); + } + + /** Returns the sorting spec of this query, or null if none is set */ + public Sorting getSorting() { return sorting; } + + /** Sets how this query should be sorted. Set to null to turn off explicit sorting. */ + public void setSorting(Sorting sorting) { this.sorting = sorting; } + + /** Sets sorting from a string. See {@link Sorting} on syntax */ + public void setSorting(String sortingString) { + if (sortingString==null) + setSorting((Sorting)null); + else + setSorting(new Sorting(sortingString)); + } + + public static Ranking getFrom(Query q) { + return (Ranking) q.properties().get(argumentTypeName); + } + + public void prepare() { + rankFeatures.prepare(rankProperties); + matchPhase.prepare(rankProperties); + prepareNow(freshness); + } + + private void prepareNow(Freshness freshness) { + if (freshness == null) return; + // TODO: See what freshness is doing with the internal props and simplify + if (rankProperties.get("vespa.now") == null || rankProperties.get("vespa.now").isEmpty()) { + rankProperties.put("vespa.now", "" + freshness.getRefTime()); + } + } + +} diff --git a/container-search/src/main/java/com/yahoo/search/query/SessionId.java b/container-search/src/main/java/com/yahoo/search/query/SessionId.java new file mode 100644 index 00000000000..7f8ca6385e1 --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/SessionId.java @@ -0,0 +1,36 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.query; + +import com.yahoo.container.Server; +import com.yahoo.text.Utf8String; + +import java.util.concurrent.atomic.AtomicLong; + +/** + * A query id which is unique across this cluster - consisting of + * container runtime id + timestamp + serial. + * + * @author <a href="mailto:bratseth@yahoo-inc.com">Jon Bratseth</a> + */ +public class SessionId { + + private static final String serverId = Server.get().getServerDiscriminator(); + private static final AtomicLong sequenceCounter = new AtomicLong(); + + private final Utf8String id; + + private SessionId(String serverId, long timestamp, long sequence) { + this.id = new Utf8String(serverId + "." + timestamp + "." + sequence); + } + + public Utf8String asUtf8String() { return id; } + + /** + * Creates a session id which is unique across the cluster this runtime is a member of each time this is called. + * Calling this causes synchronization. + */ + public static SessionId next() { + return new SessionId(serverId, System.currentTimeMillis(), sequenceCounter.getAndIncrement()); + } + +} diff --git a/container-search/src/main/java/com/yahoo/search/query/Sorting.java b/container-search/src/main/java/com/yahoo/search/query/Sorting.java new file mode 100644 index 00000000000..3af9bc34940 --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/Sorting.java @@ -0,0 +1,407 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.query; + +import com.ibm.icu.text.Collator; +import com.ibm.icu.util.ULocale; +import com.yahoo.text.Utf8; + +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Pattern; + + +/** + * Specifies how a query is sorted by a list of fields with a sort order + * + * @author Arne Bergene Fossaa + */ +public class Sorting implements Cloneable { + + public static final String STRENGTH_IDENTICAL = "identical"; + public static final String STRENGTH_QUATERNARY = "quaternary"; + public static final String STRENGTH_TERTIARY = "tertiary"; + public static final String STRENGTH_SECONDARY = "secondary"; + public static final String STRENGTH_PRIMARY = "primary"; + public static final String UCA = "uca"; + public static final String RAW = "raw"; + public static final String LOWERCASE = "lowercase"; + + private final List<FieldOrder> fieldOrders = new ArrayList<>(2); + + /** Creates an empty sort spec */ + public Sorting() { } + + public Sorting(List<FieldOrder> fieldOrders) { + this.fieldOrders.addAll(fieldOrders); + } + + /** Creates a sort spec from a string */ + public Sorting(String sortSpec) { + setSpec(sortSpec); + } + + /** + * Creates a new sorting from the given string and returns it, or returns null if the argument does not contain + * any sorting criteria (e.g it is null or the empty string) + */ + public static Sorting fromString(String sortSpec) { + if (sortSpec==null) return null; + if ("".equals(sortSpec)) return null; + return new Sorting(sortSpec); + } + + private void setSpec(String rawSortSpec) { + String[] vectors = rawSortSpec.split(" "); + + for (String sortString:vectors) { + // A sortspec element must be at least two characters long, + // a sorting order and an attribute vector name + if (sortString.length() < 1) { + continue; + } + char orderMarker = sortString.charAt(0); + int funcAttrStart = 0; + if ((orderMarker == '+') || (orderMarker == '-')) { + funcAttrStart = 1; + } + AttributeSorter sorter = null; + int startPar = sortString.indexOf('(',funcAttrStart); + int endPar = sortString.lastIndexOf(')'); + if ((startPar > 0) && (endPar > startPar)) { + String funcName = sortString.substring(funcAttrStart, startPar); + if (LOWERCASE.equalsIgnoreCase(funcName)) { + sorter = new LowerCaseSorter(sortString.substring(startPar+1, endPar)); + } else if (RAW.equalsIgnoreCase(funcName)) { + sorter = new RawSorter(sortString.substring(startPar+1, endPar)); + } else if (UCA.equalsIgnoreCase(funcName)) { + int commaPos = sortString.indexOf(',', startPar+1); + if ((startPar+1 < commaPos) && (commaPos < endPar)) { + int commaopt = sortString.indexOf(',', commaPos + 1); + UcaSorter.Strength strength = UcaSorter.Strength.UNDEFINED; + if (commaopt > 0) { + String s = sortString.substring(commaopt+1, endPar); + if (STRENGTH_PRIMARY.equalsIgnoreCase(s)) { + strength = UcaSorter.Strength.PRIMARY; + } else if (STRENGTH_SECONDARY.equalsIgnoreCase(s)) { + strength = UcaSorter.Strength.SECONDARY; + } else if (STRENGTH_TERTIARY.equalsIgnoreCase(s)) { + strength = UcaSorter.Strength.TERTIARY; + } else if (STRENGTH_QUATERNARY.equalsIgnoreCase(s)) { + strength = UcaSorter.Strength.QUATERNARY; + } else if (STRENGTH_IDENTICAL.equalsIgnoreCase(s)) { + strength = UcaSorter.Strength.IDENTICAL; + } else { + throw new IllegalArgumentException("Unknown collation strength: '" + s + "'"); + } + sorter = new UcaSorter(sortString.substring(startPar+1, commaPos), sortString.substring(commaPos+1, commaopt), strength); + } else { + sorter = new UcaSorter(sortString.substring(startPar+1, commaPos), sortString.substring(commaPos+1, endPar), strength); + } + } else { + sorter = new UcaSorter(sortString.substring(startPar+1, endPar)); + } + } else { + if (funcName.isEmpty()) { + throw new IllegalArgumentException("No sort function specified"); + } else { + throw new IllegalArgumentException("Unknown sort function '" + funcName + "'"); + } + } + } else { + sorter = new AttributeSorter(sortString.substring(funcAttrStart)); + } + Order order = Order.UNDEFINED; + if (funcAttrStart != 0) { + // Override in sortspec + order = (orderMarker == '+') ? Order.ASCENDING : Order.DESCENDING; + } + fieldOrders.add(new FieldOrder(sorter, order)); + } + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + + String space = ""; + for (FieldOrder spec : fieldOrders) { + sb.append(space); + if (spec.getSortOrder() == Order.DESCENDING) { + sb.append("-"); + } else { + sb.append("+"); + } + sb.append(spec.getFieldName()); + space = " "; + } + return sb.toString(); + } + + + public enum Order {ASCENDING,DESCENDING,UNDEFINED} + + /** + * Returns the field orders of this sort specification as list. This is never null but can be empty. + * This list can be modified to change this sort spec. + */ + public List<FieldOrder> fieldOrders() { return fieldOrders; } + + public Sorting clone() { + return new Sorting(this.fieldOrders); + } + + public static class AttributeSorter implements Cloneable { + private static final Pattern legalAttributeName = Pattern.compile("[\\[]*[a-zA-Z_][\\.a-zA-Z0-9_-]*[\\]]*"); + + private String fieldName; + public AttributeSorter(String fieldName) { + if (legalAttributeName.matcher(fieldName).matches()) { + this.fieldName = fieldName; + } else { + throw new IllegalArgumentException("Illegal attribute name '" + fieldName + "' for sorting. Requires '" + legalAttributeName.pattern() + "'"); + } + } + public String getName() { return fieldName; } + public void setName(String fieldName) { this.fieldName = fieldName; } + @Override + public String toString() { return fieldName; } + @Override + public int hashCode() { return fieldName.hashCode(); } + @Override + public boolean equals(Object other) { + if (!(other instanceof AttributeSorter)) { + return false; + } + return ((AttributeSorter) other).fieldName.equals(fieldName); + } + @Override + public AttributeSorter clone() { + try { + return (AttributeSorter)super.clone(); + } + catch (CloneNotSupportedException e) { + throw new RuntimeException(e); + } + + } + @SuppressWarnings({ "rawtypes", "unchecked" }) + public int compare(Comparable a, Comparable b) { + return a.compareTo(b); + } + + } + public static class RawSorter extends AttributeSorter + { + public RawSorter(String fieldName) { super(fieldName); } + @Override + public boolean equals(Object other) { + if (!(other instanceof RawSorter)) { + return false; + } + return super.equals(other); + } + } + public static class LowerCaseSorter extends AttributeSorter + { + public LowerCaseSorter(String fieldName) { super(fieldName); } + @Override + public String toString() { return "lowercase(" + getName() + ')'; } + @Override + public int hashCode() { return 1 + 3*super.hashCode(); } + @Override + public boolean equals(Object other) { + if (!(other instanceof LowerCaseSorter)) { + return false; + } + return super.equals(other); + } + @SuppressWarnings({ "rawtypes", "unchecked" }) + public int compare(Comparable a, Comparable b) { + if ((a instanceof String) && (b instanceof String)) { + return ((String)a).compareToIgnoreCase((String) b); + } + return a.compareTo(b); + } + } + public static class UcaSorter extends AttributeSorter + { + public enum Strength { PRIMARY, SECONDARY, TERTIARY, QUATERNARY, IDENTICAL, UNDEFINED }; + private String locale = null; + private Strength strength = Strength.UNDEFINED; + private Collator collator; + public UcaSorter(String fieldName, String locale, Strength strength) { super(fieldName); setLocale(locale, strength); } + public UcaSorter(String fieldName) { super(fieldName); } + static private int strength2Collator(Strength strength) { + switch (strength) { + case PRIMARY: return Collator.PRIMARY; + case SECONDARY: return Collator.SECONDARY; + case TERTIARY: return Collator.TERTIARY; + case QUATERNARY: return Collator.QUATERNARY; + case IDENTICAL: return Collator.IDENTICAL; + case UNDEFINED: return Collator.PRIMARY; + } + return Collator.PRIMARY; + } + public void setLocale(String locale, Strength strength) { + this.locale = locale; + this.strength = strength; + ULocale uloc; + try { + uloc = new ULocale(locale); + } catch (Throwable e) { + throw new RuntimeException("ULocale("+locale+") failed with exception " + e.toString()); + } + try { + collator = Collator.getInstance(uloc); + if (collator == null) { + throw new RuntimeException("No collator available for: " + locale); + } + } catch (Throwable e) { + throw new RuntimeException("Collator.getInstance(ULocale("+locale+")) failed with exception " + e.toString()); + } + collator.setStrength(strength2Collator(strength)); + // collator.setDecomposition(Collator.CANONICAL_DECOMPOSITION); + } + public String getLocale() { return locale; } + public Strength getStrength() { return strength; } + public Collator getCollator() { return collator; } + public String getDecomposition() { return (collator.getDecomposition() == Collator.CANONICAL_DECOMPOSITION) ? "CANONICAL_DECOMPOSITION" : "NO_DECOMPOSITION"; } + @Override + public String toString() { return "uca(" + getName() + ',' + locale + ',' + ((strength != Strength.UNDEFINED) ? strength.toString() : "PRIMARY") + ')'; } + @Override + public int hashCode() { return 1 + 3*locale.hashCode() + 5*strength.hashCode() + 7*super.hashCode(); } + @Override + public boolean equals(Object other) { + if (!(other instanceof UcaSorter)) { + return false; + } + return super.equals(other) && locale.equals(((UcaSorter)other).locale) && (strength == ((UcaSorter)other).strength); + } + public UcaSorter clone() { + UcaSorter clone = (UcaSorter)super.clone(); + if (locale != null) { + clone.setLocale(locale, strength); + } + return clone; + } + @SuppressWarnings({ "rawtypes", "unchecked" }) + public int compare(Comparable a, Comparable b) { + if ((a instanceof String) && (b instanceof String)) { + return collator.compare((String)a, (String) b); + } + return a.compareTo(b); + } + } + /** + * An attribute (field) and how it should be sorted + */ + public static class FieldOrder implements Cloneable { + + private AttributeSorter fieldSorter; + private Order sortOrder; + + /** + * Creates an attribute vector + * + * @param fieldSorter the sorter of this attribute + * @param sortOrder whether to sort this ascending or descending + */ + public FieldOrder(AttributeSorter fieldSorter, Order sortOrder) { + this.fieldSorter = fieldSorter; + this.sortOrder = sortOrder; + } + + /** + * Returns the name of this attribute + */ + public String getFieldName() { + return fieldSorter.getName(); + } + + /** + * Returns the sorter of this attribute + */ + public AttributeSorter getSorter() { return fieldSorter; } + public void setSorter(AttributeSorter sorter) { fieldSorter = sorter; } + + /** + * Returns the sorting order of this attribute + */ + public Order getSortOrder() { + return sortOrder; + } + + /** + * Decide if sortorder is ascending or not. + */ + public void setAscending(boolean asc) { + sortOrder = asc ? Order.ASCENDING : Order.DESCENDING; + } + + @Override + public String toString() { + return sortOrder.toString() + ":" + fieldSorter.toString(); + } + + @Override + public int hashCode() { + return sortOrder.hashCode() + 17 * fieldSorter.hashCode(); + } + @Override + public boolean equals(Object other) { + if (!(other instanceof FieldOrder)) { + return false; + } + FieldOrder otherAttr = (FieldOrder) other; + + return otherAttr.sortOrder.equals(sortOrder) + && otherAttr.fieldSorter.equals(fieldSorter); + } + @Override + public FieldOrder clone() { + return new FieldOrder(fieldSorter.clone(), sortOrder); + } + } + + @Override + public int hashCode() { + return fieldOrders.hashCode(); + } + + @Override + public boolean equals(Object o) { + if (o == this) return true; + if( ! (o instanceof Sorting)) return false; + + Sorting ss = (Sorting) o; + return fieldOrders.equals(ss.fieldOrders); + } + + public int encode(ByteBuffer buffer) { + int usedBytes = 0; + byte[] nameBuffer; + buffer.position(); + byte space = '.'; + for (FieldOrder fieldOrder : fieldOrders) { + if (space == ' ') { + buffer.put(space); + usedBytes++; + } + if (fieldOrder.getSortOrder() == Order.ASCENDING) { + buffer.put((byte) '+'); + } else { + buffer.put((byte) '-'); + } + usedBytes++; + nameBuffer = Utf8.toBytes(fieldOrder.getSorter().toString()); + buffer.put(nameBuffer); + usedBytes += nameBuffer.length; + // If this isn't the last element, append a separating space + //if (i + 1 < sortSpec.size()) { + space = ' '; + } + return usedBytes; + } + +} diff --git a/container-search/src/main/java/com/yahoo/search/query/context/QueryContext.java b/container-search/src/main/java/com/yahoo/search/query/context/QueryContext.java new file mode 100644 index 00000000000..e59f8589903 --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/context/QueryContext.java @@ -0,0 +1,112 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.query.context; + +import com.yahoo.processing.execution.Execution; +import com.yahoo.search.Query; +import com.yahoo.search.rendering.DefaultRenderer; +import com.yahoo.text.XMLWriter; +import com.yahoo.yolean.trace.TraceNode; + +import java.io.Writer; +import java.util.Iterator; + + +/** + * A proxy to the Execution.trace() which exists for legacy reasons. + * Calls to this is forwarded to owningQuery.getModel().getExecution().trace(). + * + * @since 4.2 + * @author <a href="mailto:steinar@yahoo-inc.com">Steinar Knutsen</a> + * @author <a href="mailto:bratseth@yahoo-inc.com">Jon Bratseth</a> + */ +public class QueryContext implements Cloneable { + + public static final String ID = "context"; + private Query owner; + + public QueryContext(int ignored,Query owner) { + this.owner=owner; + } + + //---------------- Public API --------------------------------------------------------------------------------- + + /** Adds a context message to this context */ + public void trace(String message, int traceLevel) { + owner.getModel().getExecution().trace().trace(message,traceLevel); + } + + /** + * Adds a key-value which will be logged to the access log for this query (by doing toString() on the value + * Multiple values may be set to the same key. A value cannot be removed once set. + */ + public void logValue(String key,Object value) { + owner.getModel().getExecution().trace().logValue(key, value.toString()); + } + + /** Returns the values to be written to the access log for this */ + public Iterator<Execution.Trace.LogValue> logValueIterator() { + return owner.getModel().getExecution().trace().logValueIterator(); + } + + /** + * Adds a property key-value to this context. + * If the same name is set multiple times, the behavior is thus: + * <ul> + * <li>Within a single context (thread/query clone), the last value set is used</li> + * <li>Across multiple traces, the <i>last</i> value from the <i>last</i> deepest nested thread/clone is used. + * In the case of multiple threads writing the value concurrently to their clone, it is of course undefined + * which one will be used.</li> + * </ul> + * + * @param name the name of the property + * @param value the value of the property, or null to set this property to null + */ + public void setProperty(String name,Object value) { + owner.getModel().getExecution().trace().setProperty(name,value); + } + + /** + * Returns a property set anywhere in this context. + * Note that even though this call is itself "thread robust", the object values returned + * may in some scenarios not be written behind a synchronization barrier, so when accessing + * objects which are not inherently thread safe, synchronization should be considered. + * <p> + * Note that this method have a time complexity which is proportional to + * the number of cloned/created queries times the average number of properties in each. + */ + public Object getProperty(String name) { + return owner.getModel().getExecution().trace().getProperty(name); + } + + /** Returns a short string description of this (includes the first few messages only, and no newlines) */ + @Override + public String toString() { + return owner.getModel().getExecution().trace().toString(); + } + + public boolean render(Writer writer) throws java.io.IOException { + if (owner.getTraceLevel()!=0) { + XMLWriter xmlWriter=XMLWriter.from(writer); + xmlWriter.openTag("meta").attribute("type",ID); + TraceNode traceRoot=owner.getModel().getExecution().trace().traceNode().root(); + traceRoot.accept(new DefaultRenderer.RenderingVisitor(xmlWriter,owner.getStartTime())); + xmlWriter.closeTag(); + } + return true; + } + + public QueryContext cloneFor(Query cloneOwner) { + try { + QueryContext clone=(QueryContext)super.clone(); + clone.owner=cloneOwner; + return clone; + } + catch (CloneNotSupportedException e) { + throw new RuntimeException(e); + } + } + + /** Returns the execution trace this delegates to */ + public Execution.Trace getTrace() { return owner.getModel().getExecution().trace(); } + +} diff --git a/container-search/src/main/java/com/yahoo/search/query/context/package-info.java b/container-search/src/main/java/com/yahoo/search/query/context/package-info.java new file mode 100644 index 00000000000..c19e5abedd0 --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/context/package-info.java @@ -0,0 +1,7 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +@ExportPackage +@PublicApi +package com.yahoo.search.query.context; + +import com.yahoo.api.annotations.PublicApi; +import com.yahoo.osgi.annotation.ExportPackage; diff --git a/container-search/src/main/java/com/yahoo/search/query/package-info.java b/container-search/src/main/java/com/yahoo/search/query/package-info.java new file mode 100644 index 00000000000..2384169c52b --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/package-info.java @@ -0,0 +1,10 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +/** + * The search query model + */ +@ExportPackage +@PublicApi +package com.yahoo.search.query; + +import com.yahoo.api.annotations.PublicApi; +import com.yahoo.osgi.annotation.ExportPackage; diff --git a/container-search/src/main/java/com/yahoo/search/query/parser/Parsable.java b/container-search/src/main/java/com/yahoo/search/query/parser/Parsable.java new file mode 100644 index 00000000000..92601a5464d --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/parser/Parsable.java @@ -0,0 +1,112 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.query.parser; + +import com.yahoo.language.Language; +import com.yahoo.search.query.Model; + +import java.util.Collection; +import java.util.HashSet; +import java.util.Set; + +/** + * <p>This class encapsulates all the parameters required to call {@link Parser#parse(Parsable)}. Because all set- + * methods return a reference to self, you can write very compact calls to the parser:</p> + * + * <pre> + * parser.parse(new Parsable() + * .setQuery("foo") + * .setFilter("bar") + * .setDefaultIndexName("default") + * .setLanguage(Language.ENGLISH)) + * </pre> + * + * <p>In case you are parsing the content of a {@link Model}, you can use the {@link #fromQueryModel(Model)} factory for + * convenience.</p> + * + * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a> + * @since 5.1.4 + */ +public final class Parsable { + + private final Set<String> sourceList = new HashSet<>(); + private final Set<String> restrictList = new HashSet<>(); + private String query; + private String filter; + private String defaultIndexName; + private Language language; + + public String getQuery() { + return query; + } + + public Parsable setQuery(String query) { + this.query = query; + return this; + } + + public String getFilter() { + return filter; + } + + public Parsable setFilter(String filter) { + this.filter = filter; + return this; + } + + public String getDefaultIndexName() { + return defaultIndexName; + } + + public Parsable setDefaultIndexName(String defaultIndexName) { + this.defaultIndexName = defaultIndexName; + return this; + } + + public Language getLanguage() { + return language; + } + + public Parsable setLanguage(Language language) { + this.language = language; + return this; + } + + public Set<String> getSources() { + return sourceList; + } + + public Parsable addSource(String sourceName) { + sourceList.add(sourceName); + return this; + } + + public Parsable addSources(Collection<String> sourceNames) { + sourceList.addAll(sourceNames); + return this; + } + + public Set<String> getRestrict() { + return restrictList; + } + + public Parsable addRestrict(String restrictName) { + restrictList.add(restrictName); + return this; + } + + public Parsable addRestricts(Collection<String> restrictNames) { + restrictList.addAll(restrictNames); + return this; + } + + public static Parsable fromQueryModel(Model model) { + return new Parsable() + .setQuery(model.getQueryString()) + .setFilter(model.getFilter()) + .setLanguage(model.getParsingLanguage()) + .setDefaultIndexName(model.getDefaultIndex()) + .addSources(model.getSources()) + .addRestricts(model.getRestrict()); + } + +} diff --git a/container-search/src/main/java/com/yahoo/search/query/parser/Parser.java b/container-search/src/main/java/com/yahoo/search/query/parser/Parser.java new file mode 100644 index 00000000000..3822b9b67d8 --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/parser/Parser.java @@ -0,0 +1,24 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.query.parser; + +import com.yahoo.search.query.QueryTree; + +/** + * Defines the interface of a query parser. To construct an instance of this class, use the {@link ParserFactory}. + * + * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a> + */ +public interface Parser { + + /** + * Parser the given {@link Parsable}, and returns a corresponding + * {@link QueryTree}. If parsing fails without an exception, the contained + * root will be an instance of {@link com.yahoo.prelude.query.NullItem}. + * + * @param query + * the Parsable to parse + * @return the parsed QueryTree, never null + */ + QueryTree parse(Parsable query); + +} diff --git a/container-search/src/main/java/com/yahoo/search/query/parser/ParserEnvironment.java b/container-search/src/main/java/com/yahoo/search/query/parser/ParserEnvironment.java new file mode 100644 index 00000000000..b00afa27bf6 --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/parser/ParserEnvironment.java @@ -0,0 +1,76 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.query.parser; + +import com.yahoo.language.Linguistics; +import com.yahoo.language.simple.SimpleLinguistics; +import com.yahoo.prelude.IndexFacts; +import com.yahoo.prelude.query.parser.SpecialTokenRegistry; +import com.yahoo.prelude.query.parser.SpecialTokens; +import com.yahoo.search.Searcher; +import com.yahoo.search.searchchain.Execution; + +/** + * This class encapsulates the environment of a {@link Parser}. In case you are creating a parser from within a + * {@link Searcher}, you can use the {@link #fromExecutionContext(Execution.Context)} factory for convenience. + * + * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a> + * @since 5.1.4 + */ +public final class ParserEnvironment { + + private IndexFacts indexFacts = new IndexFacts(); + private Linguistics linguistics = new SimpleLinguistics(); + private SpecialTokens specialTokens = new SpecialTokens(); + + public IndexFacts getIndexFacts() { + return indexFacts; + } + + public ParserEnvironment setIndexFacts(IndexFacts indexFacts) { + this.indexFacts = indexFacts; + return this; + } + + public Linguistics getLinguistics() { + return linguistics; + } + + public ParserEnvironment setLinguistics(Linguistics linguistics) { + this.linguistics = linguistics; + return this; + } + + public SpecialTokens getSpecialTokens() { + return specialTokens; + } + + public ParserEnvironment setSpecialTokens(SpecialTokens specialTokens) { + this.specialTokens = specialTokens; + return this; + } + + public static ParserEnvironment fromExecutionContext(Execution.Context context) { + ParserEnvironment env = new ParserEnvironment(); + if (context == null) { + return env; + } + if (context.getIndexFacts() != null) { + env.setIndexFacts(context.getIndexFacts()); + } + if (context.getLinguistics() != null) { + env.setLinguistics(context.getLinguistics()); + } + SpecialTokenRegistry registry = context.getTokenRegistry(); + if (registry != null) { + env.setSpecialTokens(registry.getSpecialTokens("default")); + } + return env; + } + + public static ParserEnvironment fromParserEnvironment(ParserEnvironment environment) { + return new ParserEnvironment() + .setIndexFacts(environment.indexFacts) + .setLinguistics(environment.linguistics) + .setSpecialTokens(environment.specialTokens); + } +} diff --git a/container-search/src/main/java/com/yahoo/search/query/parser/ParserFactory.java b/container-search/src/main/java/com/yahoo/search/query/parser/ParserFactory.java new file mode 100644 index 00000000000..e0a3338fec2 --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/parser/ParserFactory.java @@ -0,0 +1,48 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.query.parser; + +import com.yahoo.prelude.query.parser.*; +import com.yahoo.search.Query; +import com.yahoo.search.yql.YqlParser; + +/** + * <p>Implements a factory for {@link Parser}.</p> + * + * @author <a href="mailto:simon@yahoo-inc.com">Simon Thoresen</a> + * @since 5.1.4 + */ +public final class ParserFactory { + + private ParserFactory() { + // hide + } + + /** + * Creates a {@link Parser} appropriate for the given <tt>Query.Type</tt>, providing the Parser with access to + * the {@link ParserEnvironment} given. + * + * @param type the query type for which to create a Parser + * @param environment the environment settings to attach to the Parser + * @return the created Parser + */ + public static Parser newInstance(Query.Type type, ParserEnvironment environment) { + switch (type) { + case ALL: + return new AllParser(environment); + case ANY: + return new AnyParser(environment); + case PHRASE: + return new PhraseParser(environment); + case ADVANCED: + return new AdvancedParser(environment); + case WEB: + return new WebParser(environment); + case PROGRAMMATIC: + return new ProgrammaticParser(); + case YQL: + return new YqlParser(environment); + default: + throw new UnsupportedOperationException(type.toString()); + } + } +} diff --git a/container-search/src/main/java/com/yahoo/search/query/parser/package-info.java b/container-search/src/main/java/com/yahoo/search/query/parser/package-info.java new file mode 100644 index 00000000000..ddae3e83ddb --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/parser/package-info.java @@ -0,0 +1,10 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +/** + * Provides access to parsing query strings into queries + */ +@ExportPackage +@PublicApi +package com.yahoo.search.query.parser; + +import com.yahoo.api.annotations.PublicApi; +import com.yahoo.osgi.annotation.ExportPackage; diff --git a/container-search/src/main/java/com/yahoo/search/query/profile/AllReferencesQueryProfileVisitor.java b/container-search/src/main/java/com/yahoo/search/query/profile/AllReferencesQueryProfileVisitor.java new file mode 100644 index 00000000000..393aba2b002 --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/profile/AllReferencesQueryProfileVisitor.java @@ -0,0 +1,40 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.query.profile; + +import com.yahoo.processing.request.CompoundName; +import com.yahoo.search.query.profile.types.FieldDescription; +import com.yahoo.search.query.profile.types.QueryProfileFieldType; +import com.yahoo.search.query.profile.types.QueryProfileType; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +/** + * @author <a href="mailto:bratseth@yahoo-inc.com">Jon Bratseth</a> + */ +final class AllReferencesQueryProfileVisitor extends PrefixQueryProfileVisitor { + + /** A map of query profile types */ + private Set<CompoundName> references = new HashSet<>(); + + public AllReferencesQueryProfileVisitor(CompoundName prefix) { + super(prefix); + } + + @Override + public void onValue(String name, Object value, DimensionBinding binding, QueryProfile owner) {} + + @Override + public void onQueryProfileInsidePrefix(QueryProfile profile, DimensionBinding binding, QueryProfile owner) { + references.add(currentPrefix); + } + + /** Returns the values resulting from this visiting */ + public Set<CompoundName> getResult() { return references; } + + /** Returns false - we are not done until we have seen all */ + public boolean isDone() { return false; } + +} diff --git a/container-search/src/main/java/com/yahoo/search/query/profile/AllTypesQueryProfileVisitor.java b/container-search/src/main/java/com/yahoo/search/query/profile/AllTypesQueryProfileVisitor.java new file mode 100644 index 00000000000..fb9638a958b --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/profile/AllTypesQueryProfileVisitor.java @@ -0,0 +1,51 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.query.profile; + +import com.yahoo.processing.request.CompoundName; +import com.yahoo.search.query.profile.types.FieldDescription; +import com.yahoo.search.query.profile.types.QueryProfileFieldType; +import com.yahoo.search.query.profile.types.QueryProfileType; + +import java.util.HashMap; +import java.util.Map; + +/** + * @author <a href="mailto:bratseth@yahoo-inc.com">Jon Bratseth</a> + */ +final class AllTypesQueryProfileVisitor extends PrefixQueryProfileVisitor { + + /** A map of query profile types */ + private Map<CompoundName, QueryProfileType> types = new HashMap<>(); + + public AllTypesQueryProfileVisitor(CompoundName prefix) { + super(prefix); + } + + @Override + public void onValue(String name, Object value, DimensionBinding binding, QueryProfile owner) {} + + + @Override + public void onQueryProfileInsidePrefix(QueryProfile profile, DimensionBinding binding, QueryProfile owner) { + if (profile.getType() != null) + addReachableTypes(currentPrefix, profile.getType()); + } + + private void addReachableTypes(CompoundName name, QueryProfileType type) { + types.put(name, type); + for (FieldDescription fieldDescription : type.fields().values()) { + if ( ! (fieldDescription.getType() instanceof QueryProfileFieldType)) continue; + QueryProfileFieldType fieldType = (QueryProfileFieldType)fieldDescription.getType(); + if (fieldType.getQueryProfileType() !=null) { + addReachableTypes(name.append(fieldDescription.getName()), fieldType.getQueryProfileType()); + } + } + } + + /** Returns the values resulting from this visiting */ + public Map<CompoundName, QueryProfileType> getResult() { return types; } + + /** Returns false - we are not done until we have seen all */ + public boolean isDone() { return false; } + +} diff --git a/container-search/src/main/java/com/yahoo/search/query/profile/AllUnoverridableQueryProfileVisitor.java b/container-search/src/main/java/com/yahoo/search/query/profile/AllUnoverridableQueryProfileVisitor.java new file mode 100644 index 00000000000..65c3480272e --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/profile/AllUnoverridableQueryProfileVisitor.java @@ -0,0 +1,45 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.query.profile; + +import com.yahoo.processing.request.CompoundName; + +import java.util.HashSet; +import java.util.Set; + +/** + * @author <a href="mailto:bratseth@yahoo-inc.com">Jon Bratseth</a> + */ +final class AllUnoverridableQueryProfileVisitor extends PrefixQueryProfileVisitor { + + /** A map of query profile types */ + private Set<CompoundName> unoverridables = new HashSet<>(); + + public AllUnoverridableQueryProfileVisitor(CompoundName prefix) { + super(prefix); + } + + @Override + public void onValue(String name, Object value, DimensionBinding binding, QueryProfile owner) { + addUnoverridable(name, currentPrefix.append(name), binding, owner); + } + + @Override + public void onQueryProfileInsidePrefix(QueryProfile profile, DimensionBinding binding, QueryProfile owner) { + addUnoverridable(currentPrefix.last(), currentPrefix, binding, owner); + } + + private void addUnoverridable(String localName, CompoundName fullName, DimensionBinding binding, QueryProfile owner) { + if (owner == null) return; + + Boolean isOverridable = owner.isLocalOverridable(localName, binding); + if (isOverridable != null && ! isOverridable) + unoverridables.add(fullName); + } + + /** Returns the values resulting from this visiting */ + public Set<CompoundName> getResult() { return unoverridables; } + + /** Returns false - we are not done until we have seen all */ + public boolean isDone() { return false; } + +} diff --git a/container-search/src/main/java/com/yahoo/search/query/profile/AllValuesQueryProfileVisitor.java b/container-search/src/main/java/com/yahoo/search/query/profile/AllValuesQueryProfileVisitor.java new file mode 100644 index 00000000000..bef5b00c51b --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/profile/AllValuesQueryProfileVisitor.java @@ -0,0 +1,44 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.query.profile; + +import com.yahoo.processing.request.CompoundName; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +/** + * @author <a href="mailto:bratseth@yahoo-inc.com">Jon Bratseth</a> + */ +final class AllValuesQueryProfileVisitor extends PrefixQueryProfileVisitor { + + private Map<String,Object> values=new HashMap<>(); + + /* Lists all values starting at prefix */ + public AllValuesQueryProfileVisitor(CompoundName prefix) { + super(prefix); + } + + public @Override void onValue(String localName, Object value, DimensionBinding binding, QueryProfile owner) { + putValue(localName, value, values); + } + + public @Override void onQueryProfileInsidePrefix(QueryProfile profile, DimensionBinding binding, QueryProfile owner) { + putValue("", profile.getValue(), values); + } + + private final void putValue(String key, Object value, Map<String, Object> values) { + if (value == null) return; + CompoundName fullName = currentPrefix.append(key); + if (fullName.isEmpty()) return; // Avoid putting a non-leaf (subtree) root in the list + if (values.containsKey(fullName.toString())) return; // The first value encountered has priority + values.put(fullName.toString(), value); + } + + /** Returns the values resulting from this visiting */ + public Map<String, Object> getResult() { return values; } + + /** Returns false - we are not done until we have seen all */ + public boolean isDone() { return false; } + +} diff --git a/container-search/src/main/java/com/yahoo/search/query/profile/BackedOverridableQueryProfile.java b/container-search/src/main/java/com/yahoo/search/query/profile/BackedOverridableQueryProfile.java new file mode 100644 index 00000000000..71b27c6da63 --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/profile/BackedOverridableQueryProfile.java @@ -0,0 +1,139 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.query.profile; + +import com.yahoo.processing.request.CompoundName; +import com.yahoo.protect.Validator; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * <p>A wrapper of a query profile where overrides to the values in the referenced + * profile can be set.</p> + * + * <p>This is used to allow configured overrides (in a particular referencing profile) of a referenced query profile. + * + * <p>Properties which are defined as not overridable in the type (if any) of the referenced query profile + * cannot be set.</p> + * + * @author bratseth + */ +public class BackedOverridableQueryProfile extends OverridableQueryProfile implements Cloneable { + + /** The backing read only query profile, or null if this is not backed */ + private QueryProfile backingProfile; + + /** + * Creates an overridable profile from the given backing profile. The backing profile will never be + * written to. + * + * @param backingProfile the backing profile, which is assumed read only, never null + */ + public BackedOverridableQueryProfile(QueryProfile backingProfile) { + Validator.ensureNotNull("An overridable query profile must be backed by a real query profile",backingProfile); + setType(backingProfile.getType()); + this.backingProfile=backingProfile; + } + + @Override + public synchronized void freeze() { + super.freeze(); + backingProfile.freeze(); + } + + @Override + protected Object localLookup(String localName, DimensionBinding dimensionBinding) { + Object valueInThis=super.localLookup(localName,dimensionBinding); + if (valueInThis!=null) return valueInThis; + return backingProfile.localLookup(localName,dimensionBinding); + } + + protected Boolean isLocalInstanceOverridable(String localName) { + Boolean valueInThis=super.isLocalInstanceOverridable(localName); + if (valueInThis!=null) return valueInThis; + return backingProfile.isLocalInstanceOverridable(localName); + } + + @Override + protected QueryProfile createSubProfile(String name,DimensionBinding dimensionBinding) { + Object backing=backingProfile.lookup(new CompoundName(name),true,dimensionBinding.createFor(backingProfile.getDimensions())); + if (backing!=null && backing instanceof QueryProfile) + return new BackedOverridableQueryProfile((QueryProfile)backing); + else + return new OverridableQueryProfile(); // Nothing is set in this branch, so nothing to override, but need override checking + } + + /** Returns a clone of this which can be independently overridden, but which refers to the same backing profile */ + @Override + public BackedOverridableQueryProfile clone() { + BackedOverridableQueryProfile clone=(BackedOverridableQueryProfile)super.clone(); + return clone; + } + + /** Returns the query profile backing this */ + public QueryProfile getBacking() { return backingProfile; } + + @Override + public void addInherited(QueryProfile inherited) { + backingProfile.addInherited(inherited); + } + + void addInheritedHere(QueryProfile inherited) { + super.addInherited(inherited); + } + + @Override + protected void visitVariants(boolean allowContent,QueryProfileVisitor visitor,DimensionBinding dimensionBinding) { + super.visitVariants(allowContent, visitor, dimensionBinding); + if (visitor.isDone()) return; + backingProfile.visitVariants(allowContent, visitor, dimensionBinding); + } + + @Override + protected void visitInherited(boolean allowContent,QueryProfileVisitor visitor,DimensionBinding dimensionBinding, QueryProfile owner) { + super.visitInherited(allowContent,visitor,dimensionBinding, owner); + if (visitor.isDone()) return; + backingProfile.visitInherited(allowContent,visitor,dimensionBinding,owner); + } + + /** Returns a value from the content of this: The value in this, or the value from the backing if not set in this */ + protected Object getContent(String localKey) { + Object value=super.getContent(localKey); + if (value!=null) return value; + return backingProfile.getContent(localKey); + } + + /** + * Returns all the content from this: + * All the values in this, and all values in the backing where an overriding value is not set in this + */ + @Override + protected Map<String,Object> getContent() { + Map<String,Object> thisContent=super.getContent(); + Map<String,Object> backingContent=backingProfile.getContent(); + if (thisContent.isEmpty()) return backingContent; // Shortcut + if (backingContent.isEmpty()) return thisContent; // Shortcut + Map<String,Object> content=new HashMap<>(backingContent); + content.putAll(thisContent); + return content; + } + + @Override + public String toString() { + return "overridable wrapper of " + backingProfile.toString(); + } + + @Override + public boolean isExplicit() { + return backingProfile.isExplicit(); + } + + @Override + public List<String> getDimensions() { + List<String> dimensions=super.getDimensions(); + if (dimensions!=null) return dimensions; + return backingProfile.getDimensions(); + } + +} diff --git a/container-search/src/main/java/com/yahoo/search/query/profile/CopyOnWriteContent.java b/container-search/src/main/java/com/yahoo/search/query/profile/CopyOnWriteContent.java new file mode 100644 index 00000000000..3c02677b676 --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/profile/CopyOnWriteContent.java @@ -0,0 +1,159 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.query.profile; + +import com.yahoo.component.provider.FreezableClass; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +/** + * A HashMap wrapper which can be cloned without copying the wrapped map. + * Copying of the map is deferred until there is a write access to the wrapped map. + * This may be frozen, at which point no further modifications are allowed. + * Note that <b>until</b> this is cloned, the internal map may be both read and written. + * + * @author <a href="mailto:bratseth@yahoo-inc.com">Jon Bratseth</a> + */ +public class CopyOnWriteContent extends FreezableClass implements Cloneable { + + // TODO: Now that we used CompiledQueryProfiles at runtime we can remove this + + // Possible states: + // WRITABLE: The map can be freely modified - it is only used by this + // -> !isFrozen() && (map!=null || unmodifiableMap==null) + // COPYONWRITE: The map is referred by at least one clone - further modification must cause a copy + // -> !isFrozen() && (map==null && unmodifiableMap!=null) + // FROZEN: No further changes are allowed to the state of this, ever + // -> isFrozen() + + // Possible start states: + // WRITABLE: When created using the public constructor + // COPYONWRITE: When created by cloning + + // Possible state transitions: + // WRITABLE->COPYONWRITE: When this is cloned + // COPYONWRITE->WRITABLE: When a clone is written to + // (COPYONWRITE,WRITABLE)->FROZEN: When a profile is frozen + + /** The modifiable content of this. Null if this is empty or if this is not in the WRITABLE state */ + private Map<String,Object> map=null; + /** + * If map is non-null this is either null (not instantiated yet) or an unmodifiable wrapper of map, + * if map is null this is either null (this is empty) or a reference to the map of the content this was cloned from + */ + private Map<String,Object> unmodifiableMap =null; + + /** Create a WRITABLE, empty instance */ + public CopyOnWriteContent() { + } + + /** Create a COPYONWRITE instance with some initial state */ + private static CopyOnWriteContent createInCopyOnWriteState(Map<String,Object> unmodifiableMap) { + CopyOnWriteContent content=new CopyOnWriteContent(); + content.unmodifiableMap = unmodifiableMap; + return content; + } + + /** Create a WRITABLE instance with some initial state */ + private static CopyOnWriteContent createInWritableState(Map<String,Object> map) { + CopyOnWriteContent content=new CopyOnWriteContent(); + content.map = map; + return content; + } + + @Override + public void freeze() { + // Freeze this + if (unmodifiableMap==null) + unmodifiableMap= map!=null ? Collections.unmodifiableMap(map) : Collections.<String, Object>emptyMap(); + map=null; // just to keep the states simpler + + // Freeze content + for (Map.Entry<String,Object> entry : unmodifiableMap.entrySet()) { + if (entry.getValue() instanceof QueryProfile) + ((QueryProfile)entry.getValue()).freeze(); + } + super.freeze(); + } + + private boolean isEmpty() { + return (map==null || map.isEmpty()) && (unmodifiableMap ==null || unmodifiableMap.isEmpty()); + } + + private boolean isWritable() { + return !isFrozen() && (map!=null || unmodifiableMap==null); + } + + @Override + public CopyOnWriteContent clone() { + if (isEmpty()) return new CopyOnWriteContent(); // No referencing is necessary in this case + if (isDeepUnmodifiable(unmodifiableMap())) { + // Create an instance pointing to this and put both in the COPYONWRITE state + unmodifiableMap(); // Make sure we have an unmodifiable reference to the map below + map=null; // Put this into the COPYONWRITE state (unless it is already frozen, in which case this is a noop) + return createInCopyOnWriteState(unmodifiableMap()); + } + else { + // This contains query profiles, don't try to defer copying + return createInWritableState(deepClone(map)); + } + } + + private boolean isDeepUnmodifiable(Map<String,Object> map) { + for (Object value : map.values()) + if (value instanceof QueryProfile && !((QueryProfile)value).isFrozen()) return false; + return true; // all other values are primitives + } + + /** Deep clones a map - this handles all value types which can be found in a query profile */ + static Map<String,Object> deepClone(Map<String,Object> map) { + if (map==null) return null; + Map<String,Object> mapClone=new HashMap<>(map.size()); + for (Map.Entry<String,Object> entry : map.entrySet()) + mapClone.put(entry.getKey(),QueryProfile.cloneIfNecessary(entry.getValue())); + return mapClone; + } + + + //------- Content access ------------------------------------------------------- + + public Map<String,Object> unmodifiableMap() { + if (isEmpty()) return Collections.emptyMap(); + if (map==null) // in COPYONWRITE or FROZEN state + return unmodifiableMap; + // In WRITABLE state: Create unmodifiable wrapper if necessary and return it + if (unmodifiableMap==null) + unmodifiableMap=Collections.unmodifiableMap(map); + return unmodifiableMap; + } + + public Object get(String key) { + if (map!=null) return map.get(key); + if (unmodifiableMap!=null) return unmodifiableMap.get(key); + return null; + } + + public void put(String key,Object value) { + ensureNotFrozen(); + copyIfNotWritable(); + if (map==null) + map=new HashMap<>(); + map.put(key,value); + } + + public void remove(String key) { + ensureNotFrozen(); + copyIfNotWritable(); + if (map!=null) + map.remove(key); + } + + private void copyIfNotWritable() { + if (isWritable()) return; + // move from COPYONWRITE to WRITABLE state + map=new HashMap<>(unmodifiableMap); // deep clone is not necessary as this map is shallowly modifiable + unmodifiableMap=null; // will be created as needed + } + +} diff --git a/container-search/src/main/java/com/yahoo/search/query/profile/DimensionBinding.java b/container-search/src/main/java/com/yahoo/search/query/profile/DimensionBinding.java new file mode 100644 index 00000000000..9adacee74af --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/profile/DimensionBinding.java @@ -0,0 +1,223 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.query.profile; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * An immutable, binding of a list of dimensions to dimension values + * + * @author <a href="mailto:bratseth@yahoo-inc.com">Jon Bratseth</a> + */ +public class DimensionBinding { + + /** The dimensions of this */ + private List<String> dimensions=null; + + /** The values matching those dimensions */ + private DimensionValues values; + + /** The binding from those dimensions to values, and possibly other values */ + private Map<String,String> context; + + public static final DimensionBinding nullBinding = + new DimensionBinding(Collections.<String>unmodifiableList(Collections.<String>emptyList()), DimensionValues.empty, null); + + public static final DimensionBinding invalidBinding = + new DimensionBinding(Collections.<String>unmodifiableList(Collections.<String>emptyList()), DimensionValues.empty, null); + + /** Whether the value array contains only nulls */ + private boolean containsAllNulls; + + /** Creates a binding from a variant and a context. Any of the arguments may be null. */ + public static DimensionBinding createFrom(List<String> dimensions, Map<String,String> context) { + if (dimensions==null || dimensions.size()==0) { + if (context==null) return nullBinding; + if (dimensions==null) return new DimensionBinding(null,DimensionValues.empty,context); // Null, but must preserve context + } + + return new DimensionBinding(dimensions,extractDimensionValues(dimensions,context),context); + } + + /** Creates a binding from a variant and a context. Any of the arguments may be null. */ + public static DimensionBinding createFrom(List<String> dimensions, DimensionValues dimensionValues) { + if (dimensionValues==null || dimensionValues==DimensionValues.empty) return nullBinding; + if (dimensions==null) return new DimensionBinding(null,dimensionValues,null); // Null, but preserve raw material for creating a context later (in createFor) + + return new DimensionBinding(dimensions,dimensionValues,null); + } + + /** Returns a binding for a (possibly) new set of variants. Variants may be null, but not bindings */ + public DimensionBinding createFor(List<String> newDimensions) { + if (newDimensions==null) return this; // Note: Not necessarily null - if no new variants then keep the existing binding + // if (this.context==null && values.length==0) return nullBinding; // No data from which to create a non-null binding + if (this.dimensions==newDimensions) return this; // Avoid creating a new object if the dimensions are the same + + Map<String,String> context=this.context; + if (context==null) + context=this.values.asContext(this.dimensions !=null ? this.dimensions : newDimensions); + return new DimensionBinding(newDimensions,extractDimensionValues(newDimensions,context),context); + } + + /** + * Creates a dimension binding. The dimensions list given should be unmodifiable. + * The array will not be modified. The context is needed in order to convert this binding to another + * given another set of variant dimensions. + */ + private DimensionBinding(List<String> dimensions, DimensionValues values, Map<String,String> context) { + this.dimensions=dimensions; + this.values=values; + this.context = context; + containsAllNulls=values.isEmpty(); + } + + /** Returns a read-only list of the dimensions of this. This value is undefined if this isNull() */ + public List<String> getDimensions() { return dimensions; } + + /** Returns a context created from the dimensions and values of this */ + public Map<String,String> getContext() { + if (context !=null) return context; + context =values.asContext(dimensions); + return context; + } + + /** + * Returns the values for the dimensions of this. This value is undefined if this isEmpty() + * This array is always of the same length as the + * length of the dimension list - missing elements are represented as nulls. + * This is never null but may be empty. + */ + public DimensionValues getValues() { return values; } + + /** Returns true only if this binding is null (contains no values for its dimensions (if any) */ + public boolean isNull() { return dimensions==null || containsAllNulls; } + + /** + * Returns an array of the dimension values corresponding to the dimensions of this from the given context, + * in the corresponding order. The array is always of the same length as the number of dimensions. + * Dimensions which are not set in this context get a null value. + */ + private static DimensionValues extractDimensionValues(List<String> dimensions,Map<String,String> context) { + String[] dimensionValues=new String[dimensions.size()]; + if (context==null || context.size()==0) return DimensionValues.createFrom(dimensionValues); + for (int i=0; i<dimensions.size(); i++) + dimensionValues[i]=context.get(dimensions.get(i)); + return DimensionValues.createFrom(dimensionValues); + } + + /** + * Combines this binding with another if compatible. + * Two bindings are incompatible if + * <ul> + * <li>They contain a different value for the same key, or</li> + * <li>They contain the same pair of dimensions in a different order</li> + * </ul> + * + * @return the combined binding, or the special invalidBinding if these two bindings are incompatible + */ + public DimensionBinding combineWith(DimensionBinding binding) { + List<String> combinedDimensions = combineDimensions(getDimensions(), binding.getDimensions()); + if (combinedDimensions == null) return invalidBinding; + + // not runtime, so assume we don't need to preserve values outside the dimensions + Map<String, String> combinedValues = combineValues(getContext(), binding.getContext()); + if (combinedValues == null) return invalidBinding; + + return DimensionBinding.createFrom(combinedDimensions, combinedValues); + } + + /** + * Returns a combined list of dimensions from two separate lists, + * or null if they are incompatible. + * This is to combine two lists to one such that the partial order in both is preserved + * (or return null if impossible). + */ + private List<String> combineDimensions(List<String> d1, List<String> d2) { + List<String> combined = new ArrayList<>(); + int d1Index = 0, d2Index=0; + while (d1Index < d1.size() && d2Index < d2.size()) { + if (d1.get(d1Index).equals(d2.get(d2Index))) { // agreement on next element + combined.add(d1.get(d1Index)); + d1Index++; + d2Index++; + } + else if ( ! d2.contains(d1.get(d1Index))) { // next in d1 is independent from d2 + combined.add(d1.get(d1Index++)); + } + else if ( ! d1.contains(d2.get(d2Index))) { // next in d2 is independent from d1 + combined.add(d2.get(d2Index++)); + } + else { + return null; // no independent and no agreement + } + } + if (d1Index < d1.size()) + combined.addAll(d1.subList(d1Index, d1.size())); + else if (d2Index < d2.size()) + combined.addAll(d2.subList(d2Index, d2.size())); + + return combined; + } + + /** + * Returns a combined map of dimension values from two separate maps, + * or null if they are incompatible. + */ + private Map<String, String> combineValues(Map<String, String> m1, Map<String, String> m2) { + Map<String, String> combinedValues = new HashMap<>(m1); + for (Map.Entry<String, String> m2Entry : m2.entrySet()) { + if (m2Entry.getValue() == null) continue; + String m1Value = m1.get(m2Entry.getKey()); + if (m1Value != null && ! m1Value.equals(m2Entry.getValue())) + return null; // conflicting values of a key + combinedValues.put(m2Entry.getKey(), m2Entry.getValue()); + } + return combinedValues; + } + + private boolean intersects(List<String> l1, List<String> l2) { + for (String l1Item : l1) + if (l2.contains(l1Item)) + return true; + return false; + } + + /** + * Returns true if <code>this == invalidBinding</code> + */ + public boolean isInvalid() { return this == invalidBinding; } + + @Override + public String toString() { + if (isInvalid()) return "Invalid DimensionBinding"; + if (dimensions==null) return "DimensionBinding []"; + StringBuilder b=new StringBuilder("DimensionBinding ["); + for (int i=0; i<dimensions.size(); i++) { + b.append(dimensions.get(i)).append("=").append(values.get(i)); + if (i<dimensions.size()-1) + b.append(", "); + } + b.append("]"); + return b.toString(); + } + + /** Two bindings are equal if they contain the same dimensions and the same non-null values */ + @Override + public boolean equals(Object o) { + if (o==this) return true; + if (! (o instanceof DimensionBinding)) return false; + DimensionBinding other = (DimensionBinding)o; + if ( ! this.dimensions.equals(other.dimensions)) return false; + if ( ! this.values.equals(other.values)) return false; + return true; + } + + @Override + public int hashCode() { + return dimensions.hashCode() + 17 * values.hashCode(); + } + +} diff --git a/container-search/src/main/java/com/yahoo/search/query/profile/DimensionValues.java b/container-search/src/main/java/com/yahoo/search/query/profile/DimensionValues.java new file mode 100644 index 00000000000..10435c4c6b5 --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/profile/DimensionValues.java @@ -0,0 +1,140 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.query.profile; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * An immutable set of dimension values. + * Note that this may contain more or fewer values than needed given a set of dimensions. + * Any missing values are treated as null. + */ +public class DimensionValues implements Comparable<DimensionValues> { + + private final String[] values; + + public static final DimensionValues empty=new DimensionValues(new String[] {}); + + public static DimensionValues createFrom(String[] values) { + if (values==null || values.length==0 || containsAllNulls(values)) return empty; + return new DimensionValues(values); + } + + /** + * Creates a set of dimension values, where the input array <b>must</b> be of + * the right size, and where no copying is done. + * + * @param values the dimension values. This need not be normalized to the right size. + * The input array is copied by this. + */ + private DimensionValues(String[] values) { + if (values==null) throw new NullPointerException("Dimension values cannot be null"); + this.values=Arrays.copyOf(values,values.length); + } + + /** Returns true if this is has the same value every place it has a value as the givenValues. */ + public boolean matches(DimensionValues givenValues) { + for (int i=0; i<this.size() || i<givenValues.size() ; i++) + if ( ! matches(this.get(i),givenValues.get(i))) + return false; + return true; + } + + private final boolean matches(String conditionString,String checkString) { + if (conditionString==null) return true; + return conditionString.equals(checkString); + } + + /** + * Implements the sort order of this which is based on specificity + * where dimensions to the left are more significant: + * -1 is returned if this is more specific than other, + * 1 is returned if other is more specific than this, + * 0 is returned if none is more specific than the other. + * <p> + * <b>Note:</b> This ordering is not consistent with equals - it returns 0 when the same dimensions + * are <i>set</i>, regardless of what they are set <i>to</i>. + */ + @Override + public int compareTo(DimensionValues other) { + for (int i=0; i<this.size() || i<other.size(); i++) { + if (get(i)!=null && other.get(i)==null) + return -1; + if (get(i)==null && other.get(i)!=null) + return 1; + } + return 0; + } + + /** Helper method which uses compareTo to return whether this is most specific */ + public boolean isMoreSpecificThan(DimensionValues other) { + return this.compareTo(other)<0; + } + + @Override + public boolean equals(Object o) { + if (this==o) return true; + if ( ! (o instanceof DimensionValues)) return false; + DimensionValues other=(DimensionValues)o; + for (int i=0; i<this.size() || i<other.size(); i++) { + if (get(i)==null) { + if (other.get(i)!=null) return false; + } + else { + if ( ! get(i).equals(other.get(i))) return false; + } + } + return true; + } + + @Override + public int hashCode() { + int hashCode = 0; + int i = 0; + for (String value : values) { + i++; + if (value != null) + hashCode += value.hashCode() * i; + } + return hashCode; + } + + @Override + public String toString() { return Arrays.toString(values); } + + public boolean isEmpty() { + return this==empty; + } + + private static boolean containsAllNulls(String[] values) { + for (String value : values) + if (value!=null) return false; + return true; + } + + public Map<String,String> asContext(List<String> dimensions) { + Map<String,String> context=new HashMap<>(); + if (dimensions==null) return context; + for (int i=0; i<dimensions.size(); i++) { + context.put(dimensions.get(i),get(i)); + } + return context; + } + + /** Returns the string at the given index, <b>or null if it has no value at this index.</b> */ + public String get(int index) { + if (index>=values.length) return null; + return values[index]; + } + + /** Returns the number of values in this (some of which may be null) */ + public int size() { return values.length; } + + /** Returns copy of the values in this in an array */ + public String[] getValues() { + return Arrays.copyOf(values,values.length); + } + +} diff --git a/container-search/src/main/java/com/yahoo/search/query/profile/DumpTool.java b/container-search/src/main/java/com/yahoo/search/query/profile/DumpTool.java new file mode 100644 index 00000000000..b9d631cdd10 --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/profile/DumpTool.java @@ -0,0 +1,89 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.query.profile; + +import java.io.File; +import java.util.Map; + +import com.yahoo.yolean.Exceptions; +import com.yahoo.search.Query; +import com.yahoo.search.query.profile.config.QueryProfileXMLReader; + +/** + * A standalone tool for dumping query profile properties + * + * @author <a href="mailto:bratseth@yahoo-inc.com">Jon Bratseth</a> + */ +public class DumpTool { + + /** Creates and returns a dump from some parameters */ + public String resolveAndDump(String... args) { + if (args.length==0 || args[0].startsWith("-")) { + StringBuilder result=new StringBuilder(); + result.append("Dumps all resolved query profile properties for a set of dimension values\n"); + result.append("USAGE: dump [query-profile] [dir]? [parameters]?\n"); + result.append(" and [query-profile] is the name of the query profile to dump the values of\n"); + result.append(" and [dir] is a path to an application package or query profile directory. Default: current dir\n"); + result.append(" and [parameters] is the http request encoded dimension keys used during resolving. Default: none\n"); + result.append("Examples:\n"); + result.append(" dump default\n"); + result.append(" - dumps the 'default' profile non-variant values in the current dir\n"); + result.append(" dump default x=x1&y=y1\n"); + result.append(" - dumps the 'default' profile resolved with dimensions values x=x1 and y=y1 in the current dir\n"); + result.append(" dump default myapppackage\n"); + result.append(" - dumps the 'default' profile non-variant values in myapppackage/search/query-profiles\n"); + result.append(" dump default dev/myprofiles x=x1&y=y1\n"); + result.append(" - dumps the 'default' profile resolved with dimensions values x=x1 and y=y1 in dev/myprofiles\n"); + return result.toString(); + } + + // Find what the arguments means + if (args.length>=3) { + return dump(args[0],args[1],args[2]); + } + else if (args.length==2) { + if (args[1].indexOf("=")>=0) + return dump(args[0],"",args[1]); + else + return dump(args[0],args[1],""); + } + else { // args.length=1 + return dump(args[0],"",""); + } + } + + private String dump(String profileName,String dir,String parameters) { + // Import profiles + if (dir.isEmpty()) + dir="."; + File dirInAppPackage=new File(dir,"search/query-profiles"); + if (dirInAppPackage.exists()) + dir=dirInAppPackage.getPath(); + QueryProfileXMLReader reader = new QueryProfileXMLReader(); + QueryProfileRegistry registry = reader.read(dir); + registry.freeze(); + + // Dump (through query to get wiring & parameter parsing done easily) + Query query = new Query("?" + parameters, registry.compile().findQueryProfile(profileName)); + Map<String,Object> properties=query.properties().listProperties(); + + // Create result + StringBuilder b=new StringBuilder(); + for (Map.Entry<String,Object> property : properties.entrySet()) { + b.append(property.getKey()); + b.append("="); + b.append(property.getValue().toString()); + b.append("\n"); + } + return b.toString(); + } + + public static void main(String... args) { + try { + System.out.print(new DumpTool().resolveAndDump(args)); + } + catch (Exception e) { + System.err.println(Exceptions.toMessageString(e)); + } + } + +} diff --git a/container-search/src/main/java/com/yahoo/search/query/profile/FieldDescriptionQueryProfileVisitor.java b/container-search/src/main/java/com/yahoo/search/query/profile/FieldDescriptionQueryProfileVisitor.java new file mode 100644 index 00000000000..73c0fcd2cb1 --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/profile/FieldDescriptionQueryProfileVisitor.java @@ -0,0 +1,70 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.query.profile; + +import com.yahoo.search.query.profile.types.FieldDescription; + +import java.util.List; + +/** + * @author <a href="mailto:bratseth@yahoo-inc.com">Jon Bratseth</a> + */ +final class FieldDescriptionQueryProfileVisitor extends QueryProfileVisitor { + + /** The result, or null if none */ + private FieldDescription result = null; + + private final List<String> name; + + private int nameIndex=-1; + + private boolean enteringContent=false; + + public FieldDescriptionQueryProfileVisitor(List<String> name) { + this.name=name; + } + + @Override + public String getLocalKey() { + return name.get(nameIndex); + } + + @Override + public boolean enter(String name) { + if (nameIndex+2<this.name.size()) { + nameIndex++; + enteringContent=true; + } + else { + enteringContent=false; + } + return enteringContent; + } + + @Override + public void leave(String name) { + nameIndex--; + } + + @Override + public void onValue(String name,Object value, DimensionBinding binding, QueryProfile owner) { + } + + @Override + public void onQueryProfile(QueryProfile profile, DimensionBinding binding, QueryProfile owner) { + if (enteringContent) return; // not at leaf query profile + if (profile.getType() == null) return; + result = profile.getType().getField(name.get(name.size()-1)); + } + + @Override + public boolean isDone() { + return result != null; + } + + public FieldDescription result() { return result; } + + @Override + public String toString() { + return "a query profile type visitor (hash " + hashCode() + ") with current value " + result; + } +} diff --git a/container-search/src/main/java/com/yahoo/search/query/profile/ModelObjectMap.java b/container-search/src/main/java/com/yahoo/search/query/profile/ModelObjectMap.java new file mode 100644 index 00000000000..242c551f876 --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/profile/ModelObjectMap.java @@ -0,0 +1,26 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.query.profile; + +import com.yahoo.processing.request.CompoundName; +import com.yahoo.search.query.profile.types.FieldType; +import com.yahoo.search.query.properties.PropertyMap; + +/** + * A map which stores all types which cannot be stored in a query profile + * that is rich model objects. + * <p> + * This map will deep copy not only the model object map, but also each + * clonable member in the map. + * + * @author bratseth + */ +public class ModelObjectMap extends PropertyMap { + + /** Returns true if the class of the value is not acceptable as a query profile value */ + @Override + protected boolean shouldSet(CompoundName name,Object value) { + if (value==null) return true; + return FieldType.fromClass(value.getClass())==null; + } + +} diff --git a/container-search/src/main/java/com/yahoo/search/query/profile/OverridableQueryProfile.java b/container-search/src/main/java/com/yahoo/search/query/profile/OverridableQueryProfile.java new file mode 100644 index 00000000000..5d0bffa1ea8 --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/profile/OverridableQueryProfile.java @@ -0,0 +1,51 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.query.profile; + +import com.yahoo.component.ComponentId; +import com.yahoo.processing.request.CompoundName; +import com.yahoo.search.query.profile.types.QueryProfileType; + +/** + * A regular query profile which knows it is storing overrides (not configured profiles) + * and that implements override legality checking. + * + * @author bratseth + */ +public class OverridableQueryProfile extends QueryProfile { + + private static final String simpleClassName = OverridableQueryProfile.class.getSimpleName(); + + /** Creates an unbacked overridable query profile */ + protected OverridableQueryProfile() { + super(ComponentId.createAnonymousComponentId(simpleClassName)); + } + + @Override + protected Object checkAndConvertAssignment(String localName, Object inputValue, QueryProfileRegistry registry) { + Object value=super.checkAndConvertAssignment(localName, inputValue, registry); + if (value!=null && value.getClass() == QueryProfile.class) { // We are assigning a query profile - make it overridable + return new BackedOverridableQueryProfile((QueryProfile)value); + } + return value; + } + + @Override + protected QueryProfile createSubProfile(String name,DimensionBinding binding) { + return new OverridableQueryProfile(); // Nothing is set in this branch, so nothing to override, but need override checking + } + + /** Returns a clone of this which can be independently overridden */ + @Override + public OverridableQueryProfile clone() { + if (isFrozen()) return this; + OverridableQueryProfile clone=(OverridableQueryProfile)super.clone(); + clone.initId(ComponentId.createAnonymousComponentId(simpleClassName)); + return clone; + } + + @Override + public String toString() { + return "an overridable query profile with no backing"; + } + +} diff --git a/container-search/src/main/java/com/yahoo/search/query/profile/PrefixQueryProfileVisitor.java b/container-search/src/main/java/com/yahoo/search/query/profile/PrefixQueryProfileVisitor.java new file mode 100644 index 00000000000..2a22d58d8b7 --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/profile/PrefixQueryProfileVisitor.java @@ -0,0 +1,63 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.query.profile; + +import com.yahoo.processing.request.CompoundName; + +/** + * A query profile visitor which keeps track of name prefixes and can skip values outside a given prefix + * + * @author <a href="mailto:bratseth@yahoo-inc.com">Jon Bratseth</a> + */ +abstract class PrefixQueryProfileVisitor extends QueryProfileVisitor { + + /** Only call onValue/onQueryProfile for nodes having this prefix */ + private final CompoundName prefix; + + /** The current prefix, relative to prefix. */ + protected CompoundName currentPrefix = CompoundName.empty; + + private int prefixComponentIndex = -1; + + public PrefixQueryProfileVisitor(CompoundName prefix) { + if (prefix == null) + prefix = CompoundName.empty; + this.prefix = prefix; + } + + @Override + public final void onQueryProfile(QueryProfile profile, DimensionBinding binding, QueryProfile owner) { + if (prefixComponentIndex < prefix.size()) return; // Not in the prefix yet + onQueryProfileInsidePrefix(profile, binding, owner); + } + + protected abstract void onQueryProfileInsidePrefix(QueryProfile profile, DimensionBinding binding, QueryProfile owner); + + @Override + public final boolean enter(String name) { + prefixComponentIndex++; + if (prefixComponentIndex-1 < prefix.size()) return true; // we're in the given prefix, which should not be included in the name + currentPrefix = currentPrefix.append(name); + return true; + } + + @Override + public final void leave(String name) { + prefixComponentIndex--; + if (prefixComponentIndex < prefix.size()) return; // we're in the given prefix, which should not be included in the name + if ( ! name.isEmpty() && ! currentPrefix.isEmpty()) + currentPrefix = currentPrefix.first(currentPrefix.size() - 1); + } + + /** + * Returns the correct prefix component if we are still going down the prefix path, + * or null to get all if we are inside the prefix + */ + @Override + public String getLocalKey() { + if (prefixComponentIndex < prefix.size()) + return prefix.get(prefixComponentIndex); + else + return null; + } + +} diff --git a/container-search/src/main/java/com/yahoo/search/query/profile/QueryProfile.java b/container-search/src/main/java/com/yahoo/search/query/profile/QueryProfile.java new file mode 100644 index 00000000000..55210717305 --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/profile/QueryProfile.java @@ -0,0 +1,835 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.query.profile; + +import com.google.common.collect.ImmutableList; +import com.yahoo.component.ComponentId; +import com.yahoo.component.provider.FreezableSimpleComponent; +import com.yahoo.processing.request.CompoundName; +import com.yahoo.processing.request.Properties; +import com.yahoo.search.query.profile.compiled.CompiledQueryProfile; +import com.yahoo.search.query.profile.compiled.CompiledQueryProfileRegistry; +import com.yahoo.search.query.profile.types.FieldDescription; +import com.yahoo.search.query.profile.types.QueryProfileFieldType; +import com.yahoo.search.query.profile.types.QueryProfileType; + +import java.util.*; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * A query profile is a data container with an id and a class (type). More precisely, it contains + * <ul> + * <li>An id, on the form name:version, where the version is optional, and follows the same rules as for other search container components. + * <li>A class id referring to the class defining this profile (see Query Profile Classes below) + * <li>A (possibly empty) list of ids of inherited query profiles + * <li>A (possibly empty) list of declarative predicates over search request parameters which defines when this query profile is applicable (see Query Profile Selection below) + * <li>The data content, which consists of + * <ul> + * <li>named values + * <li>named references to other profiles + * </ul> + * </ul> + * + * This serves the purpose of an intermediate format between configuration and runtime structures - the runtime + * structure used is QueryProfileProperties. + * + * @author bratseth + */ +public class QueryProfile extends FreezableSimpleComponent implements Cloneable { + + /** Defines the permissible content of this, or null if any content is permissible */ + private QueryProfileType type=null; + + /** The value at this query profile - allows non-fields to have values, e.g a=value1, a.b=value2 */ + private Object value=null; + + /** The variants of this, or null if none */ + private QueryProfileVariants variants=null; + + /** The resolved variant dimensions of this, or null if none or not resolved yet (is resolved at freeze) */ + private List<String> resolvedDimensions=null; + + /** The query profiles inherited by this, or null if none */ + private List<QueryProfile> inherited=null; + + /** The content of this profile. The values may be primitives, substitutable strings or other query profiles */ + private CopyOnWriteContent content=new CopyOnWriteContent(); + + /** + * Field override settings: fieldName→OverrideValue. These overrides the override + * setting in the type (if any) of this field). If there are no query profile level settings, this is null. + */ + private Map<String,Boolean> overridable=null; + + /** + * Creates a new query profile from an id. + * The query profile can be modified freely (but not accessed) until it is {@link #freeze frozen}. + * At that point it becomes readable but unmodifiable, which it stays until it goes out of reference. + */ + public QueryProfile(ComponentId id) { + super(id); + if ( ! id.isAnonymous()) + validateName(id.getName()); + } + + /** Convenience shorthand for new QueryProfile(new ComponentId(idString)) */ + public QueryProfile(String idString) { + this(new ComponentId(idString)); + } + + // ----------------- Public API ------------------------------------------------------------------------------- + + // ----------------- Setters and getters + + /** Returns the type of this or null if it has no type */ + public QueryProfileType getType() { return type; } + + /** Sets the type of this, or set to null to not use any type checking in this profile */ + public void setType(QueryProfileType type) { this.type=type; } + + /** Returns the virtual variants of this, or null if none */ + public QueryProfileVariants getVariants() { return variants; } + + /** + * Returns the list of profiles inherited by this. + * Note that order matters for inherited profiles - variables are resolved depth first in the order found in + * the inherited list. This always returns an unmodifiable list - use addInherited to add. + */ + public List<QueryProfile> inherited() { + if (isFrozen()) return inherited; // Frozen profiles always have an unmodifiable, non-null list + if (inherited==null) return Collections.emptyList(); + return Collections.unmodifiableList(inherited); + } + + /** Adds a profile to the end of the inherited list of this. Throws an exception if this is frozen. */ + public void addInherited(QueryProfile profile) { + addInherited(profile,(DimensionValues)null); + } + + public final void addInherited(QueryProfile profile,String[] dimensionValues) { + addInherited(profile,DimensionValues.createFrom(dimensionValues)); + } + + /** Adds a profile to the end of the inherited list of this for the given variant. Throws an exception if this is frozen. */ + public void addInherited(QueryProfile profile, DimensionValues dimensionValues) { + ensureNotFrozen(); + + DimensionBinding dimensionBinding=DimensionBinding.createFrom(getDimensions(),dimensionValues); + if (dimensionBinding.isNull()) { + if (inherited==null) + inherited=new ArrayList<>(); + inherited.add(profile); + } + else { + if (variants==null) + variants=new QueryProfileVariants(dimensionBinding.getDimensions(), this); + variants.inherit(profile,dimensionBinding.getValues()); + } + } + + /** + * Returns the content fields declared in this (i.e not including those inherited) as a read-only map. + * @throws IllegalStateException if this is frozen + */ + public Map<String,Object> declaredContent() { + ensureNotFrozen(); + return content.unmodifiableMap(); + } + + /** + * Returns if the given field is declared explicitly as overridable or not in this or any <i>nested</i> profiles + * (i.e not including overridable settings <i>inherited</i> and from <i>types</i>). + * + * @param name the (possibly dotted) field name to return + * @param context the context in which the name is resolved, or null if none + * @return true/false if this is declared overridable/not overridable in this instance, null if it is not + * given any value is <i>this</i> profile instance + * @throws IllegalStateException if this is frozen + */ + public Boolean isDeclaredOverridable(String name, Map<String,String> context) { + return isDeclaredOverridable(new CompoundName(name),DimensionBinding.createFrom(getDimensions(),context)); + } + + /** Sets the dimensions over which this may vary. Note: This will erase any currently defined variants */ + public void setDimensions(String[] dimensions) { + ensureNotFrozen(); + variants=new QueryProfileVariants(dimensions, this); + } + + /** Returns the value set at this node, to allow non-leafs to have values. Returns null if none. */ + public Object getValue() { return value; } + + public void setValue(Object value) { + ensureNotFrozen(); + this.value=value; + } + + /** Returns the variant dimensions to be used in this - an unmodifiable list of dimension names */ + public List<String> getDimensions() { + if (isFrozen()) return resolvedDimensions; + if (variants!=null) return variants.getDimensions(); + if (inherited==null) return null; + for (QueryProfile inheritedProfile : inherited) { + List<String> inheritedDimensions=inheritedProfile.getDimensions(); + if (inheritedDimensions!=null) return inheritedDimensions; + } + return null; + } + + // ----------------- Query profile facade API + + /** + * Sets the overridability of a field in this profile, + * this overrides the corresponding setting in the type (if any) + */ + public final void setOverridable(String fieldName, boolean overridable, Map<String,String> context) { + setOverridable(new CompoundName(fieldName), overridable,DimensionBinding.createFrom(getDimensions(), context)); + } + + /** + * Return all objects that start with the given prefix path using no context. Use "" to list all. + * <p> + * For example, if {a.d => "a.d-value" ,a.e => "a.e-value", b.d => "b.d-value", then calling listValues("a") + * will return {"d" => "a.d-value","e" => "a.e-value"} + */ + public final Map<String, Object> listValues(String prefix) { return listValues(new CompoundName(prefix)); } + + /** + * Return all objects that start with the given prefix path using no context. Use "" to list all. + * <p> + * For example, if {a.d => "a.d-value" ,a.e => "a.e-value", b.d => "b.d-value", then calling listValues("a") + * will return {"d" => "a.d-value","e" => "a.e-value"} + */ + public final Map<String, Object> listValues(CompoundName prefix) { return listValues(prefix, null); } + + /** + * Return all objects that start with the given prefix path. Use "" to list all. + * <p> + * For example, if {a.d => "a.d-value" ,a.e => "a.e-value", b.d => "b.d-value", then calling listValues("a") + * will return {"d" => "a.d-value","e" => "a.e-value"} + */ + public final Map<String, Object> listValues(String prefix, Map<String,String> context) { + return listValues(new CompoundName(prefix), context); + } + + /** + * Return all objects that start with the given prefix path. Use "" to list all. + * <p> + * For example, if {a.d => "a.d-value" ,a.e => "a.e-value", b.d => "b.d-value", then calling listValues("a") + * will return {"d" => "a.d-value","e" => "a.e-value"} + */ + public final Map<String, Object> listValues(CompoundName prefix, Map<String,String> context) { + return listValues(prefix, context, null); + } + + /** + * Adds all objects that start with the given path prefix to the given value map. Use "" to list all. + * <p> + * For example, if {a.d => "a.d-value" ,a.e => "a.e-value", b.d => "b.d-value", then calling listValues("a") + * will return {"d" => "a.d-value","e" => "a.e-value"} + */ + public Map<String, Object> listValues(CompoundName prefix, Map<String, String> context, Properties substitution) { + DimensionBinding dimensionBinding=DimensionBinding.createFrom(getDimensions(),context); + + AllValuesQueryProfileVisitor visitor=new AllValuesQueryProfileVisitor(prefix); + accept(visitor,dimensionBinding, null); + Map<String,Object> values=visitor.getResult(); + + if (substitution==null) return values; + for (Map.Entry<String,Object> entry : values.entrySet()) { + if (entry.getValue().getClass()==String.class) continue; // Shortcut + if (entry.getValue() instanceof SubstituteString) + entry.setValue(((SubstituteString)entry.getValue()).substitute(context,substitution)); + } + return values; + } + + /** + * Lists types reachable from this, indexed by the prefix having that type. + * If this is itself typed, this' type will be included with an empty prefix + */ + Map<CompoundName, QueryProfileType> listTypes(CompoundName prefix, Map<String, String> context) { + DimensionBinding dimensionBinding = DimensionBinding.createFrom(getDimensions(), context); + AllTypesQueryProfileVisitor visitor = new AllTypesQueryProfileVisitor(prefix); + accept(visitor, dimensionBinding, null); + return visitor.getResult(); + } + + /** + * Lists references reachable from this. + */ + Set<CompoundName> listReferences(CompoundName prefix, Map<String, String> context) { + DimensionBinding dimensionBinding=DimensionBinding.createFrom(getDimensions(),context); + AllReferencesQueryProfileVisitor visitor=new AllReferencesQueryProfileVisitor(prefix); + accept(visitor,dimensionBinding,null); + return visitor.getResult(); + } + + /** + * Lists every entry (value or reference) reachable from this which is not overridable + */ + Set<CompoundName> listUnoverridable(CompoundName prefix, Map<String, String> context) { + DimensionBinding dimensionBinding = DimensionBinding.createFrom(getDimensions(),context); + AllUnoverridableQueryProfileVisitor visitor = new AllUnoverridableQueryProfileVisitor(prefix); + accept(visitor, dimensionBinding, null); + return visitor.getResult(); + } + + /** + * Returns a value from this query profile by resolving the given name: + * <ul> + * <li>The name up to the first dot is the value looked up in the value of this profile + * <li>The rest of the name (if any) is used as the name to look up in the referenced query profile + * </ul> + * + * If this name does not resolve <i>completely</i> into a value in this or any inherited profile, null is returned. + */ + public final Object get(String name) { return get(name,(Map<String,String>)null); } + + /** Returns a value from this using the given property context for resolution and using this for substitution */ + public final Object get(String name, Map<String,String> context) { + return get(name,context,null); + } + + /** Returns a value from this using the given dimensions for resolution */ + public final Object get(String name, String[] dimensionValues) { + return get(name,dimensionValues,null); + } + + public final Object get(String name, String[] dimensionValues, Properties substitution) { + return get(name,DimensionValues.createFrom(dimensionValues),substitution); + } + + /** Returns a value from this using the given dimensions for resolution */ + public final Object get(String name, DimensionValues dimensionValues, Properties substitution) { + return get(name,DimensionBinding.createFrom(getDimensions(),dimensionValues),substitution); + } + + public final Object get(String name, Map<String,String> context, Properties substitution) { + return get(name,DimensionBinding.createFrom(getDimensions(),context),substitution); + } + + public final Object get(CompoundName name, Map<String,String> context, Properties substitution) { + return get(name,DimensionBinding.createFrom(getDimensions(),context),substitution); + } + + final Object get(String name, DimensionBinding binding,Properties substitution) { + return get(new CompoundName(name),binding,substitution); + } + + final Object get(CompoundName name, DimensionBinding binding, Properties substitution) { + Object node=get(name,binding); + if (node!=null && node.getClass()==String.class) return node; // Shortcut + if (node instanceof SubstituteString) return ((SubstituteString)node).substitute(binding.getContext(),substitution); + return node; + } + + final Object get(CompoundName name,DimensionBinding dimensionBinding) { + return lookup(name,false,dimensionBinding); + } + + /** + * Returns the node at the position prescribed by the given name (without doing substitutions) - + * a primitive value, a substitutable string, a query profile, or null if not found. + */ + public final Object lookup(String name, Map<String,String> context) { + return lookup(new CompoundName(name),true,DimensionBinding.createFrom(getDimensions(),context)); + } + + /** Sets a value in this or any nested profile using null as context */ + public final void set(String name, Object value, QueryProfileRegistry registry) { + set(name,value,(Map<String,String>)null, registry); + } + + /** + * Sets a value in this or any nested profile. Any missing structure needed to set this will be created. + * If this value is already set, this will overwrite the previous value. + * + * @param name the name of the field, possibly a dotted name which will cause setting of a variable in a subprofile + * @param value the value to assign to the name, a primitive wrapper, string or a query profile + * @param context the context used to resolve where this value should be set, or null if none + * @throws IllegalArgumentException if the given name is illegal given the types of this or any nested query profile + * @throws IllegalStateException if this query profile is frozen + */ + public final void set(CompoundName name,Object value,Map<String,String> context, QueryProfileRegistry registry) { + set(name, value, DimensionBinding.createFrom(getDimensions(), context), registry); + } + + public final void set(String name,Object value,Map<String,String> context, QueryProfileRegistry registry) { + set(new CompoundName(name), value, DimensionBinding.createFrom(getDimensions(), context), registry); + } + + public final void set(String name,Object value,String[] dimensionValues, QueryProfileRegistry registry) { + set(name,value,DimensionValues.createFrom(dimensionValues), registry); + } + + /** + * Sets a value in this or any nested profile. Any missing structure needed to set this will be created. + * If this value is already set, this will overwrite the previous value. + * + * @param name the name of the field, possibly a dotted name which will cause setting of a variable in a subprofile + * @param value the value to assign to the name, a primitive wrapper, string or a query profile + * @param dimensionValues the dimension values - will be matched by order to the dimensions set in this - if this is + * shorter or longer than the number of dimensions it will be adjusted as needed + * @param registry the registry used to resolve query profile references. If null is passed query profile references + * will cause an exception + * @throws IllegalArgumentException if the given name is illegal given the types of this or any nested query profile + * @throws IllegalStateException if this query profile is frozen + */ + public final void set(String name,Object value,DimensionValues dimensionValues, QueryProfileRegistry registry) { + set(new CompoundName(name), value, DimensionBinding.createFrom(getDimensions(), dimensionValues), registry); + } + + // ----------------- Misc + + public boolean isExplicit() { + return !getId().isAnonymous(); + } + + /** + * Switches this from write-only to read-only mode. + * This profile can never be modified again after this method returns. + * Calling this on an already frozen profile has no effect. + * <p> + * Calling this will also freeze any profiles inherited and referenced by this. + */ + // TODO: Remove/simplify as query profiles are not used at query time + public synchronized void freeze() { + if (isFrozen()) return; + + resolvedDimensions=getDimensions(); + + if (variants !=null) + variants.freeze(); + + if (inherited!=null) { + for (QueryProfile inheritedProfile : inherited) + inheritedProfile.freeze(); + } + + content.freeze(); + + inherited= inherited==null ? ImmutableList.of() : ImmutableList.copyOf(inherited); + + super.freeze(); + } + + @Override + public String toString() { + return "query profile '" + getId() + "'" + (type!=null ? " of type '" + type.getId() + "'" : ""); + } + + /** + * Returns a clone of this. The clone will not be frozen and will contain copied inherited and content collections + * pointing to the same values as this. + */ + @Override + public QueryProfile clone() { + if (isFrozen()) return this; + QueryProfile clone=(QueryProfile)super.clone(); + if (variants !=null) + clone.variants = variants.clone(); + if (inherited!=null) + clone.inherited=new ArrayList<>(inherited); + + if (this.content!=null) + clone.content=content.clone(); + + return clone; + } + + /** + * Clones a value of a type which may appear in a query profile if cloning is necessary (i.e if it is + * not immutable). Returns the input type otherwise. + */ + static Object cloneIfNecessary(Object object) { + if (object instanceof QueryProfile) return ((QueryProfile)object).clone(); + return object; // Other types are immutable + } + + /** Throws IllegalArgumentException if the given string is not a valid query profile name */ + public static void validateName(String name) { + Matcher nameMatcher=namePattern.matcher(name); + if ( ! nameMatcher.matches()) + throw new IllegalArgumentException("Illegal name '" + name + "'"); + } + + // ----------------- For subclass use -------------------------------------------------------------------- + + /** Override this to intercept all writes to this profile (or any nested profiles) */ + protected void set(CompoundName name, Object value, DimensionBinding binding, QueryProfileRegistry registry) { + try { + setNode(name, value, null, binding, registry); + } + catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Could not set '" + name + "' to '" + value + "'",e); + } + } + + /** Returns this value, or its corresponding substitution string if it contains substitutions */ + protected Object convertToSubstitutionString(Object value) { + if (value==null) return value; + if (value.getClass()!=String.class) return value; + SubstituteString substituteString=SubstituteString.create((String)value); + if (substituteString==null) return value; + return substituteString; + } + + /** Returns the field description of this field, or null if it is not typed */ + protected FieldDescription getFieldDescription(CompoundName name, DimensionBinding binding) { + FieldDescriptionQueryProfileVisitor visitor=new FieldDescriptionQueryProfileVisitor(name.asList()); + accept(visitor, binding,null); + return visitor.result(); + } + + /** + * Returns true if this value is definitely overridable in this (set and not unoverridable), + * false if it is declared unoverridable (in instance or type), and null if this profile has no + * opinion on the matter because the value is not set in this. + */ + Boolean isLocalOverridable(String localName,DimensionBinding binding) { + if (localLookup(localName, binding)==null) return null; // Not set + Boolean isLocalInstanceOverridable=isLocalInstanceOverridable(localName); + if (isLocalInstanceOverridable!=null) + return isLocalInstanceOverridable.booleanValue(); + if (type!=null) return type.isOverridable(localName); + return true; + } + + protected Boolean isLocalInstanceOverridable(String localName) { + if (overridable==null) return null; + return overridable.get(localName); + } + + protected Object lookup(CompoundName name,boolean allowQueryProfileResult, DimensionBinding dimensionBinding) { + SingleValueQueryProfileVisitor visitor=new SingleValueQueryProfileVisitor(name.asList(),allowQueryProfileResult); + accept(visitor,dimensionBinding,null); + return visitor.getResult(); + } + + protected final void accept(QueryProfileVisitor visitor,DimensionBinding dimensionBinding, QueryProfile owner) { + acceptAndEnter("", visitor, dimensionBinding, owner); + } + + void acceptAndEnter(String key, QueryProfileVisitor visitor,DimensionBinding dimensionBinding, QueryProfile owner) { + boolean allowContent=visitor.enter(key); + accept(allowContent, visitor, dimensionBinding, owner); + if (allowContent) + visitor.leave(key); + } + + /** + * Visit the profiles and values referenced from this in order of decreasing precedence + * + * @param allowContent whether content in this should be visited + * @param visitor the visitor + * @param dimensionBinding the dimension binding to use + */ + final void accept(boolean allowContent,QueryProfileVisitor visitor, DimensionBinding dimensionBinding, QueryProfile owner) { + visitor.onQueryProfile(this, dimensionBinding, owner); + if (visitor.isDone()) return; + + visitVariants(allowContent,visitor,dimensionBinding); + if (visitor.isDone()) return; + + if (allowContent) { + visitContent(visitor,dimensionBinding); + if (visitor.isDone()) return; + } + + if (visitor.visitInherited()) + visitInherited(allowContent, visitor, dimensionBinding, owner); + } + + protected void visitVariants(boolean allowContent,QueryProfileVisitor visitor,DimensionBinding dimensionBinding) { + if (getVariants()!=null) + getVariants().accept(allowContent, getType(), visitor, dimensionBinding); + } + + protected void visitInherited(boolean allowContent,QueryProfileVisitor visitor,DimensionBinding dimensionBinding, QueryProfile owner) { + if (inherited==null) return; + for (QueryProfile inheritedProfile : inherited) { + inheritedProfile.accept(allowContent,visitor,dimensionBinding.createFor(inheritedProfile.getDimensions()), owner); + if (visitor.isDone()) return; + } + } + + private void visitContent(QueryProfileVisitor visitor,DimensionBinding dimensionBinding) { + String contentKey=visitor.getLocalKey(); + + // Visit this' content + if (contentKey!=null) { // Get only the content of the current key + if (type!=null) + contentKey=type.unalias(contentKey); + visitor.acceptValue(contentKey, getContent(contentKey), dimensionBinding, this); + } + else { // get all content in this + for (Map.Entry<String,Object> entry : getContent().entrySet()) { + visitor.acceptValue(entry.getKey(), entry.getValue(), dimensionBinding, this); + if (visitor.isDone()) return; + } + } + } + + /** Returns a value from the content of this, or null if not present */ + protected Object getContent(String key) { + return content.get(key); + } + + /** Returns all the content from this as an unmodifiable map */ + protected Map<String,Object> getContent() { + return content.unmodifiableMap(); + } + + /** Sets the value of a node in <i>this</i> profile - the local name given must not be nested (contain dots) */ + protected QueryProfile setLocalNode(String localName, Object value,QueryProfileType parentType, + DimensionBinding dimensionBinding, QueryProfileRegistry registry) { + if (parentType!=null && type==null && !isFrozen()) + type=parentType; + + value=checkAndConvertAssignment(localName, value, registry); + localPut(localName,value,dimensionBinding); + return this; + } + + /** + * Combines an existing and a new value for a query property key. + * Return the new object to add to the state of the owning profile (/variant), or null if no new value needs to + * be added (usually because the new value was added to the existing). + */ + static Object combineValues(Object newValue, Object existingValue) { + if (newValue instanceof QueryProfile) { + QueryProfile newProfile=(QueryProfile)newValue; + if ( existingValue==null || ! (existingValue instanceof QueryProfile)) { + if (!isModifiable(newProfile)) + newProfile=new BackedOverridableQueryProfile(newProfile); // Make the query profile reference overridable + newProfile.value=existingValue; + return newProfile; + } + + // if both are profiles: + return combineProfiles(newProfile,(QueryProfile)existingValue); + } + else { + if (existingValue instanceof QueryProfile) { // we need to set a non-leaf value on a query profile + QueryProfile existingProfile=(QueryProfile)existingValue; + if (isModifiable(existingProfile)) { + existingProfile.setValue(newValue); + return null; + } + else { + QueryProfile existingOverridable = new BackedOverridableQueryProfile((QueryProfile)existingValue); + existingOverridable.setValue(newValue); + return existingOverridable; + } + } + else { + return newValue; + } + } + } + + private static QueryProfile combineProfiles(QueryProfile newProfile,QueryProfile existingProfile) { + QueryProfile returnValue=null; + QueryProfile existingModifiable; + + // Ensure the existing profile is modifiable + if (existingProfile.getClass()==QueryProfile.class) { + existingModifiable = new BackedOverridableQueryProfile(existingProfile); + returnValue=existingModifiable; + } + else { // is an overridable wrapper + existingModifiable=existingProfile; // May be used as-is + } + + // Make the existing profile inherit the new one + if (existingModifiable instanceof BackedOverridableQueryProfile) + ((BackedOverridableQueryProfile)existingModifiable).addInheritedHere(newProfile); + else + existingModifiable.addInherited(newProfile); + + // Remove content from the existing which the new one does not allow overrides of + if (existingModifiable.content!=null) { + for (String key : existingModifiable.content.unmodifiableMap().keySet()) { + if ( ! newProfile.isLocalOverridable(key, null)) { + existingModifiable.content.remove(key); + } + } + } + + return returnValue; + } + + /** Returns whether the given profile may be modified from this profile */ + private static boolean isModifiable(QueryProfile profile) { + if (profile.isFrozen()) return false; + if ( ! profile.isExplicit()) return true; // Implicitly defined from this - ok to modify then + if (! (profile instanceof BackedOverridableQueryProfile)) return false; + return true; + } + + /** + * Converts to the type of the receiving field, if possible and necessary. + * + * @return the value to be assigned: the original or a converted value + * @throws IllegalArgumentException if the assignment is illegal + */ + protected Object checkAndConvertAssignment(String localName, Object value, QueryProfileRegistry registry) { + if (type==null) return value; // no type checking + + FieldDescription fieldDescription=type.getField(localName); + if (fieldDescription==null) { + if (type.isStrict()) + throw new IllegalArgumentException("'" + localName + "' is not declared in " + type + ", and the type is strict"); + return value; + } + + if (registry == null && (fieldDescription.getType() instanceof QueryProfileFieldType)) + throw new IllegalArgumentException("A registry was not passed: Query profile references is not supported"); + Object convertedValue = fieldDescription.getType().convertFrom(value, registry); + if (convertedValue == null) + throw new IllegalArgumentException("'" + value + "' is not a " + fieldDescription.getType().toInstanceDescription()); + return convertedValue; + } + + /** + * Looks up all inherited profiles and adds any that matches this name. + * This default implementation returns an empty profile. + */ + protected QueryProfile createSubProfile(String name,DimensionBinding dimensionBinding) { + QueryProfile queryProfile = new QueryProfile(ComponentId.createAnonymousComponentId(name)); + return queryProfile; + } + + /** Do a variant-aware content lookup in this */ + protected Object localLookup(String name, DimensionBinding dimensionBinding) { + Object node=null; + if ( variants!=null && !dimensionBinding.isNull()) + node=variants.get(name,type,true,dimensionBinding); + if (node==null) + node=content==null ? null : content.get(name); + return node; + } + + // ----------------- Private ---------------------------------------------------------------------------------- + + private Boolean isDeclaredOverridable(CompoundName name,DimensionBinding dimensionBinding) { + QueryProfile parent= lookupParentExact(name, true, dimensionBinding); + if (parent.overridable==null) return null; + return parent.overridable.get(name.last()); + } + + /** + * Sets the overridability of a field in this profile, + * this overrides the corresponding setting in the type (if any) + */ + private void setOverridable(CompoundName fieldName,boolean overridable,DimensionBinding dimensionBinding) { + QueryProfile parent= lookupParentExact(fieldName, true, dimensionBinding); + if (parent.overridable==null) + parent.overridable=new HashMap<>(); + parent.overridable.put(fieldName.last(),overridable); + } + + /** Sets a value to a (possibly non-local) node. The parent query profile holding the value is returned */ + private void setNode(CompoundName name, Object value, QueryProfileType parentType, + DimensionBinding dimensionBinding, QueryProfileRegistry registry) { + ensureNotFrozen(); + if (name.isCompound()) { + QueryProfile parent= getQueryProfileExact(name.first(), true, dimensionBinding); + parent.setNode(name.rest(), value,parentType, dimensionBinding.createFor(parent.getDimensions()), registry); + } + else { + setLocalNode(name.toString(), value,parentType, dimensionBinding, registry); + } + } + + /** + * Looks up and, if necessary, creates, the query profile which should hold the given local name portion of the + * given name. If the name contains no dots, this is returned. + * + * @param name the name of the variable to lookup the parent of + * @param create whether or not to create the parent if it is not present + * @return the parent, or null if not present and created is false + */ + private QueryProfile lookupParentExact(CompoundName name, boolean create, DimensionBinding dimensionBinding) { + CompoundName rest=name.rest(); + if (rest.isEmpty()) return this; + + QueryProfile topmostParent= getQueryProfileExact(name.first(), create, dimensionBinding); + if (topmostParent==null) return null; + return topmostParent.lookupParentExact(rest, create, dimensionBinding.createFor(topmostParent.getDimensions())); + } + + /** + * Returns a query profile from this by name + * + * @param localName the local name of the profile in this, this is never a compound + * @param create whether the profile should be created if missing + * @return the created profile, or null if not present, and create is false + */ + private QueryProfile getQueryProfileExact(String localName, boolean create, DimensionBinding dimensionBinding) { + Object node=localExactLookup(localName, dimensionBinding); + if (node!=null && node instanceof QueryProfile) { + return (QueryProfile)node; + } + if (!create) return null; + + QueryProfile queryProfile=createSubProfile(localName,dimensionBinding); + if (type!=null) { + Class<?> legalClass=type.getValueClass(localName); + if (legalClass==null || ! legalClass.isInstance(queryProfile)) + throw new RuntimeException("'" + localName + "' is not a legal query profile reference name in " + this); + queryProfile.setType(type.getType(localName)); + } + localPut(localName,queryProfile,dimensionBinding); + return queryProfile; + } + + /** Do a variant-aware content lookup in this - without looking in any wrapped content. But by matching variant bindings exactly only */ + private Object localExactLookup(String name,DimensionBinding dimensionBinding) { + if (dimensionBinding.isNull()) return content==null ? null : content.get(name); + if (variants==null) return null; + QueryProfileVariant variant=variants.getVariant(dimensionBinding.getValues(),false); + if (variant==null) return null; + return variant.values().get(name); + } + + /** Sets a value directly in this query profile (unless frozen) */ + private void localPut(String localName,Object value,DimensionBinding dimensionBinding) { + ensureNotFrozen(); + + if (type!=null) + localName=type.unalias(localName); + + validateName(localName); + value=convertToSubstitutionString(value); + + if (dimensionBinding.isNull()) { + Object combinedValue; + if (value instanceof QueryProfile) + combinedValue = combineValues(value,content==null ? null : content.get(localName)); + else + combinedValue = combineValues(value, localLookup(localName, dimensionBinding)); + + if (combinedValue!=null) + content.put(localName,combinedValue); + } + else { + if (variants==null) + variants=new QueryProfileVariants(dimensionBinding.getDimensions(), this); + variants.set(localName,dimensionBinding.getValues(),value); + } + } + + private static final Pattern namePattern=Pattern.compile("[$a-zA-Z_/][-$a-zA-Z0-9_/()]*"); + + /** + * Returns a compiled version of this which produces faster lookup times + * + * @param registry the registry this will be added to by the caller, or null if none + */ + public CompiledQueryProfile compile(CompiledQueryProfileRegistry registry) { + return QueryProfileCompiler.compile(this, registry); + } + +} diff --git a/container-search/src/main/java/com/yahoo/search/query/profile/QueryProfileCompiler.java b/container-search/src/main/java/com/yahoo/search/query/profile/QueryProfileCompiler.java new file mode 100644 index 00000000000..795c7655dfb --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/profile/QueryProfileCompiler.java @@ -0,0 +1,140 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.query.profile; + +import com.yahoo.processing.request.CompoundName; +import com.yahoo.search.query.profile.compiled.CompiledQueryProfile; +import com.yahoo.search.query.profile.compiled.CompiledQueryProfileRegistry; +import com.yahoo.search.query.profile.compiled.DimensionalMap; +import com.yahoo.search.query.profile.types.QueryProfileType; + +import java.util.Collection; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Compile a set of query profiles into compiled profiles. + * + * @author bratseth + */ +public class QueryProfileCompiler { + + private static final Logger log = Logger.getLogger(QueryProfileCompiler.class.getName()); + + public static CompiledQueryProfileRegistry compile(QueryProfileRegistry input) { + CompiledQueryProfileRegistry output = new CompiledQueryProfileRegistry(input.getTypeRegistry()); + for (QueryProfile inputProfile : input.allComponents()) { + output.register(compile(inputProfile, output)); + } + return output; + } + + public static CompiledQueryProfile compile(QueryProfile in, CompiledQueryProfileRegistry registry) { + DimensionalMap.Builder<CompoundName, Object> values = new DimensionalMap.Builder<>(); + DimensionalMap.Builder<CompoundName, QueryProfileType> types = new DimensionalMap.Builder<>(); + DimensionalMap.Builder<CompoundName, Object> references = new DimensionalMap.Builder<>(); + DimensionalMap.Builder<CompoundName, Object> unoverridables = new DimensionalMap.Builder<>(); + + // Resolve values for each existing variant and combine into a single data structure + Set<DimensionBindingForPath> variants = new HashSet<>(); + collectVariants(CompoundName.empty, in, DimensionBinding.nullBinding, variants); + variants.add(new DimensionBindingForPath(DimensionBinding.nullBinding, CompoundName.empty)); // if this contains no variants + if (log.isLoggable(Level.FINE)) + log.fine("Compiling " + in.toString() + " having " + variants.size() + " variants"); + int i = 0; + for (DimensionBindingForPath variant : variants) { + if (log.isLoggable(Level.FINER)) + log.finer(" Compiling variant " + i++ + ": " + variant); + for (Map.Entry<String, Object> entry : in.listValues(variant.path(), variant.binding().getContext(), null).entrySet()) + values.put(variant.path().append(entry.getKey()), variant.binding(), entry.getValue()); + for (Map.Entry<CompoundName, QueryProfileType> entry : in.listTypes(variant.path(), variant.binding().getContext()).entrySet()) + types.put(variant.path().append(entry.getKey()), variant.binding(), entry.getValue()); + for (CompoundName reference : in.listReferences(variant.path(), variant.binding().getContext())) + references.put(variant.path().append(reference), variant.binding(), Boolean.TRUE); // Used as a set; value is ignored + for (CompoundName name : in.listUnoverridable(variant.path(), variant.binding().getContext())) + unoverridables.put(variant.path().append(name), variant.binding(), Boolean.TRUE); // Used as a set; value is ignored + } + + return new CompiledQueryProfile(in.getId(), in.getType(), + values.build(), types.build(), references.build(), unoverridables.build(), + registry); + } + + /** + * Returns all the unique combinations of dimension values which have values set reachable from this profile. + * + * @param profile the profile we are collecting the variants of + * @param currentVariant the variant we must have to arrive at this point in the query profile graph + * @param allVariants the set of all variants accumulated so far + */ + private static void collectVariants(CompoundName path, QueryProfile profile, DimensionBinding currentVariant, Set<DimensionBindingForPath> allVariants) { + for (QueryProfile inheritedProfile : profile.inherited()) + collectVariants(path, inheritedProfile, currentVariant, allVariants); + + collectVariantsFromValues(path, profile.getContent(), currentVariant, allVariants); + + collectVariantsInThis(path, profile, currentVariant, allVariants); + if (profile instanceof BackedOverridableQueryProfile) + collectVariantsInThis(path, ((BackedOverridableQueryProfile) profile).getBacking(), currentVariant, allVariants); + } + + private static void collectVariantsInThis(CompoundName path, QueryProfile profile, DimensionBinding currentVariant, Set<DimensionBindingForPath> allVariants) { + QueryProfileVariants profileVariants = profile.getVariants(); + if (profileVariants != null) { + for (QueryProfileVariant variant : profile.getVariants().getVariants()) { + DimensionBinding combinedVariant = + DimensionBinding.createFrom(profile.getDimensions(), variant.getDimensionValues()).combineWith(currentVariant); + if (combinedVariant.isInvalid()) continue; // values at this point in the graph are unreachable + collectVariantsFromValues(path, variant.values(), combinedVariant, allVariants); + for (QueryProfile variantInheritedProfile : variant.inherited()) + collectVariants(path, variantInheritedProfile, combinedVariant, allVariants); + } + } + } + + private static void collectVariantsFromValues(CompoundName path, Map<String, Object> values, DimensionBinding currentVariant, Set<DimensionBindingForPath> allVariants) { + if ( ! values.isEmpty()) + allVariants.add(new DimensionBindingForPath(currentVariant, path)); // there are actual values for this variant + + for (Map.Entry<String, Object> entry : values.entrySet()) { + if (entry.getValue() instanceof QueryProfile) + collectVariants(path.append(entry.getKey()), (QueryProfile)entry.getValue(), currentVariant, allVariants); + } + } + + private static class DimensionBindingForPath { + + private final DimensionBinding binding; + private final CompoundName path; + + public DimensionBindingForPath(DimensionBinding binding, CompoundName path) { + this.binding = binding; + this.path = path; + } + + public DimensionBinding binding() { return binding; } + public CompoundName path() { return path; } + + @Override + public boolean equals(Object o) { + if ( o == this ) return true; + if ( ! (o instanceof DimensionBindingForPath)) return false; + DimensionBindingForPath other = (DimensionBindingForPath)o; + return other.binding.equals(this.binding) && other.path.equals(this.path); + } + + @Override + public int hashCode() { + return binding.hashCode() + 17*path.hashCode(); + } + + @Override + public String toString() { + return binding + " for path " + path; + } + + } + +} diff --git a/container-search/src/main/java/com/yahoo/search/query/profile/QueryProfileProperties.java b/container-search/src/main/java/com/yahoo/search/query/profile/QueryProfileProperties.java new file mode 100644 index 00000000000..2432cb2ab33 --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/profile/QueryProfileProperties.java @@ -0,0 +1,258 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.query.profile; + +import com.yahoo.collections.Pair; +import com.yahoo.processing.request.CompoundName; +import com.yahoo.processing.request.properties.PropertyMap; +import com.yahoo.protect.Validator; +import com.yahoo.search.query.Properties; +import com.yahoo.search.query.profile.compiled.CompiledQueryProfile; +import com.yahoo.search.query.profile.compiled.DimensionalValue; +import com.yahoo.search.query.profile.types.FieldDescription; +import com.yahoo.search.query.profile.types.QueryProfileType; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Properties backed by a query profile. + * This has the scope of one query and is not multithread safe. + * + * @author bratseth + */ +public class QueryProfileProperties extends Properties { + + private final CompiledQueryProfile profile; + + // Note: The priority order is: values has precedence over references + + /** Values which has been overridden at runtime, or null if none */ + private Map<CompoundName, Object> values = null; + /** Query profile references which has been overridden at runtime, or null if none. Earlier values has precedence */ + private List<Pair<CompoundName, CompiledQueryProfile>> references = null; + + /** Creates an instance from a profile, throws an exception if the given profile is null */ + public QueryProfileProperties(CompiledQueryProfile profile) { + Validator.ensureNotNull("The profile wrapped by this cannot be null", profile); + this.profile = profile; + } + + /** Returns the query profile backing this, or null if none */ + public CompiledQueryProfile getQueryProfile() { return profile; } + + /** Gets a value from the query profile, or from the nested profile if the value is null */ + @Override + public Object get(CompoundName name, Map<String,String> context, + com.yahoo.processing.request.Properties substitution) { + name = unalias(name, context); + Object value = null; + if (values != null) + value = values.get(name); + if (value == null) { + Pair<CompoundName, CompiledQueryProfile> reference = findReference(name); + if (reference != null) + return reference.getSecond().get(name.rest(reference.getFirst().size()), context, substitution); // yes; even if null + } + + if (value == null) + value = profile.get(name, context, substitution); + if (value == null) + value = super.get(name, context, substitution); + return value; + } + + /** + * Sets a value in this query profile + * + * @throws IllegalArgumentException if this property cannot be set in the wrapped query profile + */ + @Override + public void set(CompoundName name, Object value, Map<String,String> context) { + // TODO: Refactor + try { + name = unalias(name, context); + + if (context == null) + context = Collections.emptyMap(); + + if ( ! profile.isOverridable(name, context)) return; + + // Check runtime references + Pair<CompoundName, CompiledQueryProfile> runtimeReference = findReference(name); + if (runtimeReference != null && ! runtimeReference.getSecond().isOverridable(name.rest(runtimeReference.getFirst().size()), context)) + return; + + // Check types + if ( ! profile.getTypes().isEmpty()) { + for (int i = 0; i<name.size(); i++) { + QueryProfileType type = profile.getType(name.first(i), context); + if (type == null) continue; + String localName = name.get(i); + FieldDescription fieldDescription = type.getField(localName); + if (fieldDescription == null && type.isStrict()) + throw new IllegalArgumentException("'" + localName + "' is not declared in " + type + ", and the type is strict"); + + // TODO: In addition to strictness, check legality along the way + + if (i == name.size()-1 && fieldDescription != null) { // at the end of the path, check the assignment type + value = fieldDescription.getType().convertFrom(value, profile.getRegistry()); + if (value == null) + throw new IllegalArgumentException("'" + value + "' is not a " + fieldDescription.getType().toInstanceDescription()); + } + } + } + + if (value instanceof String && value.toString().startsWith("ref:")) { + if (profile.getRegistry() == null) + throw new IllegalArgumentException("Runtime query profile references does not work when the " + + "QueryProfileProperties are constructed without a registry"); + String queryProfileId = value.toString().substring(4); + value = profile.getRegistry().findQueryProfile(queryProfileId); + if (value == null) + throw new IllegalArgumentException("Query profile '" + queryProfileId + "' is not found"); + } + + if (value instanceof CompiledQueryProfile) { // this will be due to one of the two clauses above + if (references == null) + references = new ArrayList<>(); + references.add(0, new Pair<>(name, (CompiledQueryProfile)value)); // references set later has precedence - put first + } + else { + if (values == null) + values = new HashMap<>(); + values.put(name, value); + } + } + catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Could not set '" + name + "' to '" + value + "': " + e.getMessage()); // TODO: Nest instead + } + } + + @Override + public Map<String, Object> listProperties(CompoundName path, Map<String,String> context, + com.yahoo.processing.request.Properties substitution) { + path = unalias(path, context); + if (context == null) context = Collections.emptyMap(); + + Map<String, Object> properties = profile.listValues(path, context, substitution); + + properties.putAll(super.listProperties(path, context, substitution)); + + if (references != null) { + for (Pair<CompoundName, CompiledQueryProfile> refEntry : references) { + if ( ! refEntry.getFirst().hasPrefix(path.first(Math.min(refEntry.getFirst().size(), path.size())))) continue; + + CompoundName pathInReference; + CompoundName prefixToReferenceKeys; + if (refEntry.getFirst().size() > path.size()) { + pathInReference = CompoundName.empty; + prefixToReferenceKeys = refEntry.getFirst().rest(path.size()); + } + else { + pathInReference = path.rest(refEntry.getFirst().size()); + prefixToReferenceKeys = CompoundName.empty; + } + for (Map.Entry<String, Object> valueEntry : refEntry.getSecond().listValues(pathInReference, context, substitution).entrySet()) { + properties.put(prefixToReferenceKeys.append(new CompoundName(valueEntry.getKey())).toString(), valueEntry.getValue()); + } + } + + } + + if (values != null) { + for (Map.Entry<CompoundName, Object> entry : values.entrySet()) { + if (entry.getKey().hasPrefix(path)) + properties.put(entry.getKey().rest(path.size()).toString(), entry.getValue()); + } + } + + return properties; + } + + public boolean isComplete(StringBuilder firstMissingName, Map<String,String> context) { + // Are all types reachable from this complete? + if ( ! reachableTypesAreComplete(CompoundName.empty, profile, firstMissingName, context)) + return false; + + // Are all runtime references in this complete? + if (references == null) return true; + for (Pair<CompoundName, CompiledQueryProfile> reference : references) { + if ( ! reachableTypesAreComplete(reference.getFirst(), reference.getSecond(), firstMissingName, context)) + return false; + } + + return true; + } + + private boolean reachableTypesAreComplete(CompoundName prefix, CompiledQueryProfile profile, StringBuilder firstMissingName, Map<String,String> context) { + for (Map.Entry<CompoundName, DimensionalValue<QueryProfileType>> typeEntry : profile.getTypes().entrySet()) { + QueryProfileType type = typeEntry.getValue().get(context); + if (type == null) continue; + if ( ! typeIsComplete(prefix.append(typeEntry.getKey()), type, firstMissingName, context)) + return false; + } + return true; + } + + private boolean typeIsComplete(CompoundName prefix, QueryProfileType type, StringBuilder firstMissingName, Map<String,String> context) { + if (type == null) return true; + for (FieldDescription field : type.fields().values()) { + if ( ! field.isMandatory()) continue; + + CompoundName fieldName = prefix.append(field.getName()); + if ( get(fieldName, null) != null) continue; + if ( hasReference(fieldName)) continue; + + if (profile.getReferences().get(fieldName, context) != null) continue; + + if (firstMissingName != null) + firstMissingName.append(fieldName); + return false; + } + return true; + } + + private boolean hasReference(CompoundName name) { + if (references == null) return false; + for (Pair<CompoundName, CompiledQueryProfile> reference : references) + if (reference.getFirst().equals(name)) + return true; + return false; + } + + private Pair<CompoundName, CompiledQueryProfile> findReference(CompoundName name) { + if (references == null) return null; + for (Pair<CompoundName, CompiledQueryProfile> entry : references) { + if (name.hasPrefix(entry.getFirst())) return entry; + } + return null; + } + + CompoundName unalias(CompoundName name, Map<String,String> context) { + if (profile.getTypes().isEmpty()) return name; + + CompoundName unaliasedName = name; + for (int i = 0; i<name.size(); i++) { + QueryProfileType type = profile.getType(name.first(i), context); + if (type == null) continue; + if (type.aliases() == null) continue; // TODO: Make never null + if (type.aliases().isEmpty()) continue; + String localName = name.get(i); + String unaliasedLocalName = type.unalias(localName); + unaliasedName = unaliasedName.set(i, unaliasedLocalName); + } + return unaliasedName; + } + + @Override + public QueryProfileProperties clone() { + QueryProfileProperties clone = (QueryProfileProperties)super.clone(); + if (this.values != null) + clone.values = PropertyMap.cloneMap(this.values); + return clone; + } + +} diff --git a/container-search/src/main/java/com/yahoo/search/query/profile/QueryProfileRegistry.java b/container-search/src/main/java/com/yahoo/search/query/profile/QueryProfileRegistry.java new file mode 100644 index 00000000000..a4bca752d18 --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/profile/QueryProfileRegistry.java @@ -0,0 +1,89 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.query.profile; + +import com.yahoo.component.ComponentSpecification; +import com.yahoo.component.provider.ComponentRegistry; +import com.yahoo.search.query.profile.compiled.CompiledQueryProfileRegistry; +import com.yahoo.search.query.profile.types.QueryProfileType; +import com.yahoo.search.query.profile.types.QueryProfileTypeRegistry; + +/** + * A set of query profiles. This also holds the query profile types as a dependent registry + * + * @author bratseth + */ +public class QueryProfileRegistry extends ComponentRegistry<QueryProfile> { + + private QueryProfileTypeRegistry queryProfileTypeRegistry = new QueryProfileTypeRegistry(); + + /** The current default instance of this registry */ + private static QueryProfileRegistry instance = new QueryProfileRegistry(); + + /** Register this type by its id */ + public void register(QueryProfile profile) { + super.register(profile.getId(), profile); + } + + /** Returns a query profile type by name, or null if not found */ + public QueryProfileType getType(String type) { + return queryProfileTypeRegistry.getComponent(type); + } + + /** Returns the type registry attached to this */ + public QueryProfileTypeRegistry getTypeRegistry() { return queryProfileTypeRegistry; } + + /** + * <p>Returns a query profile for the given request string, or null if a suitable one is not found.</p> + * + * The request string must be a valid {@link com.yahoo.component.ComponentId} or null. + * + * <p> + * If the string is null, the profile named "default" is returned, or null if that does not exists. + * + * <p> + * The version part (if any) is matched used the usual component version patching rules. + * If the name part matches a query profile name perfectly, that profile is returned. + * If not, and the name is a slash-separated path, the profile with the longest matching left sub-path + * which has a type which allows path mahting is used. If there is no such profile, null is returned. + */ + public QueryProfile findQueryProfile(String idString) { + if (idString==null) return getComponent("default"); + ComponentSpecification id=new ComponentSpecification(idString); + QueryProfile profile=getComponent(id); + if (profile!=null) return profile; + + return findPathParentQueryProfile(new ComponentSpecification(idString)); + } + + private QueryProfile findPathParentQueryProfile(ComponentSpecification id) { + // Try the name with "/" appended - should have the same semantics with path matching + QueryProfile slashedProfile=getComponent(new ComponentSpecification(id.getName() + "/",id.getVersionSpecification())); + if (slashedProfile!=null && slashedProfile.getType()!=null && slashedProfile.getType().getMatchAsPath()) + return slashedProfile; + + // Extract the parent (if any) + int slashIndex=id.getName().lastIndexOf("/"); + if (slashIndex<1) return null; + String parentName=id.getName().substring(0,slashIndex); + if (parentName.equals("")) return null; + + ComponentSpecification parentId=new ComponentSpecification(parentName,id.getVersionSpecification()); + + QueryProfile pathParentProfile=getComponent(parentId); + + if (pathParentProfile!=null && pathParentProfile.getType()!=null && pathParentProfile.getType().getMatchAsPath()) + return pathParentProfile; + return findPathParentQueryProfile(parentId); + } + + /** Freezes this, and all owned query profiles and query profile types */ + public @Override void freeze() { + if (isFrozen()) return; + queryProfileTypeRegistry.freeze(); + for (QueryProfile queryProfile : allComponents()) + queryProfile.freeze(); + } + + public CompiledQueryProfileRegistry compile() { return QueryProfileCompiler.compile(this); } + +} diff --git a/container-search/src/main/java/com/yahoo/search/query/profile/QueryProfileVariant.java b/container-search/src/main/java/com/yahoo/search/query/profile/QueryProfileVariant.java new file mode 100644 index 00000000000..42ea4a96d8f --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/profile/QueryProfileVariant.java @@ -0,0 +1,157 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.query.profile; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.yahoo.search.query.profile.types.QueryProfileType; + +import java.util.*; + +/** + * A variant of a query profile + * + * @author <a href="mailto:bratseth@yahoo-inc.com">Jon Bratseth</a> +*/ +public class QueryProfileVariant implements Cloneable, Comparable<QueryProfileVariant> { + + private List<QueryProfile> inherited=null; + + private DimensionValues dimensionValues; + + private Map<String,Object> values; + + private boolean frozen=false; + + private QueryProfile owner; + + public QueryProfileVariant(DimensionValues dimensionValues, QueryProfile owner) { + this.dimensionValues=dimensionValues; + this.owner = owner; + } + + public DimensionValues getDimensionValues() { return dimensionValues; } + + /** + * Returns the live reference to the values of this. This may be modified + * if this is not frozen. + */ + public Map<String,Object> values() { + if (values==null) { + if (frozen) + return Collections.emptyMap(); + else + values=new HashMap<>(); + } + return values; + } + + /** + * Returns the live reference to the inherited profiles of this. This may be modified + * if this is not frozen. + */ + public List<QueryProfile> inherited() { + if (inherited==null) { + if (frozen) + return Collections.emptyList(); + else + inherited=new ArrayList<>(); + } + return inherited; + } + + public void set(String key, Object newValue) { + if (values==null) + values=new HashMap<>(); + + Object oldValue = values.get(key); + + if (oldValue == null) { + values.put(key, newValue); + } else { + Object combinedOrNull = QueryProfile.combineValues(newValue, oldValue); + if (combinedOrNull != null) { + values.put(key, combinedOrNull); + } + } + } + + public void inherit(QueryProfile profile) { + if (inherited==null) + inherited=new ArrayList<>(1); + inherited.add(profile); + } + + /** + * Implements the sort order of this which is based on specificity + * where dimensions to the left are more significant. + * <p> + * <b>Note:</b> This ordering is not consistent with equals - it returns 0 when the same dimensions + * are <i>set</i>, regardless of what they are set <i>to</i>. + */ + public @Override int compareTo(QueryProfileVariant other) { + return this.dimensionValues.compareTo(other.dimensionValues); + } + + public boolean matches(DimensionValues givenDimensionValues) { + return this.dimensionValues.matches(givenDimensionValues); + } + + /** Accepts a visitor to the values of this */ + public void accept(boolean allowContent,QueryProfileType type,QueryProfileVisitor visitor, DimensionBinding dimensionBinding) { + // Visit this + if (allowContent) { + String key=visitor.getLocalKey(); + if (key!=null) { + if (type!=null) + type.unalias(key); + + visitor.acceptValue(key, values().get(key), dimensionBinding, owner); + if (visitor.isDone()) return; + } + else { + for (Map.Entry<String,Object> entry : values().entrySet()) { + visitor.acceptValue(entry.getKey(), entry.getValue(), dimensionBinding, owner); + if (visitor.isDone()) return; + } + } + } + + // Visit inherited + for (QueryProfile profile : inherited()) { + if (visitor.visitInherited()) { + profile.accept(allowContent,visitor,dimensionBinding.createFor(profile.getDimensions()), owner); + } + if (visitor.isDone()) return; + } + } + + public void freeze() { + if (frozen) return; + if (inherited != null) + inherited = ImmutableList.copyOf(inherited); + if (values != null) + values = ImmutableMap.copyOf(values); + frozen=true; + } + + public QueryProfileVariant clone() { + if (frozen) return this; + try { + QueryProfileVariant clone=(QueryProfileVariant)super.clone(); + if (this.inherited!=null) + clone.inherited=new ArrayList<>(this.inherited); // TODO: Deep clone is more correct, but probably does not matter in practice + + clone.values=CopyOnWriteContent.deepClone(this.values); + + return clone; + } + catch (CloneNotSupportedException e) { + throw new RuntimeException(e); + } + } + + public @Override String toString() { + return "query profile variant for " + dimensionValues; + } + +} diff --git a/container-search/src/main/java/com/yahoo/search/query/profile/QueryProfileVariants.java b/container-search/src/main/java/com/yahoo/search/query/profile/QueryProfileVariants.java new file mode 100644 index 00000000000..fde851bdc75 --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/profile/QueryProfileVariants.java @@ -0,0 +1,486 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.query.profile; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.yahoo.component.provider.Freezable; +import com.yahoo.search.query.profile.types.QueryProfileType; + +import java.util.*; + +/** + * This class represent a set of query profiles virtually - rather + * than storing and instantiating each profile this structure represents explicitly only + * the values set in the various virtual profiles. The set of virtual profiles are defined by a set of + * <i>dimensions</i>. Values may be set for any point in this multi-dimensional space, and may also be set for + * any regular hyper-region by setting values for any point in certain of these dimensions. + * The set of virtual profiles defined by this consists of all the combinations of dimension points for + * which one or more values is set in this, as well as any possible less specified regions. + * <p> + * A set of virtual profiles are always owned by a single profile, which is also their parent + * in the inheritance hierarchy. + * + * @author <a href="mailto:bratseth@yahoo-inc.com">Jon Bratseth</a> + */ +public class QueryProfileVariants implements Freezable, Cloneable { + + private boolean frozen=false; + + /** Properties indexed by name, to support fast lookup of single values */ + private Map<String,FieldValues> fieldValuesByName=new HashMap<>(); + + /** The inherited profiles for various dimensions settings - a set of fieldvalues of List<QueryProfile> */ + private FieldValues inheritedProfiles=new FieldValues(); + + /** + * Field and inherited profiles sorted by specificity used for all-value visiting. + * This is the same as how the source data looks (apart from the sorting). + */ + private List<QueryProfileVariant> variants=new ArrayList<>(); + + /** + * The names of the dimensions (which are possible properties in the context given on lookup) of this. + * Order matters - more specific values to the left in this list are more significant than more specific values + * to the right + */ + private List<String> dimensions; + + /** The query profile this variants of */ + private QueryProfile owner; + + /** + * Creates a set of virtual query profiles which may return varying values over the set of dimensions given. + * Each dimension is a name for which a key-value may be supplied in the context properties + * on lookup time to influence the value returned. + */ + public QueryProfileVariants(String[] dimensions, QueryProfile owner) { + this(Arrays.asList(dimensions), owner); + } + + /** + * Creates a set of virtual query profiles which may return varying values over the set of dimensions given. + * Each dimension is a name for which a key-value may be supplied in the context properties + * on lookup time to influence the value returned. + * + * @param dimensions the dimension names this may vary over. The list gets owned by this, so it must not be further + * modified from outside). This will not modify the list. + */ + public QueryProfileVariants(List<String> dimensions, QueryProfile owner) { + // Note: This is not made unmodifiable (here or in freeze) because we depend on map identity comparisons of this + // list (in dimensionBinding) for performance reasons. + this.dimensions = dimensions; + this.owner = owner; + } + + /** Irreversibly prevents any further modifications to this */ + public void freeze() { + if (frozen) return; + for (FieldValues fieldValues : fieldValuesByName.values()) + fieldValues.freeze(); + fieldValuesByName = ImmutableMap.copyOf(fieldValuesByName); + inheritedProfiles.freeze(); + + Collections.sort(variants); + for (QueryProfileVariant variant : variants) + variant.freeze(); + variants = ImmutableList.copyOf(variants); + + frozen=true; + } + + @Override + public boolean isFrozen() { + return frozen; + } + + /** Visits the most specific match to the dimension binding of each variable (or the one named by the visitor) */ + void accept(boolean allowContent,QueryProfileType type,QueryProfileVisitor visitor,DimensionBinding dimensionBinding) { + String contentName=null; + if (allowContent) + contentName=visitor.getLocalKey(); + + if (contentName!=null) { + if (type!=null) + contentName=type.unalias(contentName); + acceptSingleValue(contentName,allowContent,visitor,dimensionBinding); // Special cased for performance + } + else { + acceptAllValues(allowContent,visitor,type,dimensionBinding); + } + } + + // PERF: 90% + void acceptSingleValue(String name,boolean allowContent,QueryProfileVisitor visitor,DimensionBinding dimensionBinding) { + FieldValues fieldValues=fieldValuesByName.get(name); + if (fieldValues==null || !allowContent) + fieldValues=new FieldValues(); + + fieldValues.sort(); + inheritedProfiles.sort(); + + int inheritedIndex=0; + int fieldIndex=0; + // Go through both the fields and the inherited profiles at the same time and try the single must specific pick + // from either of the lists at each step + while(fieldIndex<fieldValues.size() || inheritedIndex<inheritedProfiles.size()) { // PERF: 8% - fieldValues.size() + // Get the next most specific from field and inherited + FieldValue fieldValue=fieldValues.getIfExists(fieldIndex); // PERF: 11% - getIfExists + FieldValue inheritedProfileValue=inheritedProfiles.getIfExists(inheritedIndex); // PERF: 11% - getIfExists + + // Try the most specific first, then the other + if (inheritedProfileValue==null || (fieldValue!=null && fieldValue.compareTo(inheritedProfileValue)<=0)) { // Field is most specific, or both are equally specific + if (fieldValue.matches(dimensionBinding.getValues())) { // PERF: 42% - matches, together with the other matches + visitor.acceptValue(name, fieldValue.getValue(), dimensionBinding, owner); + } + if (visitor.isDone()) return; + fieldIndex++; + } + else if (inheritedProfileValue!=null) { // Inherited is most specific at this point + if (inheritedProfileValue.matches(dimensionBinding.getValues())) { // PERF: 42% - matches, together with the other matches + @SuppressWarnings("unchecked") + List<QueryProfile> inheritedProfileList=(List<QueryProfile>)inheritedProfileValue.getValue(); + for (QueryProfile inheritedProfile : inheritedProfileList) { + if (visitor.visitInherited()) { + inheritedProfile.accept(allowContent,visitor,dimensionBinding.createFor(inheritedProfile.getDimensions()), owner); + } + if (visitor.isDone()) return; + } + } + inheritedIndex++; + } + if (visitor.isDone()) return; + } + } + + void acceptAllValues(boolean allowContent,QueryProfileVisitor visitor, QueryProfileType type,DimensionBinding dimensionBinding) { + if (!frozen) + Collections.sort(variants); + for (QueryProfileVariant variant : variants) { + if (variant.matches(dimensionBinding.getValues())) + variant.accept(allowContent,type,visitor,dimensionBinding); + if (visitor.isDone()) return; + } + } + + /** + * Returns the most specific matching value of a name for a given set of <b>canonical</b> dimension values. + * + * @param name the name to return the best matching value of + * @param dimensionBinding the dimension bindings to use in this + */ + public Object get(String name, QueryProfileType type, boolean allowQueryProfileResult, DimensionBinding dimensionBinding) { + SingleValueQueryProfileVisitor visitor=new SingleValueQueryProfileVisitor(Collections.singletonList(name),allowQueryProfileResult); + visitor.enter(""); + accept(true,type,visitor,dimensionBinding); + visitor.leave(""); + return visitor.getResult(); + } + + /** Inherits a particular profile in a variant of this */ + public void inherit(QueryProfile profile,DimensionValues dimensionValues) { + ensureNotFrozen(); + + // Update variant + getVariant(dimensionValues,true).inherit(profile); + + // Update per-variable optimized structure + @SuppressWarnings("unchecked") + List<QueryProfile> inheritedAtDimensionValues=(List<QueryProfile>)inheritedProfiles.getExact(dimensionValues); + if (inheritedAtDimensionValues==null) { + inheritedAtDimensionValues=new ArrayList<>(); + inheritedProfiles.put(dimensionValues,inheritedAtDimensionValues); + } + inheritedAtDimensionValues.add(profile); + } + + /** + * Sets a value to this + * + * @param fieldName the name of the field to set. This cannot be a compound (dotted) name + * @param binding the dimension values for which this value applies. + * The dimensions must be canonicalized, and ownership is transferred to this. + * @param value the value to set + */ + /** + * Sets a value to this + * + * @param fieldName the name of the field to set. This cannot be a compound (dotted) name + * @param dimensionValues the dimension values for which this value applies + * @param value the value to set + */ + public void set(String fieldName,DimensionValues dimensionValues,Object value) { + ensureNotFrozen(); + + // Update variant + getVariant(dimensionValues,true).set(fieldName,value); + + // Update per-variable optimized structure + FieldValues fieldValues=fieldValuesByName.get(fieldName); + if (fieldValues==null) { + fieldValues=new FieldValues(); + fieldValuesByName.put(fieldName,fieldValues); + } + + Object combinedValue=QueryProfile.combineValues(value,fieldValues.getExact(dimensionValues)); + if (combinedValue!=null) + fieldValues.put(dimensionValues,combinedValue); + } + + /** + * Returns the dimensions over which the virtual profiles in this may return different values. + * Each dimension is a name for which a key-value may be supplied in the context properties + * on lookup time to influence the value returned. + * The dimensions may not be modified - the returned list is always read only. + */ + // Note: A performance optimization in DimensionBinding depends on the identity of the list returned from this + public List<String> getDimensions() { return dimensions; } + + /** Returns the map of field values of this indexed by field name. */ + public Map<String,FieldValues> getFieldValues() { return fieldValuesByName; } + + /** Returns the profiles inherited from various variants of this */ + public FieldValues getInherited() { return inheritedProfiles; } + + /** + * Returns all the variants of this, sorted by specificity. This is content as declared. + * The returned list is always unmodifiable. + */ + public List<QueryProfileVariant> getVariants() { + if (frozen) return variants; // Already unmodifiable + return Collections.unmodifiableList(variants); + } + + public QueryProfileVariants clone() { + try { + if (frozen) return this; + QueryProfileVariants clone=(QueryProfileVariants)super.clone(); + clone.inheritedProfiles=inheritedProfiles.clone(); + + clone.variants=new ArrayList<>(); + for (QueryProfileVariant variant : variants) + clone.variants.add(variant.clone()); + + clone.fieldValuesByName=new HashMap<>(); + for (Map.Entry<String,FieldValues> entry : fieldValuesByName.entrySet()) + clone.fieldValuesByName.put(entry.getKey(),entry.getValue().clone(entry.getKey(),clone.variants)); + + return clone; + } + catch (CloneNotSupportedException e) { + throw new RuntimeException(e); + } + } + + /** Throws an IllegalStateException if this is frozen */ + protected void ensureNotFrozen() { + if (frozen) + throw new IllegalStateException(this + " is frozen and cannot be modified"); + } + + /** + * Returns the query profile variant having exactly the given dimensions, and creates it if create is set and + * it is missing + * + * @param dimensionValues the dimension values + * @param create whether or not to create the variant if missing + * @return the profile variant, or null if not found and create is false + */ + public QueryProfileVariant getVariant(DimensionValues dimensionValues,boolean create) { + for (QueryProfileVariant profileVariant : variants) + if (profileVariant.getDimensionValues().equals(dimensionValues)) + return profileVariant; + + // Not found + if (!create) return null; + QueryProfileVariant variant=new QueryProfileVariant(dimensionValues, owner); + variants.add(variant); + return variant; + } + + public static class FieldValues implements Freezable, Cloneable { + + private List<FieldValue> resolutionList=null; + + private boolean frozen=false; + + @Override + public void freeze() { + if (frozen) return; + sort(); + if (resolutionList != null) + resolutionList = ImmutableList.copyOf(resolutionList); + frozen = true; + } + + @Override + public boolean isFrozen() { + return frozen; + } + + public void put(DimensionValues dimensionValues,Object value) { + ensureNotFrozen(); + if (resolutionList==null) resolutionList=new ArrayList<>(); + FieldValue fieldValue=getExactFieldValue(dimensionValues); + if (fieldValue!=null) // Replace + fieldValue.setValue(value); + else + resolutionList.add(new FieldValue(dimensionValues,value)); + } + + /** Returns the value having exactly the given dimensions, or null if none */ + public Object getExact(DimensionValues dimensionValues) { + FieldValue value=getExactFieldValue(dimensionValues); + if (value==null) return null; + return value.getValue(); + } + + /** Returns the field value having exactly the given dimensions, or null if none */ + private FieldValue getExactFieldValue(DimensionValues dimensionValues) { + for (FieldValue fieldValue : asList()) + if (fieldValue.getDimensionValues().equals(dimensionValues)) + return fieldValue; + return null; + } + + /** Returns the field values (values for various dimensions) for this field as a read-only list (never null) */ + public List<FieldValue> asList() { + if (resolutionList==null) return Collections.emptyList(); + return resolutionList; + } + + public FieldValue getIfExists(int index) { + if (index>=size()) return null; + return resolutionList.get(index); + } + + public void sort() { + if (frozen) return ; // sorted already + if (resolutionList!=null) + Collections.sort(resolutionList); + } + + /** Same as asList().size() */ + public int size() { + if (resolutionList==null) return 0; + return resolutionList.size(); + } + + /** Throws an IllegalStateException if this is frozen */ + protected void ensureNotFrozen() { + if (frozen) + throw new IllegalStateException(this + " is frozen and cannot be modified"); + } + + /** Clone by filling in values from the given variants */ + public FieldValues clone(String fieldName,List<QueryProfileVariant> clonedVariants) { + try { + if (frozen) return this; + FieldValues clone=(FieldValues)super.clone(); + + if (resolutionList!=null) { + clone.resolutionList=new ArrayList<>(resolutionList.size()); + for (FieldValue value : resolutionList) + clone.resolutionList.add(value.clone(fieldName,clonedVariants)); + } + + return clone; + } + catch (CloneNotSupportedException e) { + throw new RuntimeException(e); + } + } + + public @Override FieldValues clone() { + try { + if (frozen) return this; + FieldValues clone=(FieldValues)super.clone(); + + if (resolutionList!=null) { + clone.resolutionList=new ArrayList<>(resolutionList.size()); + for (FieldValue value : resolutionList) + clone.resolutionList.add(value.clone()); + } + + return clone; + } + catch (CloneNotSupportedException e) { + throw new RuntimeException(e); + } + } + + } + + public static class FieldValue implements Comparable<FieldValue>, Cloneable { + + private DimensionValues dimensionValues; + private Object value; + + public FieldValue(DimensionValues dimensionValues,Object value) { + this.dimensionValues=dimensionValues; + this.value=value; + } + + /** + * Returns the dimension values for which this value should be used. + * The dimension array is always of the exact size of the dimensions specified by the owning QueryProfileVariants, + * and the values appear in the order defined. "Wildcard" dimensions are represented by a null. + */ + public DimensionValues getDimensionValues() { return dimensionValues; } + + /** Returns the value to use for this set of dimension values */ + public Object getValue() { return value; } + + /** Sets the value to use for this set of dimension values */ + public void setValue(Object value) { this.value=value; } + + public boolean matches(DimensionValues givenDimensionValues) { + return dimensionValues.matches(givenDimensionValues); + } + + /** + * Implements the sort order of this which is based on specificity + * where dimensions to the left are more significant. + * <p> + * <b>Note:</b> This ordering is not consistent with equals - it returns 0 when the same dimensions + * are <i>set</i>, regardless of what they are set <i>to</i>. + */ + public @Override int compareTo(FieldValue other) { + return this.dimensionValues.compareTo(other.dimensionValues); + } + + /** Clone by filling in the value from the given variants */ + public FieldValue clone(String fieldName,List<QueryProfileVariant> clonedVariants) { + try { + FieldValue clone=(FieldValue)super.clone(); + if (this.value instanceof QueryProfile) + clone.value=lookupInVariants(fieldName,dimensionValues,clonedVariants); + // Otherwise the value is immutable, so keep it as-is + return clone; + } + catch (CloneNotSupportedException e) { + throw new RuntimeException(e); + } + } + + public FieldValue clone() { + try { + FieldValue clone=(FieldValue)super.clone(); + clone.value=QueryProfile.cloneIfNecessary(this.value); + return clone; + } + catch (CloneNotSupportedException e) { + throw new RuntimeException(e); + } + } + + private Object lookupInVariants(String fieldName,DimensionValues dimensionValues,List<QueryProfileVariant> variants) { + for (QueryProfileVariant variant : variants) { + if ( ! variant.getDimensionValues().equals(dimensionValues)) continue; + return variant.values().get(fieldName); + } + return null; + } + + } + +} diff --git a/container-search/src/main/java/com/yahoo/search/query/profile/QueryProfileVisitor.java b/container-search/src/main/java/com/yahoo/search/query/profile/QueryProfileVisitor.java new file mode 100644 index 00000000000..8cb6bf34021 --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/profile/QueryProfileVisitor.java @@ -0,0 +1,87 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.query.profile; + +/** + * Instances of this is used to visit nodes in a graph of query profiles + * + * <code> + * Visitor are called in the following sequence on each query profile: + * enter=enter(referenceName); + * onQueryProfile(this) + * if (enter) { + * getLocalKey() + * ...calls on nested content found in variants, this and inherited, in that order + * leave(referenceName) + * } + * + * The first enter call will be on the root node, which has an empt reference name. + * </code> + * + * @author <a href="mailto:bratseth@yahoo-inc.com">Jon Bratseth</a> + */ +abstract class QueryProfileVisitor { + + /** + * Called when a new <b>nested</b> profile in the graph is entered. + * This default implementation does nothing but returning true. + * If the node is entered (if true is returned from this), a corresponding {@link #leave(String)} call will happen + * later. + * + * @param name the name this profile is nested as, or the empty string if we are entering the root profile + * @return whether we should visit the content of this node or not + */ + public boolean enter(String name) { return true; } + + /** + * Called when the last {@link #enter(String) entered} nested profile is left. + * That is: One leave call is made for each enter call which returns true, + * but due to nesting those calls are not necessarily alternating. + * This default implementation does nothing. + */ + public void leave(String name) { } + + /** + * Called when a value (not a query profile) is encountered. + * + * @param localName the local name of this value (the full name, if needed, must be reconstructed + * by the information given by the history of {@link #enter(String)} and {@link #leave(String)} calls + * @param value the value + * @param binding the binding this holds for + * @param owner the query profile having this value, or null only when profile is the root profile + */ + public abstract void onValue(String localName, Object value, DimensionBinding binding, QueryProfile owner); + + /** + * Called when a query profile is encountered. + * + * @param profile the query profile reference encountered + * @param binding the binding this holds for + * @param owner the profile making this reference, or null only when profile is the root profile + */ + public abstract void onQueryProfile(QueryProfile profile, DimensionBinding binding, QueryProfile owner); + + /** Returns whether this visitor is done visiting what it needed to visit at this point */ + public abstract boolean isDone(); + + /** Returns whether we should, at this point, visit inherited profiles. This default implementation returns true */ + public boolean visitInherited() { return true; } + + /** + * Returns the current local key which should be visited in the last {@link #enter(String) entered} sub-profile + * (or in the top level profile if none is entered), or null to visit all content + */ + public abstract String getLocalKey(); + + /** Calls onValue or onQueryProfile on this and visits the content if it's a profile */ + final void acceptValue(String key, Object value, DimensionBinding dimensionBinding, QueryProfile owner) { + if (value==null) return; + if (value instanceof QueryProfile) { + QueryProfile queryProfileValue=(QueryProfile)value; + queryProfileValue.acceptAndEnter(key, this, dimensionBinding.createFor(queryProfileValue.getDimensions()), owner); + } + else { + onValue(key, value, dimensionBinding, owner); + } + } + +} diff --git a/container-search/src/main/java/com/yahoo/search/query/profile/SingleValueQueryProfileVisitor.java b/container-search/src/main/java/com/yahoo/search/query/profile/SingleValueQueryProfileVisitor.java new file mode 100644 index 00000000000..6d5d1b0686a --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/profile/SingleValueQueryProfileVisitor.java @@ -0,0 +1,76 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.query.profile; + +import java.util.List; + +/** + * Visitor which stores the first non-query-profile value encountered, + * or the first query profile encountered at a stop where we do not have any name components left which can be used to + * visit further subprofiles. Hence this may be used both to get the highest prioritized primitive + * value, or query profile, whichever is encountered first which matches the name. + * <p> + * + * @author <a href="mailto:bratseth@yahoo-inc.com">Jon Bratseth</a> + */ +final class SingleValueQueryProfileVisitor extends QueryProfileVisitor { + + /** The value found, or null if none */ + private Object value=null; + + private final List<String> name; + + private int nameIndex=-1; + + private final boolean allowQueryProfileResult; + + private boolean enteringContent=true; + + public SingleValueQueryProfileVisitor(List<String> name,boolean allowQueryProfileResult) { + this.name=name; + this.allowQueryProfileResult=allowQueryProfileResult; + } + + public @Override String getLocalKey() { + return name.get(nameIndex); + } + + public @Override boolean enter(String name) { + if (nameIndex+1<this.name.size()) { + nameIndex++; + enteringContent=true; + } + else { + enteringContent=false; + } + return enteringContent; + } + + public @Override void leave(String name) { + nameIndex--; + } + + public @Override void onValue(String key,Object value, DimensionBinding binding, QueryProfile owner) { + if (nameIndex==name.size()-1) + this.value=value; + } + + public @Override void onQueryProfile(QueryProfile profile,DimensionBinding binding, QueryProfile owner) { + if (enteringContent) return; // still waiting for content + if (allowQueryProfileResult) + this.value = profile; + else + this.value = profile.getValue(); + } + + public @Override boolean isDone() { + return value!=null; + } + + /** Returns the value found during visiting, or null if none */ + public Object getResult() { return value; } + + public @Override String toString() { + return "a single value visitor (hash " + hashCode() + ") with current value " + value; + } + +} diff --git a/container-search/src/main/java/com/yahoo/search/query/profile/SubstituteString.java b/container-search/src/main/java/com/yahoo/search/query/profile/SubstituteString.java new file mode 100644 index 00000000000..59401592378 --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/profile/SubstituteString.java @@ -0,0 +1,127 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.query.profile; + +import com.yahoo.processing.request.Properties; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * A string which contains one or more elements of the form %{name}, + * where these occurrences are to be replaced by a query profile lookup on name. + * <p> + * This objects does the analysis on creation and provides a (reasonably) fast method of + * performing the actual substitution (at lookup time). + * <p> + * This is a value object. Lookups in this are thread safe. + * + * @author <a href="mailto:bratseth@yahoo-inc.com">Jon Bratseth</a> + */ +public class SubstituteString { + + private final List<Component> components; + private final String stringValue; + + /** + * Returns a new SubstituteString if the given string contains substitutions, null otherwise. + */ + public static SubstituteString create(String value) { + int lastEnd=0; + int start=value.indexOf("%{"); + if (start<0) return null; // Shortcut + List<Component> components=new ArrayList<>(); + while (start>=0) { + int end=value.indexOf("}",start+2); + if (end<0) + throw new IllegalArgumentException("Unterminated value substitution '" + value.substring(start) + "'"); + String propertyName=value.substring(start+2,end); + if (propertyName.indexOf("%{")>=0) + throw new IllegalArgumentException("Unterminated value substitution '" + value.substring(start) + "'"); + components.add(new StringComponent(value.substring(lastEnd,start))); + components.add(new PropertyComponent(propertyName)); + lastEnd=end+1; + start=value.indexOf("%{",lastEnd); + } + components.add(new StringComponent(value.substring(lastEnd,value.length()))); + return new SubstituteString(components, value); + } + + private SubstituteString(List<Component> components, String stringValue) { + this.components = components; + this.stringValue = stringValue; + } + + /** + * Perform the substitution in this, by looking up in the given query profile, + * and returns the resulting string + */ + public String substitute(Map<String,String> context,Properties substitution) { + StringBuilder b=new StringBuilder(); + for (Component component : components) + b.append(component.getValue(context,substitution)); + return b.toString(); + } + + @Override + public int hashCode() { + return stringValue.hashCode(); + } + + @Override + public boolean equals(Object other) { + if (other == this) return true; + if ( ! (other instanceof SubstituteString)) return false; + return this.stringValue.equals(((SubstituteString)other).stringValue); + } + + /** Returns this string in original (unsubstituted) form */ + public @Override String toString() { + return stringValue; + } + + private abstract static class Component { + + protected abstract String getValue(Map<String,String> context,Properties substitution); + + } + + private final static class StringComponent extends Component { + + private final String value; + + public StringComponent(String value) { + this.value=value; + } + + public @Override String getValue(Map<String,String> context,Properties substitution) { + return value; + } + + public @Override String toString() { + return value; + } + + } + + private final static class PropertyComponent extends Component { + + private final String propertyName; + + public PropertyComponent(String propertyName) { + this.propertyName=propertyName; + } + + public @Override String getValue(Map<String,String> context,Properties substitution) { + Object value=substitution.get(propertyName,context,substitution); + if (value==null) return ""; + return String.valueOf(value); + } + + public @Override String toString() { + return "%{" + propertyName + "}"; + } + + } + +} diff --git a/container-search/src/main/java/com/yahoo/search/query/profile/compiled/Binding.java b/container-search/src/main/java/com/yahoo/search/query/profile/compiled/Binding.java new file mode 100644 index 00000000000..a440365ceba --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/profile/compiled/Binding.java @@ -0,0 +1,128 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.query.profile.compiled; + +import com.yahoo.search.query.profile.DimensionBinding; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +/** + * An immutable binding of a set of dimensions to values. + * This binding is minimal in that it only includes dimensions which actually have values. + * + * @author <a href="mailto:bratseth@yahoo-inc.com">Jon Bratseth</a> + */ +public class Binding implements Comparable<Binding> { + + private static final int maxDimensions = 31; + + /** + * A higher number means this is more general. This accounts for both the number and position of the bindings + * in the dimensional space, such that bindings in earlier dimensions are matched before bindings in + * later dimensions + */ + private final int generality; + + /** The dimensions of this. Unenforced invariant: Content never changes. */ + private final String[] dimensions; + + /** The values of those dimensions. Unenforced invariant: Content never changes. */ + private final String[] dimensionValues; + + private final int hashCode; + + @SuppressWarnings("unchecked") + public static final Binding nullBinding= new Binding(Integer.MAX_VALUE, Collections.<String,String>emptyMap()); + + public static Binding createFrom(DimensionBinding dimensionBinding) { + if (dimensionBinding.getDimensions().size() > maxDimensions) + throw new IllegalArgumentException("More than 31 dimensions is not supported"); + + int generality = 0; + Map<String, String> context = new HashMap<>(); + if (dimensionBinding.getDimensions() == null || dimensionBinding.getDimensions().isEmpty()) { // TODO: Just have this return the nullBinding + generality = Integer.MAX_VALUE; + } + else { + for (int i = 0; i <= maxDimensions; i++) { + String value = i < dimensionBinding.getDimensions().size() ? dimensionBinding.getValues().get(i) : null; + if (value == null) + generality += Math.pow(2, maxDimensions - i-1); + else + context.put(dimensionBinding.getDimensions().get(i), value); + } + } + return new Binding(generality, context); + } + + private Binding(int generality, Map<String, String> binding) { + this.generality = generality; + + // Map -> arrays to limit memory consumption and speed up evaluation + dimensions = new String[binding.size()]; + dimensionValues = new String[binding.size()]; + + int i = 0; + int bindingHash = 0; + for (Map.Entry<String,String> entry : binding.entrySet()) { + dimensions[i] = entry.getKey(); + dimensionValues[i] = entry.getValue(); + bindingHash += i * entry.getKey().hashCode() + 11 * i * entry.getValue().hashCode(); + i++; + } + this.hashCode = bindingHash; + } + + /** Returns true only if this binding is null (contains no values for its dimensions (if any) */ + public boolean isNull() { return dimensions.length == 0; } + + @Override + public String toString() { + StringBuilder b = new StringBuilder("Binding["); + for (int i = 0; i < dimensions.length; i++) + b.append(dimensions[i]).append("=").append(dimensionValues[i]).append(","); + if (dimensions.length > 0) + b.setLength(b.length()-1); + b.append("] (generality " + generality + ")"); + return b.toString(); + } + + /** Returns whether the given binding has exactly the same values as this */ + @Override + public boolean equals(Object o) { + if (o == this) return true; + if (! (o instanceof Binding)) return false; + Binding other = (Binding)o; + return Arrays.equals(this.dimensions, other.dimensions) + && Arrays.equals(this.dimensionValues, other.dimensionValues); + } + + @Override + public int hashCode() { return hashCode; } + + /** + * Returns true if all the dimension values in this have the same values + * in the given context. + */ + public boolean matches(Map<String,String> context) { + for (int i = 0; i < dimensions.length; i++) { + if ( ! dimensionValues[i].equals(context.get(dimensions[i]))) return false; + } + return true; + } + + /** + * Implements a partial ordering where more specific bindings come before less specific ones, + * taking both the number of bindings and their positions into account (earlier dimensions + * take precedence over later ones. + * <p> + * The order is not well defined for bindings in different dimensional spaces. + */ + @Override + public int compareTo(Binding other) { + return Integer.compare(this.generality, other.generality); + } + +} diff --git a/container-search/src/main/java/com/yahoo/search/query/profile/compiled/CompiledQueryProfile.java b/container-search/src/main/java/com/yahoo/search/query/profile/compiled/CompiledQueryProfile.java new file mode 100644 index 00000000000..a4056ee55a2 --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/profile/compiled/CompiledQueryProfile.java @@ -0,0 +1,183 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.query.profile.compiled; + +import com.yahoo.component.AbstractComponent; +import com.yahoo.component.ComponentId; +import com.yahoo.processing.request.CompoundName; +import com.yahoo.processing.request.Properties; +import com.yahoo.search.query.profile.QueryProfileProperties; +import com.yahoo.search.query.profile.SubstituteString; +import com.yahoo.search.query.profile.types.QueryProfileType; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * A query profile in a state where it is optimized for fast lookups. + * + * @author bratseth + */ +public class CompiledQueryProfile extends AbstractComponent implements Cloneable { + + private static final Pattern namePattern=Pattern.compile("[$a-zA-Z_/][-$a-zA-Z0-9_/()]*"); + + private final CompiledQueryProfileRegistry registry; + + /** The type of this, or null if none */ + private final QueryProfileType type; + + /** The values of this */ + private final DimensionalMap<CompoundName, Object> entries; + + /** Keys which have a type in this */ + private final DimensionalMap<CompoundName, QueryProfileType> types; + + /** Keys which are (typed or untyped) references to other query profiles in this. Used as a set. */ + private final DimensionalMap<CompoundName, Object> references; + + /** Values which are not overridable in this. Used as a set. */ + private final DimensionalMap<CompoundName, Object> unoverridables; + + /** + * Creates a new query profile from an id. + */ + public CompiledQueryProfile(ComponentId id, QueryProfileType type, + DimensionalMap<CompoundName, Object> entries, + DimensionalMap<CompoundName, QueryProfileType> types, + DimensionalMap<CompoundName, Object> references, + DimensionalMap<CompoundName, Object> unoverridables, + CompiledQueryProfileRegistry registry) { + super(id); + this.registry = registry; + if (type != null) + type.freeze(); + this.type = type; + this.entries = entries; + this.types = types; + this.references = references; + this.unoverridables = unoverridables; + if ( ! id.isAnonymous()) + validateName(id.getName()); + } + + // ----------------- Public API ------------------------------------------------------------------------------- + + /** Returns the registry this belongs to, or null if none (in which case runtime profile reference assignment won't work) */ + public CompiledQueryProfileRegistry getRegistry() { return registry; } + + /** Returns the type of this or null if it has no type */ + // TODO: Move into below + public QueryProfileType getType() { return type; } + + /** + * Returns whether or not the given field name can be overridden at runtime. + * Attempts to override values which cannot be overridden will not fail but be ignored. + * Default: true. + * + * @param name the name of the field to check + * @param context the context in which to check, or null if none + */ + public final boolean isOverridable(CompoundName name, Map<String, String> context) { + return unoverridables.get(name, context) == null; + } + + /** Returns the type of a given prefix reachable from this profile, or null if none */ + public final QueryProfileType getType(CompoundName name, Map<String, String> context) { + return types.get(name, context); + } + + /** Returns the types reachable from this, or an empty map (never null) if none */ + public DimensionalMap<CompoundName, QueryProfileType> getTypes() { return types; } + + /** Returns the references reachable from this, or an empty map (never null) if none */ + public DimensionalMap<CompoundName, Object> getReferences() { return references; } + + /** + * Return all objects that start with the given prefix path using no context. Use "" to list all. + * <p> + * For example, if {a.d => "a.d-value" ,a.e => "a.e-value", b.d => "b.d-value", then calling listValues("a") + * will return {"d" => "a.d-value","e" => "a.e-value"} + */ + public final Map<String, Object> listValues(final CompoundName prefix) { return listValues(prefix, Collections.<String,String>emptyMap()); } + public final Map<String, Object> listValues(final String prefix) { return listValues(new CompoundName(prefix)); } + /** + * Return all objects that start with the given prefix path. Use "" to list all. + * <p> + * For example, if {a.d => "a.d-value" ,a.e => "a.e-value", b.d => "b.d-value", then calling listValues("a") + * will return {"d" => "a.d-value","e" => "a.e-value"} + */ + public final Map<String, Object> listValues(final String prefix,Map<String,String> context) { + return listValues(new CompoundName(prefix), context); + } + /** + * Return all objects that start with the given prefix path. Use "" to list all. + * <p> + * For example, if {a.d => "a.d-value" ,a.e => "a.e-value", b.d => "b.d-value", then calling listValues("a") + * will return {"d" => "a.d-value","e" => "a.e-value"} + */ + public final Map<String, Object> listValues(final CompoundName prefix,Map<String,String> context) { + return listValues(prefix, context, null); + } + /** + * Adds all objects that start with the given path prefix to the given value map. Use "" to list all. + * <p> + * For example, if {a.d => "a.d-value" ,a.e => "a.e-value", b.d => "b.d-value", then calling listValues("a") + * will return {"d" => "a.d-value","e" => "a.e-value"} + */ + public Map<String, Object> listValues(CompoundName prefix, Map<String,String> context, Properties substitution) { + Map<String, Object> values = new HashMap<>(); + for (Map.Entry<CompoundName, DimensionalValue<Object>> entry : entries.entrySet()) { + if ( entry.getKey().size() <= prefix.size()) continue; + if ( ! entry.getKey().hasPrefix(prefix)) continue; + + Object value = entry.getValue().get(context); + if (value == null) continue; + + value = substitute(value, context, substitution); + CompoundName suffixName = entry.getKey().rest(prefix.size()); + values.put(suffixName.toString(), value); + } + return values; + } + + public final Object get(String name) { + return get(name, Collections.<String,String>emptyMap()); + } + public final Object get(String name, Map<String,String> context) { + return get(name, context, new QueryProfileProperties(this)); + } + public final Object get(String name, Map<String,String> context, Properties substitution) { + return get(new CompoundName(name), context, substitution); + } + public final Object get(CompoundName name, Map<String, String> context, Properties substitution) { + return substitute(entries.get(name, context), context, substitution); + } + + private Object substitute(Object value, Map<String,String> context, Properties substitution) { + if (value == null) return value; + if (substitution == null) return value; + if (value.getClass() != SubstituteString.class) return value; + return ((SubstituteString)value).substitute(context, substitution); + } + + /** Throws IllegalArgumentException if the given string is not a valid query profile name */ + private static void validateName(String name) { + Matcher nameMatcher=namePattern.matcher(name); + if ( ! nameMatcher.matches()) + throw new IllegalArgumentException("Illegal name '" + name + "'"); + } + + @Override + public CompiledQueryProfile clone() { + return this; // immutable + } + + @Override + public String toString() { + return "query profile '" + getId() + "'" + (type!=null ? " of type '" + type.getId() + "'" : ""); + } + +} diff --git a/container-search/src/main/java/com/yahoo/search/query/profile/compiled/CompiledQueryProfileRegistry.java b/container-search/src/main/java/com/yahoo/search/query/profile/compiled/CompiledQueryProfileRegistry.java new file mode 100644 index 00000000000..91a81888267 --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/profile/compiled/CompiledQueryProfileRegistry.java @@ -0,0 +1,76 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.query.profile.compiled; + +import com.yahoo.component.ComponentSpecification; +import com.yahoo.component.provider.ComponentRegistry; +import com.yahoo.search.query.profile.types.QueryProfileType; +import com.yahoo.search.query.profile.types.QueryProfileTypeRegistry; + +/** + * A set of compiled query profiles. + * + * @author bratseth + */ +public class CompiledQueryProfileRegistry extends ComponentRegistry<CompiledQueryProfile> { + + private final QueryProfileTypeRegistry typeRegistry; + + /** Creates a compiled query profile registry with no types */ + public CompiledQueryProfileRegistry() { + this(QueryProfileTypeRegistry.emptyFrozen()); + } + + public CompiledQueryProfileRegistry(QueryProfileTypeRegistry typeRegistry) { + this.typeRegistry = typeRegistry; + } + + /** Registers a type by its id */ + public void register(CompiledQueryProfile profile) { + super.register(profile.getId(), profile); + } + + public QueryProfileTypeRegistry getTypeRegistry() { return typeRegistry; } + + /** + * <p>Returns a query profile for the given request string, or null if a suitable one is not found.</p> + * + * The request string must be a valid {@link com.yahoo.component.ComponentId} or null.<br> + * If the string is null, the profile named "default" is returned, or null if that does not exists. + * + * <p> + * The version part (if any) is matched used the usual component version patching rules. + * If the name part matches a query profile name perfectly, that profile is returned. + * If not, and the name is a slash-separated path, the profile with the longest matching left sub-path + * which has a type which allows path matching is used. If there is no such profile, null is returned. + */ + public CompiledQueryProfile findQueryProfile(String idString) { + if (idString==null || idString.isEmpty()) return getComponent("default"); + ComponentSpecification id=new ComponentSpecification(idString); + CompiledQueryProfile profile=getComponent(id); + if (profile!=null) return profile; + + return findPathParentQueryProfile(new ComponentSpecification(idString)); + } + + private CompiledQueryProfile findPathParentQueryProfile(ComponentSpecification id) { + // Try the name with "/" appended - should have the same semantics with path matching + CompiledQueryProfile slashedProfile=getComponent(new ComponentSpecification(id.getName() + "/",id.getVersionSpecification())); + if (slashedProfile!=null && slashedProfile.getType()!=null && slashedProfile.getType().getMatchAsPath()) + return slashedProfile; + + // Extract the parent (if any) + int slashIndex=id.getName().lastIndexOf("/"); + if (slashIndex<1) return null; + String parentName=id.getName().substring(0,slashIndex); + if (parentName.equals("")) return null; + + ComponentSpecification parentId=new ComponentSpecification(parentName,id.getVersionSpecification()); + + CompiledQueryProfile pathParentProfile=getComponent(parentId); + + if (pathParentProfile!=null && pathParentProfile.getType()!=null && pathParentProfile.getType().getMatchAsPath()) + return pathParentProfile; + return findPathParentQueryProfile(parentId); + } + +} diff --git a/container-search/src/main/java/com/yahoo/search/query/profile/compiled/DimensionalMap.java b/container-search/src/main/java/com/yahoo/search/query/profile/compiled/DimensionalMap.java new file mode 100644 index 00000000000..b82939fa4ac --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/profile/compiled/DimensionalMap.java @@ -0,0 +1,68 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.query.profile.compiled; + +import com.google.common.collect.ImmutableMap; +import com.yahoo.search.query.profile.DimensionBinding; + +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +/** + * A map which may return different values depending on the values given in a context + * supplied with the key on all operations. + * <p> + * Dimensional maps are immutable and created through a DimensionalMap.Builder + * + * @author <a href="mailto:bratseth@yahoo-inc.com">Jon Bratseth</a> + */ +public class DimensionalMap<KEY, VALUE> { + + private final Map<KEY, DimensionalValue<VALUE>> values; + + private DimensionalMap(Map<KEY, DimensionalValue<VALUE>> values) { + this.values = ImmutableMap.copyOf(values); + } + + /** Returns the value for this key matching a context, or null if none */ + public VALUE get(KEY key, Map<String, String> context) { + DimensionalValue<VALUE> variants = values.get(key); + if (variants == null) return null; + return variants.get(context); + } + + /** Returns the set of dimensional entries across all contexts. */ + public Set<Map.Entry<KEY, DimensionalValue<VALUE>>> entrySet() { + return values.entrySet(); + } + + /** Returns true if this is empty for all contexts. */ + public boolean isEmpty() { + return values.isEmpty(); + } + + public static class Builder<KEY, VALUE> { + + private Map<KEY, DimensionalValue.Builder<VALUE>> entries = new HashMap<>(); + + // TODO: DimensionBinding -> Binding? + public void put(KEY key, DimensionBinding binding, VALUE value) { + DimensionalValue.Builder<VALUE> entry = entries.get(key); + if (entry == null) { + entry = new DimensionalValue.Builder<>(); + entries.put(key, entry); + } + entry.add(value, binding); + } + + public DimensionalMap<KEY, VALUE> build() { + Map<KEY, DimensionalValue<VALUE>> map = new HashMap<>(); + for (Map.Entry<KEY, DimensionalValue.Builder<VALUE>> entry : entries.entrySet()) { + map.put(entry.getKey(), entry.getValue().build()); + } + return new DimensionalMap<>(map); + } + + } + +} diff --git a/container-search/src/main/java/com/yahoo/search/query/profile/compiled/DimensionalValue.java b/container-search/src/main/java/com/yahoo/search/query/profile/compiled/DimensionalValue.java new file mode 100644 index 00000000000..0112928ada6 --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/profile/compiled/DimensionalValue.java @@ -0,0 +1,159 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.query.profile.compiled; + +import com.yahoo.search.query.profile.DimensionBinding; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Contains the values a given key in a DimensionalMap may take for different dimensional contexts. + * + * @author <a href="mailto:bratseth@yahoo-inc.com">Jon Bratseth</a> + */ +public class DimensionalValue<VALUE> { + + private final List<Value<VALUE>> values; + + /** Create a set of variants which is a single value regardless of dimensions */ + public DimensionalValue(Value<VALUE> value) { + this.values = Collections.singletonList(value); + } + + public DimensionalValue(List<Value<VALUE>> valueVariants) { + if (valueVariants.size() == 1) { // special cased for efficiency + this.values = Collections.singletonList(valueVariants.get(0)); + } + else { + this.values = new ArrayList<>(valueVariants); + Collections.sort(this.values); + } + } + + /** Returns the value matching this context, or null if none */ + public VALUE get(Map<String, String> context) { + if (context == null) + context = Collections.emptyMap(); + for (Value<VALUE> value : values) { + if (value.matches(context)) + return value.value(); + } + return null; + } + + public boolean isEmpty() { return values.isEmpty(); } + + @Override + public String toString() { + return values.toString(); + } + + public static class Builder<VALUE> { + + /** The minimal set of variants needed to capture all values at this key */ + private Map<VALUE, Value.Builder<VALUE>> buildableVariants = new HashMap<>(); + + public void add(VALUE value, DimensionBinding variantBinding) { + // Note: We know we can index by the value because its possible types are constrained + // to what query profiles allow: String, primitives and query profiles + Value.Builder variant = buildableVariants.get(value); + if (variant == null) { + variant = new Value.Builder<>(value); + buildableVariants.put(value, variant); + } + variant.addVariant(variantBinding); + } + + public DimensionalValue<VALUE> build() { + List<Value> variants = new ArrayList<>(); + for (Value.Builder buildableVariant : buildableVariants.values()) { + variants.addAll(buildableVariant.build()); + } + return new DimensionalValue(variants); + } + + } + + /** A value for a particular binding */ + private static class Value<VALUE> implements Comparable<Value> { + + private VALUE value = null; + + /** The minimal binding this holds for */ + private Binding binding = null; + + public Value(VALUE value, Binding binding) { + this.value = value; + this.binding = binding; + } + + /** Returns the value at this entry or null if none */ + public VALUE value() { return value; } + + /** Returns the binding that must match for this to be a valid entry, or Binding.nullBinding if none */ + public Binding binding() { + if (binding == null) return Binding.nullBinding; + return binding; + } + + public boolean matches(Map<String, String> context) { + return binding.matches(context); + } + + @Override + public int compareTo(Value other) { + return this.binding.compareTo(other.binding); + } + + @Override + public String toString() { + return " value '" + value + "' for " + binding; + } + + /** + * A single value with the minimal set of dimension combinations it holds for. + */ + private static class Builder<VALUE> { + + private final VALUE value; + + /** + * The set of bindings this value is for. + * Some of these are more general versions of others. + * We need to keep both to allow interleaving a different value with medium generality. + */ + private Set<DimensionBinding> variants = new HashSet<>(); + + public Builder(VALUE value) { + this.value = value; + } + + /** Add a binding this holds for */ + public void addVariant(DimensionBinding binding) { + variants.add(binding); + } + + /** Build a separate value object for each dimension combination which has this value */ + public List<Value<VALUE>> build() { + // Shortcut for efficiency of the normal case + if (variants.size()==1) + return Collections.singletonList(new Value<>(value, Binding.createFrom(variants.iterator().next()))); + + List<Value<VALUE>> values = new ArrayList<>(variants.size()); + for (DimensionBinding variant : variants) + values.add(new Value<>(value, Binding.createFrom(variant))); + return values; + } + + public Object value() { + return value; + } + + } + } +} diff --git a/container-search/src/main/java/com/yahoo/search/query/profile/config/QueryProfileConfigurer.java b/container-search/src/main/java/com/yahoo/search/query/profile/config/QueryProfileConfigurer.java new file mode 100644 index 00000000000..5770665e3a1 --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/profile/config/QueryProfileConfigurer.java @@ -0,0 +1,227 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.query.profile.config; + +import com.yahoo.component.ComponentId; +import com.yahoo.component.ComponentSpecification; +import com.yahoo.config.subscription.ConfigSubscriber; +import com.yahoo.search.query.profile.DimensionValues; +import com.yahoo.search.query.profile.QueryProfile; +import com.yahoo.search.query.profile.QueryProfileRegistry; +import com.yahoo.search.query.profile.types.FieldDescription; +import com.yahoo.search.query.profile.types.FieldType; +import com.yahoo.search.query.profile.types.QueryProfileType; +import com.yahoo.search.query.profile.types.QueryProfileTypeRegistry; +import com.yahoo.text.BooleanParser; + +import java.util.HashSet; +import java.util.Set; + +/** + * @author bratseth + */ +public class QueryProfileConfigurer implements ConfigSubscriber.SingleSubscriber<QueryProfilesConfig> { + + private final ConfigSubscriber subscriber = new ConfigSubscriber(); + + private volatile QueryProfileRegistry currentRegistry; + + public QueryProfileConfigurer(String configId) { + subscriber.subscribe(this, QueryProfilesConfig.class, configId); + } + + /** Returns the registry created by the last occurring call to configure */ + public QueryProfileRegistry getCurrentRegistry() { return currentRegistry; } + + private void setCurrentRegistry(QueryProfileRegistry registry) { + this.currentRegistry=registry; + } + + public void configure(QueryProfilesConfig config) { + QueryProfileRegistry registry = createFromConfig(config); + setCurrentRegistry(registry); + } + + public static QueryProfileRegistry createFromConfig(QueryProfilesConfig config) { + QueryProfileRegistry registry=new QueryProfileRegistry(); + + // Pass 1: Create all profiles and profile types + for (QueryProfilesConfig.Queryprofiletype profileTypeConfig : config.queryprofiletype()) { + createProfileType(profileTypeConfig,registry.getTypeRegistry()); + } + for (QueryProfilesConfig.Queryprofile profileConfig : config.queryprofile()) { + createProfile(profileConfig,registry); + } + + // Pass 2: Resolve references and add content + for (QueryProfilesConfig.Queryprofiletype profileTypeConfig : config.queryprofiletype()) { + fillProfileType(profileTypeConfig,registry.getTypeRegistry()); + } + + // To ensure topological sorting, using DPS. This will _NOT_ detect cycles (but it will not fail if they + // exist either) + Set<ComponentId> filled = new HashSet<>(); + for (QueryProfilesConfig.Queryprofile profileConfig : config.queryprofile()) { + fillProfile(profileConfig, config, registry, filled); + } + + registry.freeze(); + return registry; + } + + /** Stop subscribing from this configurer */ + public void shutdown() { + subscriber.close(); + } + + private static void createProfile(QueryProfilesConfig.Queryprofile config,QueryProfileRegistry registry) { + QueryProfile profile=new QueryProfile(config.id()); + try { + String typeId=config.type(); + if (typeId!=null && !typeId.isEmpty()) + profile.setType(registry.getType(typeId)); + + if (config.dimensions().size()>0) { + String[] dimensions=new String[config.dimensions().size()]; + for (int i=0; i<config.dimensions().size(); i++) + dimensions[i]=config.dimensions().get(i); + profile.setDimensions(dimensions); + } + + registry.register(profile); + } + catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Invalid " + profile,e); + } + } + + private static void createProfileType(QueryProfilesConfig.Queryprofiletype config, QueryProfileTypeRegistry registry) { + QueryProfileType type=new QueryProfileType(config.id()); + type.setStrict(config.strict()); + type.setMatchAsPath(config.matchaspath()); + registry.register(type); + } + + private static void fillProfile(QueryProfilesConfig.Queryprofile config, + QueryProfilesConfig queryProfilesConfig, + QueryProfileRegistry registry, + Set<ComponentId> filled) { + QueryProfile profile=registry.getComponent(new ComponentSpecification(config.id()).toId()); + if (filled.contains(profile.getId())) return; + filled.add(profile.getId()); + try { + for (String inheritedId : config.inherit()) { + QueryProfile inherited=registry.getComponent(inheritedId); + if (inherited==null) + throw new IllegalArgumentException("Inherited query profile '" + inheritedId + "' in " + profile + " was not found"); + fillProfile(inherited, queryProfilesConfig, registry, filled); + profile.addInherited(inherited); + } + + for (QueryProfilesConfig.Queryprofile.Reference referenceConfig : config.reference()) { + QueryProfile referenced=registry.getComponent(referenceConfig.value()); + if (referenced==null) + throw new IllegalArgumentException("Query profile '" + referenceConfig.value() + "' referenced as '" + + referenceConfig.name() + "' in " + profile + " was not found"); + profile.set(referenceConfig.name(),referenced, registry); + if (referenceConfig.overridable()!=null && !referenceConfig.overridable().isEmpty()) + profile.setOverridable(referenceConfig.name(),BooleanParser.parseBoolean(referenceConfig.overridable()),null); + } + + for (QueryProfilesConfig.Queryprofile.Property propertyConfig : config.property()) { + profile.set(propertyConfig.name(),propertyConfig.value(), registry); + if (propertyConfig.overridable()!=null && !propertyConfig.overridable().isEmpty()) + profile.setOverridable(propertyConfig.name(),BooleanParser.parseBoolean(propertyConfig.overridable()),null); + } + + for (QueryProfilesConfig.Queryprofile.Queryprofilevariant variantConfig : config.queryprofilevariant()) { + String[] forDimensionValueArray=new String[variantConfig.fordimensionvalues().size()]; + for (int i=0; i<variantConfig.fordimensionvalues().size(); i++) { + forDimensionValueArray[i]=variantConfig.fordimensionvalues().get(i).trim(); + if ("*".equals(forDimensionValueArray[i])) + forDimensionValueArray[i]=null; + } + DimensionValues forDimensionValues=DimensionValues.createFrom(forDimensionValueArray); + + for (String inheritedId : variantConfig.inherit()) { + QueryProfile inherited=registry.getComponent(inheritedId); + if (inherited==null) + throw new IllegalArgumentException("Inherited query profile '" + inheritedId + "' in " + profile + + " for '" + forDimensionValues + "' was not found"); + fillProfile(inherited, queryProfilesConfig, registry, filled); + profile.addInherited(inherited, forDimensionValues); + } + + for (QueryProfilesConfig.Queryprofile.Queryprofilevariant.Reference referenceConfig : variantConfig.reference()) { + QueryProfile referenced=registry.getComponent(referenceConfig.value()); + if (referenced==null) + throw new IllegalArgumentException("Query profile '" + referenceConfig.value() + "' referenced as '" + + referenceConfig.name() + "' in " + profile + " for '" + forDimensionValues + "' was not found"); + profile.set(referenceConfig.name(), referenced, forDimensionValues, registry); + } + + for (QueryProfilesConfig.Queryprofile.Queryprofilevariant.Property propertyConfig : variantConfig.property()) { + profile.set(propertyConfig.name(), propertyConfig.value(), forDimensionValues, registry); + } + + } + + } + catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Invalid " + profile,e); + } + } + + /** Fill a given profile by locating its config */ + private static void fillProfile(QueryProfile inherited, + QueryProfilesConfig queryProfilesConfig, + QueryProfileRegistry registry, + Set<ComponentId> visited) { + for (QueryProfilesConfig.Queryprofile inheritedConfig : queryProfilesConfig.queryprofile()) { + if (inherited.getId().stringValue().equals(inheritedConfig.id())) { + fillProfile(inheritedConfig, queryProfilesConfig, registry, visited); + } + } + } + + private static void fillProfileType(QueryProfilesConfig.Queryprofiletype config,QueryProfileTypeRegistry registry) { + QueryProfileType type=registry.getComponent(new ComponentSpecification(config.id()).toId()); + try { + + for (String inheritedId : config.inherit()) { + QueryProfileType inherited=registry.getComponent(inheritedId); + if (inherited==null) + throw new IllegalArgumentException("Inherited query profile type '" + inheritedId + "' in " + type + " was not found"); + else + type.inherited().add(inherited); + + } + + for (QueryProfilesConfig.Queryprofiletype.Field fieldConfig : config.field()) + instantiateFieldDescription(fieldConfig,type,registry); + } + catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Invalid " + type,e); + } + } + + private static void instantiateFieldDescription(QueryProfilesConfig.Queryprofiletype.Field fieldConfig, + QueryProfileType type, + QueryProfileTypeRegistry registry) { + try { + FieldType fieldType=FieldType.fromString(fieldConfig.type(),registry); + FieldDescription field=new FieldDescription( + fieldConfig.name(), + fieldType, + fieldConfig.alias(), + fieldConfig.mandatory(), + fieldConfig.overridable() + ); + type.addField(field, registry); + } + catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Invalid field '" + fieldConfig.name() + "' in " + type,e); + } + } + + +} diff --git a/container-search/src/main/java/com/yahoo/search/query/profile/config/QueryProfileXMLReader.java b/container-search/src/main/java/com/yahoo/search/query/profile/config/QueryProfileXMLReader.java new file mode 100644 index 00000000000..97e3fb90dc9 --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/profile/config/QueryProfileXMLReader.java @@ -0,0 +1,366 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.query.profile.config; + +import com.yahoo.component.ComponentId; +import com.yahoo.component.ComponentSpecification; +import com.yahoo.io.reader.NamedReader; +import com.yahoo.search.query.profile.DimensionValues; +import com.yahoo.search.query.profile.QueryProfile; +import com.yahoo.search.query.profile.QueryProfileRegistry; +import com.yahoo.search.query.profile.types.FieldDescription; +import com.yahoo.search.query.profile.types.FieldType; +import com.yahoo.search.query.profile.types.QueryProfileType; +import com.yahoo.search.query.profile.types.QueryProfileTypeRegistry; +import com.yahoo.text.XML; +import org.w3c.dom.Element; + +import java.io.File; +import java.io.FileReader; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.logging.Logger; + +/** + * A class which imports query profiles and types from XML files + * + * @author bratseth + */ +public class QueryProfileXMLReader { + + private static Logger logger=Logger.getLogger(QueryProfileXMLReader.class.getName()); + + /** + * Reads all query profile xml files in a given directory, + * and all type xml files from the immediate subdirectory "types/" (if any) + * + * @throws RuntimeException if <code>directory</code> is not a readable directory, or if there is some error in the XML + */ + public QueryProfileRegistry read(String directory) { + List<NamedReader> queryProfileReaders=new ArrayList<>(); + List<NamedReader> queryProfileTypeReaders=new ArrayList<>(); + try { + File dir=new File(directory); + if ( !dir.isDirectory() ) throw new IllegalArgumentException("Could not read query profiles: '" + + directory + "' is not a valid directory."); + + for (File file : sortFiles(dir)) { + if ( ! file.getName().endsWith(".xml")) continue; + queryProfileReaders.add(new NamedReader(file.getName(),new FileReader(file))); + } + File typeDir=new File(dir,"types"); + if (typeDir.isDirectory()) { + for (File file : sortFiles(typeDir)) { + if ( ! file.getName().endsWith(".xml")) continue; + queryProfileTypeReaders.add(new NamedReader(file.getName(),new FileReader(file))); + } + } + + return read(queryProfileTypeReaders,queryProfileReaders); + } + catch (IOException e) { + throw new IllegalArgumentException("Could not read query profiles from '" + directory + "'",e); + } + finally { + closeAll(queryProfileReaders); + closeAll(queryProfileTypeReaders); + } + } + + private List<File> sortFiles(File dir) { + ArrayList<File> files = new ArrayList<>(); + files.addAll(Arrays.asList(dir.listFiles())); + Collections.sort(files); + return files; + } + + private void closeAll(List<NamedReader> readers) { + for (NamedReader reader : readers) { + try { reader.close(); } catch (IOException e) { } + } + } + + /** + * Read the XML file readers into a registry. This does not close the readers. + * This method is used directly from the admin system. + */ + public QueryProfileRegistry read(List<NamedReader> queryProfileTypeReaders,List<NamedReader> queryProfileReaders) { + QueryProfileRegistry registry=new QueryProfileRegistry(); + + // Phase 1 + List<Element> queryProfileTypeElements=createQueryProfileTypes(queryProfileTypeReaders,registry.getTypeRegistry()); + List<Element> queryProfileElements=createQueryProfiles(queryProfileReaders,registry); + + // Phase 2 + fillQueryProfileTypes(queryProfileTypeElements,registry.getTypeRegistry()); + fillQueryProfiles(queryProfileElements,registry); + return registry; + } + + public List<Element> createQueryProfileTypes(List<NamedReader> queryProfileTypeReaders, QueryProfileTypeRegistry registry) { + List<Element> queryProfileTypeElements=new ArrayList<>(queryProfileTypeReaders.size()); + for (NamedReader reader : queryProfileTypeReaders) { + Element root=XML.getDocument(reader).getDocumentElement(); + if ( ! root.getNodeName().equals("query-profile-type")) { + logger.info("Ignoring '" + reader.getName() + + "': Expected XML root element 'query-profile-type' but was '" + root.getNodeName() + "'"); + continue; + } + + String idString=root.getAttribute("id"); + if (idString==null || idString.equals("")) + throw new IllegalArgumentException("'" + reader.getName() + "' has no 'id' attribute in the root element"); + ComponentId id=new ComponentId(idString); + validateFileNameToId(reader.getName(),id,"query profile type"); + QueryProfileType type=new QueryProfileType(id); + type.setMatchAsPath(XML.getChild(root,"match") != null); + type.setStrict(XML.getChild(root,"strict") != null); + registry.register(type); + queryProfileTypeElements.add(root); + } + return queryProfileTypeElements; + } + + public List<Element> createQueryProfiles(List<NamedReader> queryProfileReaders, QueryProfileRegistry registry) { + List<Element> queryProfileElements=new ArrayList<>(queryProfileReaders.size()); + for (NamedReader reader : queryProfileReaders) { + Element root=XML.getDocument(reader).getDocumentElement(); + if ( ! root.getNodeName().equals("query-profile")) { + logger.info("Ignoring '" + reader.getName() + + "': Expected XML root element 'query-profile' but was '" + root.getNodeName() + "'"); + continue; + } + + String idString=root.getAttribute("id"); + if (idString==null || idString.equals("")) + throw new IllegalArgumentException("Query profile '" + reader.getName() + "' has no 'id' attribute in the root element"); + ComponentId id=new ComponentId(idString); + validateFileNameToId(reader.getName(),id,"query profile"); + + QueryProfile queryProfile=new QueryProfile(id); + String typeId=root.getAttribute("type"); + if (typeId!=null && ! typeId.equals("")) { + QueryProfileType type=registry.getType(typeId); + if (type==null) + throw new IllegalArgumentException("Query profile '" + reader.getName() + "': Type id '" + typeId + "' can not be resolved"); + queryProfile.setType(type); + } + + Element dimensions=XML.getChild(root,"dimensions"); + if (dimensions!=null) + queryProfile.setDimensions(toArray(XML.getValue(dimensions))); + + registry.register(queryProfile); + queryProfileElements.add(root); + } + return queryProfileElements; + } + + /** Throws an exception if the name is not corresponding to the id */ + private void validateFileNameToId(final String actualName,ComponentId id,String artifactName) { + String expectedCanonicalFileName=id.toFileName(); + String expectedAlternativeFileName=id.stringValue().replace(":","-").replace("/","_"); // legacy + String fileName=new File(actualName).getName(); + fileName=stripXmlEnding(fileName); + String canonicalFileName=ComponentId.fromFileName(fileName).toFileName(); + if ( ! canonicalFileName.equals(expectedCanonicalFileName) && ! canonicalFileName.equals(expectedAlternativeFileName)) + throw new IllegalArgumentException("The file name of " + artifactName + " '" + id + + "' must be '" + expectedCanonicalFileName + ".xml' but was '" + actualName + "'"); + } + + private String stripXmlEnding(String fileName) { + if (!fileName.endsWith(".xml")) + throw new IllegalArgumentException("'" + fileName + "' should have a .xml ending"); + else + return fileName.substring(0,fileName.length()-4); + } + + private String[] toArray(String csv) { + String[] array=csv.split(","); + for (int i=0; i<array.length; i++) + array[i]=array[i].trim(); + return array; + } + + public void fillQueryProfileTypes(List<Element> queryProfileTypeElements, QueryProfileTypeRegistry registry) { + for (Element element : queryProfileTypeElements) { + QueryProfileType type=registry.getComponent(new ComponentSpecification(element.getAttribute("id")).toId()); + try { + readInheritedTypes(element,type,registry); + readFieldDefinitions(element,type,registry); + } + catch (RuntimeException e) { + throw new IllegalArgumentException("Error reading " + type,e); + } + } + } + + private void readInheritedTypes(Element element,QueryProfileType type,QueryProfileTypeRegistry registry) { + String inheritedString=element.getAttribute("inherits"); + if (inheritedString==null || inheritedString.equals("")) return; + for (String inheritedId : inheritedString.split(" ")) { + inheritedId=inheritedId.trim(); + if (inheritedId.equals("")) continue; + QueryProfileType inheritedType=registry.getComponent(inheritedId); + if (inheritedType==null) throw new IllegalArgumentException("Could not resolve inherited query profile type '" + inheritedId); + type.inherited().add(inheritedType); + } + } + + private void readFieldDefinitions(Element element,QueryProfileType type,QueryProfileTypeRegistry registry) { + for (Element field : XML.getChildren(element,"field")) { + String name=field.getAttribute("name"); + if (name==null || name.equals("")) throw new IllegalArgumentException("A field has no 'name' attribute"); + try { + String fieldTypeName=field.getAttribute("type"); + if (fieldTypeName==null) throw new IllegalArgumentException("Field '" + field + "' has no 'type' attribute"); + FieldType fieldType=FieldType.fromString(fieldTypeName,registry); + type.addField(new FieldDescription(name,fieldType,field.getAttribute("alias"), + getBooleanAttribute("mandatory",false,field),getBooleanAttribute("overridable",true,field)), registry); + } + catch(RuntimeException e) { + throw new IllegalArgumentException("Invalid field '" + name + "'",e); + } + } + } + + public void fillQueryProfiles(List<Element> queryProfileElements, QueryProfileRegistry registry) { + for (Element element : queryProfileElements) { + // Lookup by exact id + QueryProfile profile=registry.getComponent(new ComponentSpecification(element.getAttribute("id")).toId()); + try { + readInherited(element,profile,registry,null,profile.toString()); + readFields(element,profile,registry,null,profile.toString()); + readVariants(element,profile,registry); + } + catch (RuntimeException e) { + throw new IllegalArgumentException("Error reading " + profile,e); + } + } + } + + private void readInherited(Element element,QueryProfile profile,QueryProfileRegistry registry,DimensionValues dimensionValues,String sourceDescription) { + String inheritedString=element.getAttribute("inherits"); + if (inheritedString==null || inheritedString.equals("")) return; + for (String inheritedId : inheritedString.split(" ")) { + inheritedId=inheritedId.trim(); + if (inheritedId.equals("")) continue; + QueryProfile inheritedProfile=registry.getComponent(inheritedId); + if (inheritedProfile==null) throw new IllegalArgumentException("Could not resolve inherited query profile '" + inheritedId + "' in " + sourceDescription); + profile.addInherited(inheritedProfile,dimensionValues); + } + } + + private void readFields(Element element,QueryProfile profile,QueryProfileRegistry registry,DimensionValues dimensionValues,String sourceDescription) { + List<KeyValue> references=new ArrayList<>(); + List<KeyValue> properties=new ArrayList<>(); + for (Element field : XML.getChildren(element,"field")) { + String name=field.getAttribute("name"); + if (name==null || name.equals("")) throw new IllegalArgumentException("A field in " + sourceDescription + " has no 'name' attribute"); + try { + Boolean overridable=getBooleanAttribute("overridable",null,field); + if (overridable!=null) + profile.setOverridable(name,overridable,null); + + Object fieldValue=readFieldValue(field,name,sourceDescription,registry); + if (fieldValue instanceof QueryProfile) + references.add(new KeyValue(name,fieldValue)); + else + properties.add(new KeyValue(name,fieldValue)); + } + catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Invalid field '" + name + "' in " + sourceDescription,e); + } + } + // Must set references before properties + for (KeyValue keyValue : references) + profile.set(keyValue.getKey() ,keyValue.getValue(), dimensionValues, registry); + for (KeyValue keyValue : properties) + profile.set(keyValue.getKey(), keyValue.getValue(), dimensionValues, registry); + + } + + private Object readFieldValue(Element field,String name,String targetDescription,QueryProfileRegistry registry) { + Element ref=XML.getChild(field,"ref"); + if (ref!=null) { + String referencedName=XML.getValue(ref); + QueryProfile referenced=registry.getComponent(referencedName); + if (referenced==null) + throw new IllegalArgumentException("Could not find query profile '" + referencedName + "' referenced as '" + + name + "' in " + targetDescription); + return referenced; + } + else { + return XML.getValue(field); + } + } + + private void readVariants(Element element,QueryProfile profile,QueryProfileRegistry registry) { + for (Element queryProfileVariantElement : XML.getChildren(element,"query-profile")) { // A "virtual" query profile contained inside another + List<String> dimensions=profile.getDimensions(); + if (dimensions==null) + throw new IllegalArgumentException("Cannot create a query profile variant in " + profile + + ", as it has not declared any variable dimensions"); + String dimensionString=queryProfileVariantElement.getAttribute("for"); + String[] dimensionValueArray=makeStarsNull(toArray(dimensionString)); + if (dimensions.size()<dimensionValueArray.length) + throw new IllegalArgumentException("Cannot create a query profile variant for '" + dimensionString + + "' as only " + dimensions.size() + " dimensions has been defined"); + DimensionValues dimensionValues=DimensionValues.createFrom(dimensionValueArray); + + String description="variant '" + dimensionString + "' in " + profile.toString(); + readInherited(queryProfileVariantElement,profile,registry,dimensionValues,description); + readFields(queryProfileVariantElement,profile,registry,dimensionValues,description); + } + } + + private String[] makeStarsNull(String[] strings) { + for (int i=0; i<strings.length; i++) + if (strings[i].equals("*")) + strings[i]=null; + return strings; + } + + /** + * Returns true if the string is "true".<br> + * Returns false if the string is "false".<br> + * Returns <code>default</code> if the string is null or empty (this parameter may be null)<br> + * @throws IllegalArgumentException if the string has any other value + */ + private Boolean asBoolean(String s,Boolean defaultValue) { + if (s==null) return defaultValue; + if (s.isEmpty()) return defaultValue; + if ("true".equals(s)) return true; + if ("false".equals(s)) return false; + throw new IllegalArgumentException("Expected 'true' or 'false' but was'" + s + "'"); + } + + /** Returns the given attribute as a boolean, using the semantics of {@link #asBoolean} */ + private Boolean getBooleanAttribute(String attributeName,Boolean defaultValue,Element from) { + try { + return asBoolean(from.getAttribute(attributeName),defaultValue); + } + catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Attribute '" + attributeName,e); + } + } + + private static class KeyValue { + + private String key; + private Object value; + + public KeyValue(String key,Object value) { + this.key=key; + this.value=value; + } + + public String getKey() { return key; } + + public Object getValue() { return value; } + + } + +} diff --git a/container-search/src/main/java/com/yahoo/search/query/profile/config/package-info.java b/container-search/src/main/java/com/yahoo/search/query/profile/config/package-info.java new file mode 100644 index 00000000000..8ea4e887661 --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/profile/config/package-info.java @@ -0,0 +1,5 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +@ExportPackage +package com.yahoo.search.query.profile.config; + +import com.yahoo.osgi.annotation.ExportPackage; diff --git a/container-search/src/main/java/com/yahoo/search/query/profile/package-info.java b/container-search/src/main/java/com/yahoo/search/query/profile/package-info.java new file mode 100644 index 00000000000..df3f4ac45ab --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/profile/package-info.java @@ -0,0 +1,12 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +/** + * Query Profiles provide nested sets of named (and optionally typed) key-values which can be referenced in a Query + * to proviode initial values of Query properties. Values in nested query profiles can be looked up from + * the query properties by dotting the names. Query profiles supports inheritance to allow variations + * for, e.g different buckets, client types, markets etc. */ +@ExportPackage +@PublicApi +package com.yahoo.search.query.profile; + +import com.yahoo.api.annotations.PublicApi; +import com.yahoo.osgi.annotation.ExportPackage; diff --git a/container-search/src/main/java/com/yahoo/search/query/profile/types/FieldDescription.java b/container-search/src/main/java/com/yahoo/search/query/profile/types/FieldDescription.java new file mode 100644 index 00000000000..c522ec04023 --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/profile/types/FieldDescription.java @@ -0,0 +1,148 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.query.profile.types; + +import com.google.common.collect.ImmutableList; +import com.yahoo.processing.request.CompoundName; +import com.yahoo.search.query.profile.QueryProfile; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +/** + * A field description of a query profile type. Immutable. + * Field descriptions can be sorted by name. + * + * @author bratseth + */ +public class FieldDescription implements Comparable<FieldDescription> { + + private final CompoundName name; + private final FieldType type; + private final List<String> aliases; + + /** If true, this value must be provided either in the query profile or in the search request */ + private final boolean mandatory; + + /** If true, assignments to this value from outside will be ignored */ + private final boolean overridable; + + public FieldDescription(String name, FieldType type) { + this(name,type,false); + } + + public FieldDescription(String name, String type) { + this(name,FieldType.fromString(type,null)); + } + + public FieldDescription(String name, FieldType type, boolean mandatory) { + this(name, type, mandatory, true); + } + + public FieldDescription(String name, String type, String aliases) { + this(name,type,aliases,false,true); + } + + public FieldDescription(String name, FieldType type, String aliases) { + this(name, type, aliases, false, true); + } + + /** + * Creates a field description + * + * @param name the name of the field + * @param typeString the type of the field represented as a string - see {@link com.yahoo.search.query.profile.types.FieldType} + * @param aliases a space-separated list of alias names of this field name. Aliases are not following dotted + * (meaning they are global, not that they cannot contain dots) and are case insensitive. Null is permissible + * if there are no aliases + * @param mandatory whether it is mandatory to provide a value for this field. default: false + * @param overridable whether this can be overridden when first set in a profile. Default: true + */ + public FieldDescription(String name, String typeString, String aliases, boolean mandatory, boolean overridable) { + this(name,FieldType.fromString(typeString,null),aliases,mandatory,overridable); + } + + public FieldDescription(String name, FieldType type, boolean mandatory, boolean overridable) { + this(name, type, null, mandatory, overridable); + } + + public FieldDescription(String name, FieldType type, String aliases, boolean mandatory, boolean overridable) { + this(new CompoundName(name), type, aliases, mandatory, overridable); + } + + /** + * Creates a field description from a list where the aliases are represented as a comma-separated string + */ + public FieldDescription(CompoundName name, FieldType type, String aliases, boolean mandatory, boolean overridable) { + this(name, type, toList(aliases), mandatory, overridable); + } + + /** + * Creates a field description + * + * @param name the name of the field + * @param type the type of the field represented as a string - see {@link com.yahoo.search.query.profile.types.FieldType} + * @param aliases a list of aliases, never null. Aliases are not following dotted + * (meaning they are global, not that they cannot contain dots) and are case insensitive. + * @param mandatory whether it is mandatory to provide a value for this field. default: false + * @param overridable whether this can be overridden when first set in a profile. Default: true + */ + public FieldDescription(CompoundName name, FieldType type, List<String> aliases, boolean mandatory, boolean overridable) { + if (name.isEmpty()) + throw new IllegalArgumentException("Illegal name ''"); + for (String nameComponent : name.asList()) + QueryProfile.validateName(nameComponent); + this.name = name; + this.type = type; + + // Forbidden until we can figure out the right semantics + if (name.isCompound() && ! aliases.isEmpty()) throw new IllegalArgumentException("Aliases is not allowed with compound names"); + + this.aliases = ImmutableList.copyOf(aliases); + this.mandatory = mandatory; + this.overridable = overridable; + } + + private static List<String> toList(String string) { + if (string == null || string.isEmpty()) return ImmutableList.of(); + return ImmutableList.copyOf(Arrays.asList(string.split(" "))); + } + + /** Returns the full name of this as a string */ + public String getName() { return name.toString(); } + + /** Returns the full name of this as a compound name */ + public CompoundName getCompoundName() { return name; } + + public FieldType getType() { return type; } + + /** Returns a unmodifiable list of the aliases of this. An empty list (never null) if there are none. */ + public List<String> getAliases() { return aliases; } + + /** Returns whether this field must be provided in the query profile or the search definition. Default: false */ + public boolean isMandatory() { return mandatory; } + + /** Returns false if overrides to values for this field from the outside should be ignored. Default: true */ + public boolean isOverridable() { return overridable; } + + public int compareTo(FieldDescription other) { + return name.toString().compareTo(other.name.toString()); + } + + /** Returns a copy of this with the name set to the argument name */ + public FieldDescription withName(CompoundName name) { + return new FieldDescription(name, type, aliases, mandatory, overridable); + } + + /** Returns a copy of this with the type set to the argument type */ + public FieldDescription withType(FieldType type) { + return new FieldDescription(name, type, aliases, mandatory, overridable); + } + + @Override + public String toString() { + return "field '" + name + "' type " + type.stringValue() + "" + + (mandatory?" (mandatory)":"") + (!overridable?" (not overridable)":""); + } + +} diff --git a/container-search/src/main/java/com/yahoo/search/query/profile/types/FieldType.java b/container-search/src/main/java/com/yahoo/search/query/profile/types/FieldType.java new file mode 100644 index 00000000000..abe3c4425ae --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/profile/types/FieldType.java @@ -0,0 +1,94 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.query.profile.types; + +import com.yahoo.search.query.profile.QueryProfile; +import com.yahoo.search.query.profile.QueryProfileRegistry; +import com.yahoo.search.query.profile.compiled.CompiledQueryProfileRegistry; +import com.yahoo.search.yql.YqlQuery; +import com.yahoo.tensor.Tensor; + +import java.util.Optional; + +/** + * Superclass of query type field types. + * Field types are immutable. + * + * @author bratseth + */ +@SuppressWarnings("rawtypes") +public abstract class FieldType { + + public static final PrimitiveFieldType stringType = new PrimitiveFieldType(String.class); + public static final PrimitiveFieldType integerType = new PrimitiveFieldType(Integer.class); + public static final PrimitiveFieldType longType = new PrimitiveFieldType(Long.class); + public static final PrimitiveFieldType floatType = new PrimitiveFieldType(Float.class); + public static final PrimitiveFieldType doubleType = new PrimitiveFieldType(Double.class); + public static final PrimitiveFieldType booleanType = new PrimitiveFieldType(Boolean.class); + public static final TensorFieldType genericTensorType = new TensorFieldType(Optional.empty()); + public static final QueryFieldType queryType = new QueryFieldType(); + public static final QueryProfileFieldType genericQueryProfileType = new QueryProfileFieldType(); + + /** Returns the class of instance values of this field type */ + public abstract Class getValueClass(); + + /** Returns a string representation of this type which can be converted back to a type class by {@link #fromString} */ + public abstract String stringValue(); + + public abstract String toString(); + + /** Returns a string describing possible instances of this type, suitable for user error messages */ + public abstract String toInstanceDescription(); + + /** Converts the given type to an instance of this type, if possible. Returns null if not possible. */ + public abstract Object convertFrom(Object o, QueryProfileRegistry registry); + + /** Converts the given type to an instance of this type, if possible. Returns null if not possible. */ + public abstract Object convertFrom(Object o, CompiledQueryProfileRegistry registry); + + /** + * Returns the field type for a given string name. + * + * @param typeString a type string - a primitive name, "query-profile" or "query-profile:profile-name" + * @param registry the registry in which query profile references are resolved when the last form above is used, + * or null in which case that form cannot be used + * @throws IllegalArgumentException if the string does not resolve to a type + */ + public static FieldType fromString(String typeString, QueryProfileTypeRegistry registry) { + if ("string".equals(typeString)) + return stringType; + if ("integer".equals(typeString)) + return integerType; + if ("long".equals(typeString)) + return longType; + if ("float".equals(typeString)) + return floatType; + if ("double".equals(typeString)) + return doubleType; + if ("boolean".equals(typeString)) + return booleanType; + if ("query".equals(typeString)) + return queryType; + if (typeString.startsWith("tensor")) + return TensorFieldType.fromTypeString(typeString); + if ("query-profile".equals(typeString)) + return genericQueryProfileType; + if (typeString.startsWith("query-profile:")) + return QueryProfileFieldType.fromString(typeString.substring("query-profile:".length()),registry); + throw new IllegalArgumentException("Unknown type '" + typeString + "'"); + } + + /** Returns the field type from a value class, or null if there is no type for it */ + public static FieldType fromClass(Class clazz) { + if (clazz == String.class) return stringType; + if (clazz == Integer.class) return integerType; + if (clazz == Long.class) return longType; + if (clazz == Float.class) return floatType; + if (clazz == Double.class) return doubleType; + if (clazz == Boolean.class) return booleanType; + if (clazz == Tensor.class) return genericTensorType; + if (clazz == YqlQuery.class) return queryType; + if (clazz == QueryProfile.class) return genericQueryProfileType; + return null; + } + +} diff --git a/container-search/src/main/java/com/yahoo/search/query/profile/types/PrimitiveFieldType.java b/container-search/src/main/java/com/yahoo/search/query/profile/types/PrimitiveFieldType.java new file mode 100644 index 00000000000..76b3f78ac2f --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/profile/types/PrimitiveFieldType.java @@ -0,0 +1,86 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.query.profile.types; + +import com.yahoo.search.query.profile.QueryProfileRegistry; +import com.yahoo.search.query.profile.compiled.CompiledQueryProfileRegistry; + +import static com.yahoo.text.Lowercase.toLowerCase; + +/** + * Represents a query field type which is a primitive - String, Integer, Float, Double or Long. + * + * @author bratseth + */ +@SuppressWarnings("rawtypes") +public class PrimitiveFieldType extends FieldType { + + private Class primitiveClass; + + PrimitiveFieldType(Class primitiveClass) { + this.primitiveClass=primitiveClass; + } + + public @Override Class getValueClass() { return primitiveClass; } + + public @Override String stringValue() { + return toLowerCase(primitiveClass.getSimpleName()); + } + + public @Override String toString() { return "field type " + stringValue(); } + + public @Override String toInstanceDescription() { + return toLowerCase(primitiveClass.getSimpleName()); + } + + @Override + public Object convertFrom(Object object, CompiledQueryProfileRegistry registry) { + return convertFrom(object, (QueryProfileRegistry)null); + } + + public @Override Object convertFrom(Object object, QueryProfileRegistry registry) { + if (primitiveClass == object.getClass()) return object; + + if (object.getClass() == String.class) return convertFromString((String)object); + if (object instanceof Number) return convertFromNumber((Number)object); + + return null; + } + + private Object convertFromString(String string) { + try { + if (primitiveClass==Integer.class) return Integer.valueOf(string); + if (primitiveClass==Double.class) return Double.valueOf(string); + if (primitiveClass==Float.class) return Float.valueOf(string); + if (primitiveClass==Long.class) return Long.valueOf(string); + if (primitiveClass==Boolean.class) return Boolean.valueOf(string); + } + catch (NumberFormatException e) { + return null; // Handled in caller + } + throw new RuntimeException("Programming error"); + } + + private Object convertFromNumber(Number number) { + if (primitiveClass==Integer.class) return number.intValue(); + if (primitiveClass==Double.class) return number.doubleValue(); + if (primitiveClass==Float.class) return number.floatValue(); + if (primitiveClass==Long.class) return number.longValue(); + if (primitiveClass==String.class) return String.valueOf(number); + throw new RuntimeException("Programming error: Input type is " + number.getClass() + + " primitiveClass is " + primitiveClass); + } + + @Override + public int hashCode() { + return primitiveClass.hashCode(); + } + + @Override + public boolean equals(Object o) { + if (o == this) return true; + if ( ! (o instanceof PrimitiveFieldType)) return false; + PrimitiveFieldType other = (PrimitiveFieldType)o; + return other.primitiveClass.equals(this.primitiveClass); + } + +} diff --git a/container-search/src/main/java/com/yahoo/search/query/profile/types/QueryFieldType.java b/container-search/src/main/java/com/yahoo/search/query/profile/types/QueryFieldType.java new file mode 100644 index 00000000000..a0982fdf0f6 --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/profile/types/QueryFieldType.java @@ -0,0 +1,41 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.query.profile.types; + +import com.yahoo.search.query.profile.QueryProfileRegistry; +import com.yahoo.search.query.profile.compiled.CompiledQueryProfileRegistry; +import com.yahoo.search.yql.YqlQuery; +import com.yahoo.tensor.MapTensor; +import com.yahoo.tensor.Tensor; + +/** + * A YQL query template field type in a query profile + * + * @author bratseth + */ +public class QueryFieldType extends FieldType { + + @Override + public Class getValueClass() { return YqlQuery.class; } + + @Override + public String stringValue() { return "query"; } + + @Override + public String toString() { return "field type " + stringValue(); } + + @Override + public String toInstanceDescription() { return "a YQL query template"; } + + @Override + public Object convertFrom(Object o, QueryProfileRegistry registry) { + if (o instanceof YqlQuery) return o; + if (o instanceof String) return YqlQuery.from((String)o); + return null; + } + + @Override + public Object convertFrom(Object o, CompiledQueryProfileRegistry registry) { + return convertFrom(o, (QueryProfileRegistry)null); + } + +} diff --git a/container-search/src/main/java/com/yahoo/search/query/profile/types/QueryProfileFieldType.java b/container-search/src/main/java/com/yahoo/search/query/profile/types/QueryProfileFieldType.java new file mode 100644 index 00000000000..df52e78c6ef --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/profile/types/QueryProfileFieldType.java @@ -0,0 +1,100 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.query.profile.types; + +import com.yahoo.search.query.profile.QueryProfile; +import com.yahoo.search.query.profile.QueryProfileRegistry; +import com.yahoo.search.query.profile.compiled.CompiledQueryProfile; +import com.yahoo.search.query.profile.compiled.CompiledQueryProfileRegistry; + +/** + * Represents a query profile field type which is a reference to a query profile. + * The reference may optionally specify the type of the referred query profile. + * + * @author bratseth + */ +public class QueryProfileFieldType extends FieldType { + + private final QueryProfileType type; + + public static QueryProfileFieldType fromString(String queryProfileName, QueryProfileTypeRegistry registry) { + if (queryProfileName==null || queryProfileName.equals("")) + return new QueryProfileFieldType(null); + + if (registry==null) + throw new IllegalArgumentException("Can not resolve query profile type '" + queryProfileName + + "' because no registry is provided"); + QueryProfileType queryProfileType=registry.getComponent(queryProfileName); + if (queryProfileType==null) + throw new IllegalArgumentException("Could not resolve query profile type '" + queryProfileName + "'"); + return new QueryProfileFieldType(registry.getComponent(queryProfileName)); + } + + public QueryProfileFieldType() { this(null); } + + public QueryProfileFieldType(QueryProfileType type) { + this.type = type; + } + + /** Returns the query profile type of this, or null if any type works */ + public QueryProfileType getQueryProfileType() { return type; } + + public @Override Class<?> getValueClass() { return QueryProfile.class; } + + public @Override String stringValue() { + return "query-profile" + (type!=null ? ":" + type.getId().getName() : ""); + } + + public @Override String toString() { + return "field type " + stringValue(); + } + + public @Override String toInstanceDescription() { + return "reference to a query profile" + (type!=null ? " of type '" + type.getId().getName() + "'" : ""); + } + + @Override + public CompiledQueryProfile convertFrom(Object object, CompiledQueryProfileRegistry registry) { + String profileId = object.toString(); + if (profileId.startsWith("ref:")) + profileId = profileId.substring("ref:".length()); + CompiledQueryProfile profile = registry.getComponent(profileId); + if (profile == null) return null; + if (type != null && ! type.equals(profile.getType())) return null; + return profile; + } + + @Override + public QueryProfile convertFrom(Object object, QueryProfileRegistry registry) { + QueryProfile profile; + if (object instanceof String) + profile = registry.getComponent((String)object); + else if (object instanceof QueryProfile) + profile = (QueryProfile)object; + else + return null; + + // Verify its type as well + if (type!=null && type!=profile.getType()) return null; + return profile; + } + + @Override + public int hashCode() { + if (type == null) return 17; + return type.hashCode(); + } + + @Override + public boolean equals(Object o) { + if (o == this) return true; + if ( ! (o instanceof QueryProfileFieldType)) return false; + QueryProfileFieldType other = (QueryProfileFieldType)o; + return equals(this.type.getId(), other.type.getId()); + } + + private boolean equals(Object o1, Object o2) { + if (o1 == null) return o2 == null; + return o1.equals(o2); + } + +} diff --git a/container-search/src/main/java/com/yahoo/search/query/profile/types/QueryProfileType.java b/container-search/src/main/java/com/yahoo/search/query/profile/types/QueryProfileType.java new file mode 100644 index 00000000000..ecf60f8723d --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/profile/types/QueryProfileType.java @@ -0,0 +1,355 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.query.profile.types; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.yahoo.component.ComponentId; +import com.yahoo.component.provider.FreezableSimpleComponent; +import com.yahoo.processing.request.CompoundName; +import com.yahoo.search.query.profile.QueryProfile; + +import java.util.*; + +import static com.yahoo.text.Lowercase.toLowerCase; + +/** + * Defines a kind of query profiles + * + * @author bratseth + */ +public class QueryProfileType extends FreezableSimpleComponent { + + /** The fields of this query profile type */ + private Map<String, FieldDescription> fields = new HashMap<>(); + + /** The query profile types this inherits */ + private List<QueryProfileType> inherited = new ArrayList<>(); + + /** If this is true, keys which are not declared in this type cannot be set in instances */ + private boolean strict = false; + + /** True if the name of instances of this profile should be matched as path names, see QueryProfileRegistry */ + private boolean matchAsPath = false; + + private boolean builtin = false; + + /** Aliases *from* any strings *to* field names. Aliases are case insensitive */ + private Map<String, String> aliases = null; + + public QueryProfileType(String idString) { + this(new ComponentId(idString)); + } + + public QueryProfileType(ComponentId id) { + super(id); + QueryProfile.validateName(id.getName()); + } + + private QueryProfileType(ComponentId id, Map<String, FieldDescription> fields, List<QueryProfileType> inherited, + boolean strict, boolean matchAsPath, boolean builtin, Map<String,String> aliases) { + super(id); + this.fields = new HashMap<>(fields); + this.inherited = new ArrayList<>(inherited); + this.strict = strict; + this.matchAsPath = matchAsPath; + this.builtin = builtin; + this.aliases = aliases == null ? null : new HashMap<>(aliases); + } + + /** Return this is it is not frozen, returns a modifiable deeply unfrozen copy otherwise */ + public QueryProfileType unfrozen() { + if ( ! isFrozen()) return this; + + // Unfreeze inherited query profile references + List<QueryProfileType> unfrozenInherited = new ArrayList<>(); + for (QueryProfileType inheritedType : inherited) { + unfrozenInherited.add(inheritedType.unfrozen()); + } + + // Unfreeze nested query profile references + Map<String, FieldDescription> unfrozenFields = new HashMap<>(); + for (Map.Entry<String, FieldDescription> field : fields.entrySet()) { + FieldDescription unfrozenFieldValue = field.getValue(); + if (field.getValue().getType() instanceof QueryProfileFieldType) { + QueryProfileFieldType queryProfileFieldType = (QueryProfileFieldType)field.getValue().getType(); + if (queryProfileFieldType.getQueryProfileType() != null) { + QueryProfileFieldType unfrozenType = + new QueryProfileFieldType(queryProfileFieldType.getQueryProfileType().unfrozen()); + unfrozenFieldValue = field.getValue().withType(unfrozenType); + } + } + unfrozenFields.put(field.getKey(), unfrozenFieldValue); + } + + return new QueryProfileType(getId(), unfrozenFields, unfrozenInherited, strict, matchAsPath, builtin, aliases); + } + + /** Mark this type as built into the system. Do not use */ + public void setBuiltin(boolean builtin) { this.builtin=builtin; } + + /** Returns whether this type is built into the system */ + public boolean isBuiltin() { return builtin; } + + /** + * Returns the query profile types inherited from this (never null). + * If this profile type is not frozen, this list can be modified to change the set of inherited types. + * If it is frozen, the returned list is immutable. + */ + public List<QueryProfileType> inherited() { return inherited; } + + /** + * Returns the fields declared in this (i.e not including those inherited) as an immutable map. + * + * @throws IllegalStateException if this is frozen + */ + public Map<String,FieldDescription> declaredFields() { + ensureNotFrozen(); + return Collections.unmodifiableMap(fields); + } + + /** + * Returns true if <i>this</i> is declared strict. + * @throws IllegalStateException if this is frozen + */ + public boolean isDeclaredStrict() { + ensureNotFrozen(); + return strict; + } + + /** + * Returns true if <i>this</i> is declared as match as path. + * @throws IllegalStateException if this is frozen + */ + public boolean getDeclaredMatchAsPath() { + ensureNotFrozen(); + return matchAsPath; + } + + /** Set whether nondeclared fields are permissible. Throws an exception if this is frozen. */ + public void setStrict(boolean strict) { + ensureNotFrozen(); + this.strict=strict; + } + + /** Returns whether field not declared in this type is permissible in instances. Default is false: Additional values are allowed */ + public boolean isStrict() { + if (isFrozen()) return strict; + + // Check if any of this or an inherited is true + if (strict) return true; + for (QueryProfileType inheritedType : inherited) + if (inheritedType.isStrict()) return true; + return false; + } + + /** Returns whether instances of this should be matched as path names. Throws if this is frozen. */ + public void setMatchAsPath(boolean matchAsPath) { + ensureNotFrozen(); + this.matchAsPath=matchAsPath; + } + + /** Returns whether instances of this should be matched as path names. Default is false: Use exact name matching. */ + public boolean getMatchAsPath() { + if (isFrozen()) return matchAsPath; + + // Check if any of this or an inherited is true + if (matchAsPath) return true; + for (QueryProfileType inheritedType : inherited) + if (inheritedType.getMatchAsPath()) return true; + return false; + } + + public void freeze() { + if (isFrozen()) return; + // Flatten the inheritance hierarchy into this to facilitate faster lookup + for (QueryProfileType inheritedType : inherited) { + for (FieldDescription field : inheritedType.fields().values()) + if ( ! fields.containsKey(field.getName())) + fields.put(field.getName(),field); + } + fields = ImmutableMap.copyOf(fields); + inherited = ImmutableList.copyOf(inherited); + strict = isStrict(); + matchAsPath = getMatchAsPath(); + super.freeze(); + } + + /** + * Returns whether the given field name is overridable in this type. + * Default: true (so all non-declared fields returns true) + */ + public boolean isOverridable(String fieldName) { + FieldDescription field=getField(fieldName); + if (field==null) return true; + return field.isOverridable(); + } + + /** + * Returns the permissible class for the value of the given name in this type + * + * @return the permissible class for a value, <code>Object</code> if all types are legal, + * null if no types are legal (i.e if the name is not legal) + */ + public Class<?> getValueClass(String name) { + FieldDescription fieldDescription=getField(name); + if (fieldDescription==null) { + if (strict) + return null; // Undefined -> Not legal + else + return Object.class; // Undefined -> Anything is legal + } + return fieldDescription.getType().getValueClass(); + } + + /** Returns the type of the given query profile type declared as a field in this */ + public QueryProfileType getType(String localName) { + FieldDescription fieldDescription=getField(localName); + if (fieldDescription ==null) return null; + if ( ! (fieldDescription.getType() instanceof QueryProfileFieldType)) return null; + return ((QueryProfileFieldType) fieldDescription.getType()).getQueryProfileType(); + } + + /** + * Returns the description of the field with the given name in this type or an inherited type + * (depth first left to right search). Returns null if the field is not defined in this or an inherited profile. + */ + public FieldDescription getField(String name) { + FieldDescription field=fields.get(name); + if ( field!=null ) return field; + + if ( isFrozen() ) return null; // Inherited are collapsed into this + + for (QueryProfileType inheritedType : this.inherited() ) { + field=inheritedType.getField(name); + if (field!=null) return field; + } + + return null; + } + + /** + * Removes a field from this (not from any inherited profile) + * + * @return the removed field or null if none + * @throws IllegalStateException if this is frozen + */ + public FieldDescription removeField(String fieldName) { + ensureNotFrozen(); + return fields.remove(fieldName); + } + + /** + * Adds a field to this, without associating with a type registry; field descriptions with compound + * is not be supported. + * + * @throws IllegalStateException if this is frozen + */ + public void addField(FieldDescription fieldDescription) { + // Compound names translates to new types, which must be added to a supplied registry + if (fieldDescription.getCompoundName().isCompound()) + throw new IllegalArgumentException("Adding compound names is only legal when supplying a registry"); + addField(fieldDescription, null); + } + + /** + * Adds a field to this + * + * @throws IllegalStateException if this is frozen + */ + public void addField(FieldDescription fieldDescription, QueryProfileTypeRegistry registry) { + CompoundName name = fieldDescription.getCompoundName(); + if (name.isCompound()) { + // Add (/to) a query profile type containing the rest of the name. + // (we do not need the field description settings for intermediate query profile types + // as the leaf entry will enforce them) + QueryProfileType type = getOrCreateQueryProfileType(name.first(), registry); + type.addField(fieldDescription.withName(name.rest()), registry); + } + else { + ensureNotFrozen(); + fields.put(fieldDescription.getName(), fieldDescription); + } + + for (String alias : fieldDescription.getAliases()) + addAlias(alias, fieldDescription.getName()); + } + + private QueryProfileType getOrCreateQueryProfileType(String name, QueryProfileTypeRegistry registry) { + FieldDescription fieldDescription = getField(name); + if (fieldDescription != null) { + if ( ! ( fieldDescription.getType() instanceof QueryProfileFieldType)) + throw new IllegalArgumentException("Cannot use name '" + name + "' as a prefix because it is " + + "already a " + fieldDescription.getType()); + QueryProfileFieldType fieldType = (QueryProfileFieldType) fieldDescription.getType(); + QueryProfileType type = fieldType.getQueryProfileType(); + if (type == null) { // an as-yet untyped reference; add type + type = new QueryProfileType(name); + registry.register(type.getId(), type); + fields.put(name, fieldDescription.withType(new QueryProfileFieldType(type))); + } + return type; + } + else { + QueryProfileType type = new QueryProfileType(name); + registry.register(type.getId(), type); + fields.put(name, new FieldDescription(name, new QueryProfileFieldType(type))); + return type; + } + } + + private void addAlias(String alias,String field) { + ensureNotFrozen(); + if (aliases==null) + aliases=new HashMap<>(); + aliases.put(toLowerCase(alias),field); + } + + /** Returns all the fields of this profile type and all types it inherits as a read-only map */ + public Map<String,FieldDescription> fields() { + if (isFrozen()) return fields; + if (inherited().size()==0) return Collections.unmodifiableMap(fields); + + // Collapse inherited + Map<String,FieldDescription> allFields=new HashMap<>(fields); + for (QueryProfileType inheritedType : inherited) + allFields.putAll(inheritedType.fields()); + return Collections.unmodifiableMap(allFields); + } + + /** + * Returns the alias to field mapping of this type as a read-only map. This is never null. + * Note that all keys are lower-cased because aliases are case-insensitive + */ + public Map<String,String> aliases() { + if (isFrozen()) return aliases; + if (aliases == null) return Collections.emptyMap(); + return Collections.unmodifiableMap(aliases); + } + + /** Returns the field name of an alias or field name */ + public String unalias(String aliasOrField) { + if (aliases==null || aliases.isEmpty()) return aliasOrField; + String field=aliases.get(toLowerCase(aliasOrField)); + if (field!=null) return field; + return aliasOrField; + } + + @Override + public int hashCode() { + return getId().hashCode(); + } + + /** Two types are equal if they have the same id */ + @Override + public boolean equals(Object o) { + if (o == this) return true; + if ( ! (o instanceof QueryProfileType)) return false; + QueryProfileType other = (QueryProfileType)o; + return other.getId().equals(this.getId()); + } + + public String toString() { + return "query profile type '" + getId() + "'"; + } + +} diff --git a/container-search/src/main/java/com/yahoo/search/query/profile/types/QueryProfileTypeRegistry.java b/container-search/src/main/java/com/yahoo/search/query/profile/types/QueryProfileTypeRegistry.java new file mode 100644 index 00000000000..3f64caa7ab1 --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/profile/types/QueryProfileTypeRegistry.java @@ -0,0 +1,37 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.query.profile.types; + +import com.yahoo.component.provider.ComponentRegistry; +import com.yahoo.search.Query; +import com.yahoo.search.query.profile.QueryProfileRegistry; + +/** + * A registry of query profile types + * + * @author bratseth + */ +public class QueryProfileTypeRegistry extends ComponentRegistry<QueryProfileType> { + + public QueryProfileTypeRegistry() { + Query.addNativeQueryProfileTypesTo(this); + } + + /** Register this type by its id */ + public void register(QueryProfileType type) { + super.register(type.getId(), type); + } + + @Override + public void freeze() { + if (isFrozen()) return; + for (QueryProfileType queryProfileType : allComponents()) + queryProfileType.freeze(); + } + + public static QueryProfileTypeRegistry emptyFrozen() { + QueryProfileTypeRegistry registry = new QueryProfileTypeRegistry(); + registry.freeze(); + return registry; + } + +} diff --git a/container-search/src/main/java/com/yahoo/search/query/profile/types/TensorFieldType.java b/container-search/src/main/java/com/yahoo/search/query/profile/types/TensorFieldType.java new file mode 100644 index 00000000000..747cf73acb3 --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/profile/types/TensorFieldType.java @@ -0,0 +1,59 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.query.profile.types; + +import com.yahoo.search.query.profile.QueryProfileRegistry; +import com.yahoo.search.query.profile.compiled.CompiledQueryProfileRegistry; +import com.yahoo.tensor.MapTensor; +import com.yahoo.tensor.Tensor; +import com.yahoo.tensor.TensorType; + +import java.util.Optional; + +/** + * A tensor field type in a query profile + * + * @author bratseth + */ +public class TensorFieldType extends FieldType { + + private final Optional<TensorType> type; + + /** Creates a tensor field type with optional information about the kind of tensor this will hold */ + public TensorFieldType(Optional<TensorType> type) { + this.type = type; + } + + /** Returns information about the type of tensor this will hold, or empty to allow any kind of tensor */ + public Optional<TensorType> type() { return type; } + + @Override + public Class getValueClass() { return Tensor.class; } + + @Override + public String stringValue() { return "tensor"; } + + @Override + public String toString() { return "field type " + stringValue(); } + + @Override + public String toInstanceDescription() { return "a tensor"; } + + @Override + public Object convertFrom(Object o, QueryProfileRegistry registry) { + if (o instanceof Tensor) return o; + if (o instanceof String) return MapTensor.from((String)o); + return null; + } + + @Override + public Object convertFrom(Object o, CompiledQueryProfileRegistry registry) { + return convertFrom(o, (QueryProfileRegistry)null); + } + + public static TensorFieldType fromTypeString(String s) { + if (s.equals("tensor")) return genericTensorType; + return new TensorFieldType(Optional.of(TensorType.fromSpec(s))); + } + + +} diff --git a/container-search/src/main/java/com/yahoo/search/query/profile/types/package-info.java b/container-search/src/main/java/com/yahoo/search/query/profile/types/package-info.java new file mode 100644 index 00000000000..1f9fa7a1fb4 --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/profile/types/package-info.java @@ -0,0 +1,11 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +/** + * Query profile types defines the set of fields a query profile may, can or must have. Query profile + * types may be inherited in a type hierarchy. + */ +@ExportPackage +@PublicApi +package com.yahoo.search.query.profile.types; + +import com.yahoo.api.annotations.PublicApi; +import com.yahoo.osgi.annotation.ExportPackage; diff --git a/container-search/src/main/java/com/yahoo/search/query/properties/DefaultProperties.java b/container-search/src/main/java/com/yahoo/search/query/properties/DefaultProperties.java new file mode 100644 index 00000000000..01c861b879e --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/properties/DefaultProperties.java @@ -0,0 +1,40 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.query.properties; + +import com.yahoo.processing.request.CompoundName; +import com.yahoo.search.query.Properties; +import com.yahoo.search.query.profile.types.FieldDescription; +import com.yahoo.search.query.profile.types.QueryProfileType; + +import java.util.Map; + +/** + * Default values for properties that are meant to be customized in query profiles. + * @author tonytv + */ +public final class DefaultProperties extends Properties { + public static final CompoundName MAX_OFFSET = new CompoundName("maxOffset"); + public static final CompoundName MAX_HITS = new CompoundName("maxHits"); + + + public static final QueryProfileType argumentType = new QueryProfileType("DefaultProperties"); + static { + argumentType.setBuiltin(true); + + argumentType.addField(new FieldDescription(MAX_OFFSET.toString(), "integer")); + argumentType.addField(new FieldDescription(MAX_HITS.toString(), "integer")); + + argumentType.freeze(); + } + + @Override + public Object get(CompoundName name, Map<String, String> context, com.yahoo.processing.request.Properties substitution) { + if (MAX_OFFSET.equals(name)) { + return 1000; + } else if (MAX_HITS.equals(name)) { + return 400; + } else { + return super.get(name, context, substitution); + } + } +} diff --git a/container-search/src/main/java/com/yahoo/search/query/properties/PropertyAliases.java b/container-search/src/main/java/com/yahoo/search/query/properties/PropertyAliases.java new file mode 100644 index 00000000000..cc2c08c5504 --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/properties/PropertyAliases.java @@ -0,0 +1,58 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.query.properties; + +import com.yahoo.processing.request.CompoundName; +import com.yahoo.search.query.Properties; + +import java.util.Map; + +/** + * A properties implementation which translates the incoming name to its standard name + * if it is a registered alias. + * <p> + * Aliases are case insensitive. One standard name may have multiple aliases. + * <p> + * This is multithread safe or not depending on the status of the passed map of aliases. + * Cloning will not deep copy the set of aliases. + * + * @author <a href="mailto:bratseth@yahoo-inc.com">Jon Bratseth</a> + */ +public class PropertyAliases extends Properties { + + /** A map from aliases to standard names */ + private final Map<String,CompoundName> aliases; + + /** + * Creates an instance with a set of aliases. The given aliases will be used directly by this class. + * To make this class immutable and thread safe, relinquish ownership of the parameter map. + */ + public PropertyAliases(Map<String,CompoundName> aliases) { + this.aliases=aliases; + } + + /** + * Returns the standard name for an alias, or the given name if it is not a registered alias + * + * @param nameOrAlias the name to check if is an alias + * @return the real name if an alias or the input name itself + */ + protected CompoundName unalias(CompoundName nameOrAlias) { + CompoundName properName = aliases.get(nameOrAlias.getLowerCasedName()); + return (properName != null) ? properName : nameOrAlias; + } + + public @Override Map<String, Object> listProperties(CompoundName property,Map<String,String> context, + com.yahoo.processing.request.Properties substitution) { + return super.listProperties(unalias(property),context,substitution); + } + + public @Override Object get(CompoundName name,Map<String,String> context, + com.yahoo.processing.request.Properties substitution) { + return super.get(unalias(name),context,substitution); + } + + public @Override void set(CompoundName name,Object value,Map<String,String> context) { + super.set(unalias(name),value,context); + } + +} diff --git a/container-search/src/main/java/com/yahoo/search/query/properties/PropertyMap.java b/container-search/src/main/java/com/yahoo/search/query/properties/PropertyMap.java new file mode 100644 index 00000000000..820c4fc8ea3 --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/properties/PropertyMap.java @@ -0,0 +1,132 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.query.properties; + +import com.yahoo.processing.request.CompoundName; +import com.yahoo.search.query.Properties; +import com.yahoo.search.result.Hit; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedList; +import java.util.Map; +import java.util.logging.Logger; + +/** + * A Map backing of Properties. + * <p> + * When this is cloned it will deep copy not only the model object map, but also each + * clonable member inside the map. + * <p> + * Subclassing is supported, a hook can be implemented to provide conditional inclusion in the map. + * By default - all properties are accepted, so set is never propagated. + * <p> + * This class is not multithread safe. + * + * @author bratseth + */ +public class PropertyMap extends Properties { + + private static Logger log=Logger.getLogger(PropertyMap.class.getName()); + + /** The properties of this */ + private Map<CompoundName, Object> properties = new LinkedHashMap<>(); + + public void set(CompoundName name, Object value, Map<String,String> context) { + if (shouldSet(name, value)) + properties.put(name, value); + else + super.set(name, value, context); + } + + /** + * Return true if this value should be set in this map, false if the set should be propagated instead + * This default implementation always returns true. + */ + protected boolean shouldSet(CompoundName name,Object value) { return true; } + + public @Override Object get(CompoundName name, Map<String,String> context, + com.yahoo.processing.request.Properties substitution) { + if ( ! properties.containsKey(name)) return super.get(name,context,substitution); + return properties.get(name); + } + + /** + * Returns a direct reference to the map containing the properties set in this instance. + */ + public Map<CompoundName, Object> propertyMap() { + return properties; + } + + public @Override PropertyMap clone() { + PropertyMap clone = (PropertyMap)super.clone(); + clone.properties = new HashMap<>(); + for (Map.Entry<CompoundName, Object> entry : this.properties.entrySet()) { + Object cloneValue = clone(entry.getValue()); + if (cloneValue == null) + cloneValue = entry.getValue(); // Shallow copy objects which does not support cloning + clone.properties.put(entry.getKey(), cloneValue); + } + return clone; + } + + /** Clones this object if it is clonable, and the clone is public. Returns null if not */ + public static Object clone(Object object) { + if (object==null) return null; + if (! ( object instanceof Cloneable) ) return null; + if (object instanceof Object[]) + return arrayClone((Object[])object); + else + return objectClone(object); + } + + private static Object arrayClone(Object[] object) { + Object[] arrayClone= Arrays.copyOf(object, object.length); + // deep clone + for (int i=0; i<arrayClone.length; i++) { + Object elementClone=clone(arrayClone[i]); + if (elementClone!=null) + arrayClone[i]=elementClone; + } + return arrayClone; + } + + private static Object objectClone(Object object) { + if (object instanceof Hit) { + return ((Hit) object).clone(); + } else if (object instanceof LinkedList) { + return ((LinkedList) object).clone(); + } + try { + Method cloneMethod=object.getClass().getMethod("clone"); + return cloneMethod.invoke(object); + } + catch (NoSuchMethodException e) { + log.warning("'" + object + "' is Cloneable, but has no clone method - will use the same instance in all requests"); + return null; + } + catch (IllegalAccessException e) { + log.warning("'" + object + "' is Cloneable, but clone method cannot be accessed - will use the same instance in all requests"); + return null; + } + catch (InvocationTargetException e) { + throw new RuntimeException("Exception cloning '" + object + "'",e); + } + } + + @Override + public Map<String, Object> listProperties(CompoundName path, Map<String, String> context, com.yahoo.processing.request.Properties substitution) { + Map<String, Object> map = super.listProperties(path, context, substitution); + + for (Map.Entry<CompoundName, Object> entry : properties.entrySet()) { + if ( ! entry.getKey().hasPrefix(path)) continue; + CompoundName propertyName = entry.getKey().rest(path.size()); + if (propertyName.isEmpty()) continue; + map.put(propertyName.toString(), entry.getValue()); + } + return map; + } + +} diff --git a/container-search/src/main/java/com/yahoo/search/query/properties/QueryProperties.java b/container-search/src/main/java/com/yahoo/search/query/properties/QueryProperties.java new file mode 100644 index 00000000000..cd4e02dc768 --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/properties/QueryProperties.java @@ -0,0 +1,296 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.query.properties; + +import com.yahoo.component.ComponentId; +import com.yahoo.processing.request.CompoundName; +import com.yahoo.search.Query; +import com.yahoo.search.query.*; +import com.yahoo.search.query.profile.compiled.CompiledQueryProfileRegistry; +import com.yahoo.search.query.profile.types.FieldDescription; +import com.yahoo.search.query.profile.types.QueryProfileType; +import com.yahoo.search.query.profile.types.QueryProfileTypeRegistry; +import com.yahoo.search.query.ranking.Diversity; +import com.yahoo.search.query.ranking.MatchPhase; +import com.yahoo.tensor.Tensor; + +import java.util.Map; + +/** + * Maps between the query model and text properties. + * This can be done simpler by using reflection but the performance penalty was not worth it, + * especially since we should be conservative in adding things to the query model. + * + * @author bratseth + */ +public class QueryProperties extends Properties { + + private static final String MODEL_PREFIX = Model.MODEL + "."; + private static final String RANKING_PREFIX = Ranking.RANKING + "."; + private static final String PRESENTATION_PREFIX = Presentation.PRESENTATION + "."; + + public static final CompoundName[] PER_SOURCE_QUERY_PROPERTIES = new CompoundName[] { + new CompoundName(MODEL_PREFIX + Model.QUERY_STRING), + new CompoundName(MODEL_PREFIX + Model.TYPE), + new CompoundName(MODEL_PREFIX + Model.FILTER), + new CompoundName(MODEL_PREFIX + Model.DEFAULT_INDEX), + new CompoundName(MODEL_PREFIX + Model.LANGUAGE), + new CompoundName(MODEL_PREFIX + Model.ENCODING), + new CompoundName(MODEL_PREFIX + Model.SOURCES), + new CompoundName(MODEL_PREFIX + Model.SEARCH_PATH), + new CompoundName(MODEL_PREFIX + Model.RESTRICT), + new CompoundName(RANKING_PREFIX + Ranking.LOCATION), + new CompoundName(RANKING_PREFIX + Ranking.PROFILE), + new CompoundName(RANKING_PREFIX + Ranking.SORTING), + new CompoundName(RANKING_PREFIX + Ranking.FRESHNESS), + new CompoundName(RANKING_PREFIX + Ranking.QUERYCACHE), + new CompoundName(RANKING_PREFIX + Ranking.LIST_FEATURES), + new CompoundName(PRESENTATION_PREFIX + Presentation.BOLDING), + new CompoundName(PRESENTATION_PREFIX + Presentation.SUMMARY), + new CompoundName(PRESENTATION_PREFIX + Presentation.REPORT_COVERAGE), + new CompoundName(PRESENTATION_PREFIX + Presentation.FORMAT), + new CompoundName(PRESENTATION_PREFIX + Presentation.SUMMARY_FIELDS), + Query.HITS, + Query.OFFSET, + Query.TRACE_LEVEL, + Query.TIMEOUT, + Query.NO_CACHE, + Query.GROUPING_SESSION_CACHE }; + + private Query query; + private final CompiledQueryProfileRegistry profileRegistry; + + public QueryProperties(Query query, CompiledQueryProfileRegistry profileRegistry) { + this.query = query; + this.profileRegistry = profileRegistry; + } + + public void setParentQuery(Query query) { + this.query=query; + super.setParentQuery(query); + } + + @SuppressWarnings("deprecation") + @Override + public Object get(final CompoundName key, Map<String,String> context, + com.yahoo.processing.request.Properties substitution) { + if (key.size()==2 && key.first().equals(Model.MODEL)) { + if (key.last().equals(Model.QUERY_STRING)) return query.getModel().getQueryString(); + if (key.last().equals(Model.TYPE)) return query.getModel().getType(); + if (key.last().equals(Model.FILTER)) return query.getModel().getFilter(); + if (key.last().equals(Model.DEFAULT_INDEX)) return query.getModel().getDefaultIndex(); + if (key.last().equals(Model.LANGUAGE)) return query.getModel().getLanguage(); + if (key.last().equals(Model.ENCODING)) return query.getModel().getEncoding(); + if (key.last().equals(Model.SOURCES)) return query.getModel().getSources(); + if (key.last().equals(Model.SEARCH_PATH)) return query.getModel().getSearchPath(); + if (key.last().equals(Model.RESTRICT)) return query.getModel().getRestrict(); + } + else if (key.first().equals(Ranking.RANKING)) { + if (key.size()==2) { + if (key.last().equals(Ranking.LOCATION)) return query.getRanking().getLocation(); + if (key.last().equals(Ranking.PROFILE)) return query.getRanking().getProfile(); + if (key.last().equals(Ranking.SORTING)) return query.getRanking().getSorting(); + if (key.last().equals(Ranking.FRESHNESS)) return query.getRanking().getFreshness(); + if (key.last().equals(Ranking.QUERYCACHE)) return query.getRanking().getQueryCache(); + if (key.last().equals(Ranking.LIST_FEATURES)) return query.getRanking().getListFeatures(); + } + else if (key.size()>=3 && key.get(1).equals(Ranking.MATCH_PHASE)) { + if (key.size() == 3) { + MatchPhase matchPhase = query.getRanking().getMatchPhase(); + if (key.last().equals(MatchPhase.ATTRIBUTE)) return matchPhase.getAttribute(); + if (key.last().equals(MatchPhase.ASCENDING)) return matchPhase.getAscending(); + if (key.last().equals(MatchPhase.MAX_HITS)) return matchPhase.getMaxHits(); + if (key.last().equals(MatchPhase.MAX_FILTER_COVERAGE)) return matchPhase.getMaxFilterCoverage(); + } else if (key.size() >= 4 && key.get(2).equals(Ranking.DIVERSITY)) { + Diversity diversity = query.getRanking().getMatchPhase().getDiversity(); + if (key.size() == 4) { + if (key.last().equals(Diversity.ATTRIBUTE)) return diversity.getAttribute(); + if (key.last().equals(Diversity.MINGROUPS)) return diversity.getMinGroups(); + } else if ((key.size() == 5) && key.get(3).equals(Diversity.CUTOFF)) { + if (key.last().equals(Diversity.FACTOR)) return diversity.getCutoffFactor(); + if (key.last().equals(Diversity.STRATEGY)) return diversity.getCutoffStrategy(); + } + } + } + else if (key.size()>2) { + // pass the portion after "ranking.features/properties" down + if (key.get(1).equals(Ranking.FEATURES)) return query.getRanking().getFeatures().getObject(key.rest().rest().toString()); + if (key.get(1).equals(Ranking.PROPERTIES)) return query.getRanking().getProperties().get(key.rest().rest().toString()); + } + } + else if (key.size()==2 && key.first().equals(Presentation.PRESENTATION)) { + if (key.last().equals(Presentation.BOLDING)) return query.getPresentation().getBolding(); + if (key.last().equals(Presentation.SUMMARY)) return query.getPresentation().getSummary(); + if (key.last().equals(Presentation.REPORT_COVERAGE)) return query.getPresentation().getReportCoverage(); + if (key.last().equals(Presentation.FORMAT)) return query.getPresentation().getFormat(); + if (key.last().equals(Presentation.TIMING)) return query.getPresentation().getTiming(); + if (key.last().equals(Presentation.SUMMARY_FIELDS)) return query.getPresentation().getSummaryFields(); + } + else if (key.first().equals("rankfeature") || key.first().equals("featureoverride")) { // featureoverride is deprecated + return query.getRanking().getFeatures().getObject(key.rest().toString()); + } else if (key.first().equals("rankproperty")) { + return query.getRanking().getProperties().get(key.rest().toString()); + } else if (key.size()==1) { + if (key.equals(Query.HITS)) return query.getHits(); + if (key.equals(Query.OFFSET)) return query.getOffset(); + if (key.equals(Query.TRACE_LEVEL)) return query.getTraceLevel(); + if (key.equals(Query.TIMEOUT)) return query.getTimeout(); + if (key.equals(Query.NO_CACHE)) return query.getNoCache(); + if (key.equals(Query.GROUPING_SESSION_CACHE)) return query.getGroupingSessionCache(); + if (key.toString().equals(Model.MODEL)) return query.getModel(); + if (key.toString().equals(Ranking.RANKING)) return query.getRanking(); + if (key.toString().equals(Presentation.PRESENTATION)) return query.getPresentation(); + } + return super.get(key,context,substitution); + } + + @SuppressWarnings("deprecation") + @Override + public void set(final CompoundName key,Object value,Map<String,String> context) { + // Note: The defaults here are never used + try { + if (key.size()==2 && key.first().equals(Model.MODEL)) { + if (key.last().equals(Model.QUERY_STRING)) + query.getModel().setQueryString(asString(value, "")); + else if (key.last().equals(Model.TYPE)) + query.getModel().setType(asString(value, "ANY")); + else if (key.last().equals(Model.FILTER)) + query.getModel().setFilter(asString(value, "")); + else if (key.last().equals(Model.DEFAULT_INDEX)) + query.getModel().setDefaultIndex(asString(value, "")); + else if (key.last().equals(Model.LANGUAGE)) + query.getModel().setLanguage(asString(value, "")); + else if (key.last().equals(Model.ENCODING)) + query.getModel().setEncoding(asString(value,"")); + else if (key.last().equals(Model.SEARCH_PATH)) + query.getModel().setSearchPath(asString(value,"")); + else if (key.last().equals(Model.SOURCES)) + query.getModel().setSources(asString(value,"")); + else if (key.last().equals(Model.RESTRICT)) + query.getModel().setRestrict(asString(value,"")); + else + throwIllegalParameter(key.last(),Model.MODEL); + } + else if (key.first().equals(Ranking.RANKING)) { + if (key.size()==2) { + if (key.last().equals(Ranking.LOCATION)) + query.getRanking().setLocation(asString(value,"")); + else if (key.last().equals(Ranking.PROFILE)) + query.getRanking().setProfile(asString(value,"")); + else if (key.last().equals(Ranking.SORTING)) + query.getRanking().setSorting(asString(value,"")); + else if (key.last().equals(Ranking.FRESHNESS)) + query.getRanking().setFreshness(asString(value, "")); + else if (key.last().equals(Ranking.QUERYCACHE)) + query.getRanking().setQueryCache(asBoolean(value, false)); + else if (key.last().equals(Ranking.LIST_FEATURES)) + query.getRanking().setListFeatures(asBoolean(value,false)); + } + else if (key.size()>=3 && key.get(1).equals(Ranking.MATCH_PHASE)) { + if (key.size() == 3) { + MatchPhase matchPhase = query.getRanking().getMatchPhase(); + if (key.last().equals(MatchPhase.ATTRIBUTE)) { + matchPhase.setAttribute(asString(value, null)); + } else if (key.last().equals(MatchPhase.ASCENDING)) { + matchPhase.setAscending(asBoolean(value, false)); + } else if (key.last().equals(MatchPhase.MAX_HITS)) { + matchPhase.setMaxHits(asLong(value, null)); + } else if (key.last().equals(MatchPhase.MAX_FILTER_COVERAGE)) { + matchPhase.setMaxFilterCoverage(asDouble(value, 1.0)); + } + } else if (key.size() > 3 && key.get(2).equals(Ranking.DIVERSITY)) { + Diversity diversity = query.getRanking().getMatchPhase().getDiversity(); + if (key.last().equals(Diversity.ATTRIBUTE)) { + diversity.setAttribute(asString(value, null)); + } else if (key.last().equals(Diversity.MINGROUPS)) { + diversity.setMinGroups(asLong(value, null)); + } else if ((key.size() > 4) && key.get(3).equals(Diversity.CUTOFF)) { + if (key.last().equals(Diversity.FACTOR)) { + diversity.setCutoffFactor(asDouble(value, 10.0)); + } else if (key.last().equals(Diversity.STRATEGY)) { + diversity.setCutoffStrategy(asString(value, "loose")); + } + } + } + } + else if (key.size()>2) { + String restKey = key.rest().rest().toString(); + if (key.get(1).equals(Ranking.FEATURES)) + setRankingFeature(query, restKey, toSpecifiedType(restKey, value, profileRegistry.getTypeRegistry().getComponent("features"))); + else if (key.get(1).equals(Ranking.PROPERTIES)) + query.getRanking().getProperties().put(restKey, toSpecifiedType(restKey, value, profileRegistry.getTypeRegistry().getComponent("properties"))); + else + throwIllegalParameter(key.rest().toString(),Ranking.RANKING); + } + } + else if (key.size()==2 && key.first().equals(Presentation.PRESENTATION)) { + if (key.last().equals(Presentation.BOLDING)) + query.getPresentation().setBolding(asBoolean(value, true)); + else if (key.last().equals(Presentation.SUMMARY)) + query.getPresentation().setSummary(asString(value, "")); + else if (key.last().equals(Presentation.REPORT_COVERAGE)) + query.getPresentation().setReportCoverage(asBoolean(value,true)); + else if (key.last().equals(Presentation.FORMAT)) + query.getPresentation().setFormat(asString(value,"")); + else if (key.last().equals(Presentation.TIMING)) + query.getPresentation().setTiming(asBoolean(value, true)); + else if (key.last().equals(Presentation.SUMMARY_FIELDS)) + query.getPresentation().setSummaryFields(asString(value,"")); + else + throwIllegalParameter(key.last(), Presentation.PRESENTATION); + } + else if (key.first().equals("rankfeature") || key.first().equals("featureoverride") ) { // featureoverride is deprecated + setRankingFeature(query, key.rest().toString(), toSpecifiedType(key.rest().toString(), value, profileRegistry.getTypeRegistry().getComponent("features"))); + } else if (key.first().equals("rankproperty")) { + query.getRanking().getProperties().put(key.rest().toString(), toSpecifiedType(key.rest().toString(), value, profileRegistry.getTypeRegistry().getComponent("properties"))); + } else if (key.size()==1) { + if (key.equals(Query.HITS)) + query.setHits(asInteger(value,10)); + else if (key.equals(Query.OFFSET)) + query.setOffset(asInteger(value,0)); + else if (key.equals(Query.TRACE_LEVEL)) + query.setTraceLevel(asInteger(value,0)); + else if (key.equals(Query.TIMEOUT)) + query.setTimeout(value.toString()); + else if (key.equals(Query.NO_CACHE)) + query.setNoCache(asBoolean(value,false)); + else if (key.equals(Query.GROUPING_SESSION_CACHE)) + query.setGroupingSessionCache(asBoolean(value, false)); + else + super.set(key,value,context); + } + else + super.set(key,value,context); + } + catch (Exception e) { // Make sure error messages are informative. This should be moved out of this properties implementation + if (e.getMessage().startsWith("Could not set")) + throw e; + else + throw new IllegalArgumentException("Could not set '" + key + "' to '" + value + "'", e); + } + } + + private void setRankingFeature(Query query, String key, Object value) { + if (value instanceof Tensor) + query.getRanking().getFeatures().put(key, (Tensor)value); + else + query.getRanking().getFeatures().put(key, asString(value, "")); + } + + private Object toSpecifiedType(String key, Object value, QueryProfileType type) { + if ( ! ( value instanceof String)) return value; // already typed + if (type == null) return value; // no type info -> keep as string + FieldDescription field = type.getField(key); + if (field == null) return value; // ditto + return field.getType().convertFrom(value, profileRegistry); + } + + private void throwIllegalParameter(String key,String namespace) { + throw new IllegalArgumentException("'" + key + "' is not a valid property in '" + namespace + + "'. See the search api for valid keys starting by '" + namespace + "'."); + } + + @Override + public final Query getParentQuery() { + return query; + } +} diff --git a/container-search/src/main/java/com/yahoo/search/query/properties/QueryPropertyAliases.java b/container-search/src/main/java/com/yahoo/search/query/properties/QueryPropertyAliases.java new file mode 100644 index 00000000000..15544e8ff4c --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/properties/QueryPropertyAliases.java @@ -0,0 +1,33 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.query.properties; + +import com.yahoo.processing.request.CompoundName; + +import java.util.Map; + +/** + * Property aliases which contains some hardcoded unaliasing of prefixes of + * rankfeature and rankproperty maps. + * + * @author <a href="mailto:bratseth@yahoo-inc.com">Jon Bratseth</a> + */ +public class QueryPropertyAliases extends PropertyAliases { + + /** + * Creates an instance with a set of aliases. The given aliases will be used directly by this class. + * To make this class immutable and thread safe, relinquish ownership of the parameter map. + */ + public QueryPropertyAliases(Map<String,CompoundName> aliases) { + super(aliases); + } + + @Override + protected CompoundName unalias(CompoundName nameOrAlias) { + if (nameOrAlias.first().equalsIgnoreCase("rankfeature")) + return nameOrAlias.rest().prepend("ranking", "features"); + else if (nameOrAlias.first().equalsIgnoreCase("rankproperty")) + return nameOrAlias.rest().prepend("ranking", "properties"); + return super.unalias(nameOrAlias); + } + +} diff --git a/container-search/src/main/java/com/yahoo/search/query/properties/RequestContextProperties.java b/container-search/src/main/java/com/yahoo/search/query/properties/RequestContextProperties.java new file mode 100644 index 00000000000..c97f4daf6d4 --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/properties/RequestContextProperties.java @@ -0,0 +1,41 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.query.properties; + +import com.yahoo.processing.request.CompoundName; +import com.yahoo.search.query.Properties; + +import java.util.Map; + +/** + * Turns get(name) into get(name,request) using the request given at construction time. + * This is used to allow the query's request to be supplied to all property requests + * without forcing users of the query.properties() to supply this explicitly. + * + * @author <a href="mailto:bratseth@yahoo-inc.com">Jon Bratseth</a> + */ +public class RequestContextProperties extends Properties { + + private final Map<String,String> requestMap; + + public RequestContextProperties(Map<String, String> properties) { + this.requestMap=properties; + } + + @Override + public Object get(CompoundName name,Map<String,String> context, + com.yahoo.processing.request.Properties substitution) { + return super.get(name,context==null ? requestMap : context,substitution); + } + + @Override + public void set(CompoundName name,Object value,Map<String,String> context) { + super.set(name,value,context==null ? requestMap : context); + } + + @Override + public Map<String, Object> listProperties(CompoundName path,Map<String,String> context, + com.yahoo.processing.request.Properties substitution) { + return super.listProperties(path,context==null ? requestMap : context,substitution); + } + +} diff --git a/container-search/src/main/java/com/yahoo/search/query/properties/SubProperties.java b/container-search/src/main/java/com/yahoo/search/query/properties/SubProperties.java new file mode 100644 index 00000000000..7f5c2ec2558 --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/properties/SubProperties.java @@ -0,0 +1,67 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.query.properties; + +import com.yahoo.processing.request.CompoundName; +import com.yahoo.processing.request.Properties; + +import java.util.Map; + +/** + * A wrapper around a chain of property objects that prefixes all gets/sets with a given path + * + * @author <a href="mailto:arnebef@yahoo-inc.com">Arne Bergene Fossaa</a> + */ +public class SubProperties extends com.yahoo.search.query.Properties { + + final private CompoundName pathPrefix; + final private Properties parent; + + public SubProperties(String pathPrefix, Properties properties) { + this(new CompoundName(pathPrefix),properties); + } + + public SubProperties(CompoundName pathPrefix, Properties properties) { + this.pathPrefix = pathPrefix; + this.parent = properties; + } + + @Override + public Object get(CompoundName key, Map<String,String> context, + com.yahoo.processing.request.Properties substitution) { + if(key == null) return null; + Object result = parent.get(getPathPrefix() + "." + key,context,substitution); + if(result == null) { + return super.get(key,context,substitution); + } else { + return result; + } + } + + @Override + public void set(CompoundName key, Object obj, Map<String,String> context) { + if(key == null) return; + parent.set(getPathPrefix() + "." + key, obj, context); + } + + @Override + public Map<String, Object> listProperties(CompoundName path,Map<String,String> context, + com.yahoo.processing.request.Properties substitution) { + Map<String, Object> map = super.listProperties(path,context,substitution); + if(path.isEmpty()) { + map.putAll(parent.listProperties(getPathPrefix(),context,substitution)); + } else { + map.putAll(parent.listProperties(getPathPrefix() + "." + path,context,substitution)); + } + return map; + } + + public CompoundName getPathPrefixCompound() { + return pathPrefix; + } + + /** Returns getPatchPrefixCompound.toString() */ + public String getPathPrefix() { + return getPathPrefixCompound().toString(); + } + +} diff --git a/container-search/src/main/java/com/yahoo/search/query/properties/package-info.java b/container-search/src/main/java/com/yahoo/search/query/properties/package-info.java new file mode 100644 index 00000000000..047a5494e53 --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/properties/package-info.java @@ -0,0 +1,7 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +@ExportPackage +@PublicApi +package com.yahoo.search.query.properties; + +import com.yahoo.api.annotations.PublicApi; +import com.yahoo.osgi.annotation.ExportPackage; diff --git a/container-search/src/main/java/com/yahoo/search/query/ranking/Diversity.java b/container-search/src/main/java/com/yahoo/search/query/ranking/Diversity.java new file mode 100644 index 00000000000..b1865ad9d75 --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/ranking/Diversity.java @@ -0,0 +1,127 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.query.ranking; + +import com.yahoo.search.query.Ranking; +import com.yahoo.search.query.profile.types.FieldDescription; +import com.yahoo.search.query.profile.types.QueryProfileType; + +import java.util.Objects; + +/** + * <p>The diversity settings during match phase of a query. + * These are the same settings for diversity during match phase that can be set in a rank profile + * and is used for achieving guaranteed diversity at the cost of slightly higher cost as more hits must be + * considered compared to plain match-phase.</p> + * + * <p>You specify an additional attribute to be the diversifier and also min diversity needed.</p> + * + * @author <a href="mailto:balder@yahoo-inc.com">Henning Baldersheim</a> + */ +public class Diversity implements Cloneable { + + /** The type representing the property arguments consumed by this */ + private static final QueryProfileType argumentType; + + public static final String ATTRIBUTE = "attribute"; + public static final String MINGROUPS = "minGroups"; + public static final String CUTOFF = "cutoff"; + public static final String FACTOR = "factor"; + public static final String STRATEGY = "strategy"; + + + static { + argumentType =new QueryProfileType(Ranking.DIVERSITY); + argumentType.setStrict(true); + argumentType.setBuiltin(true); + argumentType.addField(new FieldDescription(ATTRIBUTE, "string")); + argumentType.addField(new FieldDescription(MINGROUPS, "long")); + argumentType.freeze(); + } + public static QueryProfileType getArgumentType() { return argumentType; } + + public enum CutoffStrategy {loose, strict}; + private String attribute = null; + private Long minGroups = null; + private Double cutoffFactor = null; + private CutoffStrategy cutoffStrategy= null; + + /** + * Sets the attribute field which will be used to guarantee diversity. + * Set to null (default) to disable diversification. + * <p> + * If this is set, make sure to also set the maxGroups value. + * <p> + * This attribute must be singlevalue. + */ + public void setAttribute(String attribute) { this.attribute = attribute; } + + /** Returns the attribute to use for diversity, or null if none */ + public String getAttribute() { return attribute; } + + /** + * Sets the max hits to aim for producing in the match phase. + * This must be set if an attribute value is set. + * It should be set to a reasonable fraction of the total documents on each partition. + */ + public void setMinGroups(long minGroups) { this.minGroups = minGroups; } + + /** Returns the max hits to aim for producing in the match phase on each content node, or null if not set */ + public Long getMinGroups() { return minGroups; } + + public void setCutoffFactor(double cutoffFactor) { this.cutoffFactor = cutoffFactor; } + public Double getCutoffFactor() { return cutoffFactor; } + public void setCutoffStrategy(String cutoffStrategy) { this.cutoffStrategy = CutoffStrategy.valueOf(cutoffStrategy); } + public CutoffStrategy getCutoffStrategy() { return cutoffStrategy; } + + /** Internal operation - DO NOT USE */ + public void prepare(RankProperties rankProperties) { + if (attribute == null && minGroups == null) return; + + if (attribute != null && !attribute.isEmpty()) { + rankProperties.put("vespa.matchphase.diversity.attribute", attribute); + } + if (minGroups != null) { + rankProperties.put("vespa.matchphase.diversity.mingroups", String.valueOf(minGroups)); + } + if (cutoffFactor != null) { + rankProperties.put("vespa.matchphase.diversity.cutoff.factor", String.valueOf(cutoffFactor)); + } + if (cutoffStrategy != null) { + rankProperties.put("vespa.matchphase.diversity.cutoff.strategy", cutoffStrategy); + } + } + + @Override + public Diversity clone() { + try { + return (Diversity)super.clone(); + } + catch (CloneNotSupportedException e) { + throw new RuntimeException("Won't happen", e); + } + } + + @Override + public int hashCode() { + int hash = 0; + if (attribute != null) hash += 11 * attribute.hashCode(); + if (minGroups != null) hash += 13 * minGroups.hashCode(); + if (cutoffFactor != null) hash += 17 * cutoffFactor.hashCode(); + if (cutoffStrategy != null) hash += 19 * cutoffStrategy.hashCode(); + return hash; + } + + @Override + public boolean equals(Object o) { + if (o == this) return true; + if ( ! (o instanceof Diversity)) return false; + + Diversity other = (Diversity)o; + if ( ! Objects.equals(this.attribute, other.attribute)) return false; + if ( ! Objects.equals(this.minGroups, other.minGroups)) return false; + if ( ! Objects.equals(this.cutoffFactor, other.cutoffFactor)) return false; + if ( ! Objects.equals(this.cutoffStrategy, other.cutoffStrategy)) return false; + return true; + } + +} diff --git a/container-search/src/main/java/com/yahoo/search/query/ranking/MatchPhase.java b/container-search/src/main/java/com/yahoo/search/query/ranking/MatchPhase.java new file mode 100644 index 00000000000..ba25ddbe7e6 --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/ranking/MatchPhase.java @@ -0,0 +1,153 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.query.ranking; + +import com.yahoo.processing.request.CompoundName; +import com.yahoo.search.query.Ranking; +import com.yahoo.search.query.profile.types.FieldDescription; +import com.yahoo.search.query.profile.types.QueryProfileType; + +import java.util.Objects; + +/** + * The match phase ranking settings of this query. + * These are the same settings for match phase that can be set in a rank profile + * and is used for achieving reasonable query behavior given a query which causes too many matches: + * The engine will fall back to retrieving the best values according to the attribute given here + * during matching. + * <p> + * For this feature to work well, the order given by the attribute should correlate reasonably with the order + * of results produced if full evaluation is performed. + * + * @author bratseth + */ +public class MatchPhase implements Cloneable { + + /** The type representing the property arguments consumed by this */ + private static final QueryProfileType argumentType; + + public static final String ATTRIBUTE = "attribute"; + public static final String ASCENDING = "ascending"; + public static final String MAX_HITS = "maxHits"; + public static final String MAX_FILTER_COVERAGE = "maxFilterCoverage"; + + static { + argumentType =new QueryProfileType(Ranking.MATCH_PHASE); + argumentType.setStrict(true); + argumentType.setBuiltin(true); + argumentType.addField(new FieldDescription(ATTRIBUTE, "string")); + argumentType.addField(new FieldDescription(ASCENDING, "boolean")); + argumentType.addField(new FieldDescription(MAX_HITS, "long")); + argumentType.addField(new FieldDescription(MAX_FILTER_COVERAGE, "double")); + argumentType.addField(new FieldDescription(Ranking.DIVERSITY, "query-profile", "diversity")); + argumentType.freeze(); + } + public static QueryProfileType getArgumentType() { return argumentType; } + + private String attribute = null; + private boolean ascending = false; + private Long maxHits = null; + private Double maxFilterCoverage = 1.0; + private Diversity diversity = new Diversity(); + + /** + * Sets the attribute field which will be used to decide the best matches after it has been determined + * during matching that this query is going to cause too many matches. + * Set to null (default) to disable degradation. + * <p> + * If this is set, make sure to also set the maxHits value. + * Otherwise, the attribute setting is ignored. + * <p> + * This attribute should have fast-search turned on. + */ + public void setAttribute(String attribute) { this.attribute = attribute; } + + /** Returns the attribute to use for degradation, or null if none */ + public String getAttribute() { return attribute; } + + /** + * Set to true to sort by the attribute in ascending order when this is in use during the match phase, + * false (default) to use descending order. + */ + public void setAscending(boolean ascending) { this.ascending = ascending; } + + /** + * Returns the order to sort the attribute during the path phase when this takes effect. + */ + public boolean getAscending() { return ascending; } + + /** + * Sets the max hits to aim for producing in the match phase. + * This must be set if an attribute value is set. + * It should be set to a reasonable fraction of the total documents on each partition. + */ + public void setMaxHits(long maxHits) { this.maxHits = maxHits; } + + public void setMaxFilterCoverage(double maxFilterCoverage) { + if ((maxFilterCoverage < 0.0) || (maxFilterCoverage > 1.0)) { + throw new IllegalArgumentException("maxFilterCoverage must be in the range [0.0, 1.0]. It is " + maxFilterCoverage); + } + this.maxFilterCoverage = maxFilterCoverage; + } + + /** Returns the max hits to aim for producing in the match phase on each content node, or null if not set */ + public Long getMaxHits() { return maxHits; } + + public Double getMaxFilterCoverage() { return maxFilterCoverage; } + + public Diversity getDiversity() { return diversity; } + + public void setDiversity(Diversity diversity) { + this.diversity = diversity; + } + + /** Internal operation - DO NOT USE */ + public void prepare(RankProperties rankProperties) { + if (attribute == null || maxHits == null) return; + + rankProperties.put("vespa.matchphase.degradation.attribute", attribute); + if (ascending) { // backend default is descending + rankProperties.put("vespa.matchphase.degradation.ascendingorder", "true"); + } + rankProperties.put("vespa.matchphase.degradation.maxhits", String.valueOf(maxHits)); + rankProperties.put("vespa.matchphase.degradation.maxfiltercoverage", String.valueOf(maxFilterCoverage)); + diversity.prepare(rankProperties); + } + + @Override + public int hashCode() { + int hash = 0; + hash += 13 * Boolean.hashCode(ascending); + hash += 19 * diversity.hashCode(); + if (attribute != null) hash += 11 * attribute.hashCode(); + if (maxHits != null) hash += 17 * maxHits.hashCode(); + hash += 23 * maxFilterCoverage.hashCode(); + return hash; + } + + @Override + public boolean equals(Object o) { + if (o == this) return true; + if ( ! (o instanceof MatchPhase)) return false; + + MatchPhase other = (MatchPhase)o; + if ( this.ascending != other.ascending) return false; + if ( ! Objects.equals(this.attribute, other.attribute)) return false; + if ( ! Objects.equals(this.maxHits, other.maxHits)) return false; + if ( ! Objects.equals(this.diversity, other.diversity)) return false; + if ( ! Objects.equals(this.maxFilterCoverage, other.maxFilterCoverage)) return false; + return true; + } + + @Override + public MatchPhase clone() { + try { + MatchPhase clone = (MatchPhase)super.clone(); + clone.diversity = diversity.clone(); + return clone; + } + catch (CloneNotSupportedException e) { + throw new RuntimeException("Won't happen", e); + } + } + +} diff --git a/container-search/src/main/java/com/yahoo/search/query/ranking/RankFeatures.java b/container-search/src/main/java/com/yahoo/search/query/ranking/RankFeatures.java new file mode 100644 index 00000000000..1bcd548882c --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/ranking/RankFeatures.java @@ -0,0 +1,130 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.query.ranking; + +import com.yahoo.fs4.MapEncoder; +import com.yahoo.tensor.Tensor; +import com.yahoo.text.JSON; + +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * Contains the rank features of a query. + * + * @author bratseth + */ +public class RankFeatures implements Cloneable { + + private final Map<String, Object> features; + + public RankFeatures() { + this(new LinkedHashMap<>()); + } + + private RankFeatures(Map<String, Object> features) { + this.features = features; + } + + /** Sets a rank feature by full name to a value */ + public void put(String name, String value) { + features.put(name, value); + } + + /** Sets a tensor rank feature */ + public void put(String name, Tensor value) { + features.put(name, value); + } + + /** Returns a rank feature as a string by full name or null if not set */ + public String get(String name) { + Object value = features.get(name); + if (value == null) return null; + return value.toString(); + } + + /** Returns this value as whatever type it was stored as. Returns null if the value is not set. */ + public Object getObject(String name) { + return features.get(name); + } + + /** + * Returns a tensor rank feature, or empty if there is no value with this name. + * + * @throws IllegalArgumentException if the value is set but is not a tensor + */ + public Optional<Tensor> getTensor(String name) { + Object feature = features.get(name); + if (feature == null) return Optional.empty(); + if (feature instanceof Tensor) return Optional.of((Tensor)feature); + throw new IllegalArgumentException("Expected a tensor value of '" + name + "' but has " + feature); + } + + /** + * Returns the map holding the features of this. + * This map may be modified to change the rank features of the query. + */ + public Map<String, Object> asMap() { return features; } + + public boolean isEmpty() { + return features.isEmpty(); + } + + /** + * Prepares this for encoding, not for external use. See encode on Query for details. + * <p> + * If the query feature is found in the rank feature set, + * remove all these entries and insert them into the rank property set instead. + * We want to hide from the user that the query feature value is sent down as a rank property + * and picked up by the query feature executor in the backend. + */ + public void prepare(RankProperties rankProperties) { + if (isEmpty()) return; + + List<String> featuresToRemove = new ArrayList<>(); + List<String> propertiesToInsert = new ArrayList<>(); + for (String key : features.keySet()) { + if (key.startsWith("query(") && key.endsWith(")")) { + featuresToRemove.add(key); + propertiesToInsert.add(key.substring("query(".length(), key.length() - 1)); + } else if (key.startsWith("$")) { + featuresToRemove.add(key); + propertiesToInsert.add(key.substring(1)); + } + } + for (int i = 0; i < featuresToRemove.size(); ++i) { + rankProperties.put(propertiesToInsert.get(i), features.remove(featuresToRemove.get(i))); + } + } + + public int encode(ByteBuffer buffer) { + return MapEncoder.encodeMap("feature", features, buffer); + } + + @Override + public boolean equals(Object other) { + if (other == this) return true; + if ( ! (other instanceof RankFeatures)) return false; + + return this.features.equals(((RankFeatures)other).features); + } + + @Override + public int hashCode() { + return features.hashCode(); + } + + @Override + public RankFeatures clone() { + return new RankFeatures(new LinkedHashMap<>(features)); + } + + @Override + public String toString() { + return JSON.encode(features); + } + +} diff --git a/container-search/src/main/java/com/yahoo/search/query/ranking/RankProperties.java b/container-search/src/main/java/com/yahoo/search/query/ranking/RankProperties.java new file mode 100644 index 00000000000..eccb8bac2d4 --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/ranking/RankProperties.java @@ -0,0 +1,114 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.query.ranking; + +import com.yahoo.fs4.GetDocSumsPacket; +import com.yahoo.fs4.MapEncoder; +import com.yahoo.text.JSON; + +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Contains the properties properties of a query. + * This is a multimap: Multiple properties may be set for the same key. + * + * @author <a href="mailto:bratseth@yahoo-inc.com">Jon Bratseth</a> + */ +public class RankProperties implements Cloneable { + + private Map<String, List<Object>> properties = new LinkedHashMap<>(); + + public RankProperties() { + this(new LinkedHashMap<>()); + } + + private RankProperties(Map<String, List<Object>> properties) { + this.properties = properties; + } + + public void put(String name, String value) { + put(name, (Object)value); + } + + /** Adds a property by full name to a value */ + public void put(String name, Object value) { + List<Object> list = properties.get(name); + if (list == null) { + list = new ArrayList<>(); + properties.put(name, list); + } + list.add(value); + } + + /** + * Returns a read-only list of properties properties by full name. + * If this is not set, null is returned. If this is explicitly set to + * have no values, and empty list is returned. + */ + public List<String> get(String name) { + List<Object> values = properties.get(name); + if (values == null) return null; + if (values.isEmpty()) return Collections.<String>emptyList(); + + // Compatibility ... + List<String> stringValues = new ArrayList<>(values.size()); + for (Object value : values) + stringValues.add(value.toString()); + return Collections.unmodifiableList(stringValues); + } + + /** Removes all properties properties for a given name */ + public void remove(String name) { + properties.remove(name); + } + + public boolean isEmpty() { + return properties.isEmpty(); + } + + /** Returns a modifiable map of the properties of this */ + public Map<String, List<Object>> asMap() { return properties; } + + /** Encodes this in a binary internal representation and returns the number of property maps encoded (0 or 1) */ + public int encode(ByteBuffer buffer, boolean encodeQueryData) { + if (encodeQueryData) { + return MapEncoder.encodeObjectMultiMap("rank", properties, buffer); + } + else { + List<Object> sessionId = properties.get(GetDocSumsPacket.sessionIdKey); + if (sessionId == null) return 0; + return MapEncoder.encodeSingleValue("rank", GetDocSumsPacket.sessionIdKey, sessionId.get(0), buffer); + } + } + + @Override + public boolean equals(Object other) { + if (other == this) return true; + if ( ! (other instanceof RankProperties)) return false; + + return this.properties.equals(((RankProperties)other).properties); + } + + @Override + public int hashCode() { + return properties.hashCode(); + } + + @Override + public RankProperties clone() { + Map<String, List<Object>> clone = new LinkedHashMap<>(); + for (Map.Entry<String, List<Object>> entry : properties.entrySet()) + clone.put(entry.getKey(), new ArrayList<>(entry.getValue())); + return new RankProperties(clone); + } + + @Override + public String toString() { + return JSON.encode(properties); + } + +} diff --git a/container-search/src/main/java/com/yahoo/search/query/ranking/package-info.java b/container-search/src/main/java/com/yahoo/search/query/ranking/package-info.java new file mode 100644 index 00000000000..f254b327f96 --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/ranking/package-info.java @@ -0,0 +1,7 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +@ExportPackage +@PublicApi +package com.yahoo.search.query.ranking; + +import com.yahoo.api.annotations.PublicApi; +import com.yahoo.osgi.annotation.ExportPackage; diff --git a/container-search/src/main/java/com/yahoo/search/query/rewrite/QueryRewriteSearcher.java b/container-search/src/main/java/com/yahoo/search/query/rewrite/QueryRewriteSearcher.java new file mode 100644 index 00000000000..bb76c1006f2 --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/rewrite/QueryRewriteSearcher.java @@ -0,0 +1,423 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.query.rewrite; + +import com.google.inject.Inject; +import com.yahoo.search.*; +import com.yahoo.config.*; +import com.yahoo.search.query.rewrite.RewritesConfig.FsaDict; +import com.yahoo.search.searchchain.Execution; +import com.yahoo.fsa.FSA; +import com.yahoo.filedistribution.fileacquirer.FileAcquirer; +import com.yahoo.component.ComponentId; + +import java.io.*; +import java.util.*; +import java.util.concurrent.TimeUnit; +import java.util.logging.Logger; + +/** + * <p>A template class for all rewriters</p> + * + * <p>All rewriters extending this class would need to implement the + * rewrite method which contains the rewriter's main logic, + * getSkipRewriterIfRewritten method which indicates whether this + * rewriter should be skipped if the query has been rewritten, + * getRewriterName method which returns the name of the rewriter used + * in query profile, configure method which contains any instance + * creation time configuration besides the default FSA loading, and + * getDefaultDicts method which return the pair of dictionary name + * and filename.</p> + * + * <p>Common rewrite features are in RewriterFeatures.java. + * Common rewriter utils are in RewriterUtils.java.</p> + * + * @author Karen Sze Wing Lee + */ +public abstract class QueryRewriteSearcher extends Searcher { + + // Indicate whether rewriter is properly initiated + private boolean isOk = false; + + protected final Logger logger = Logger.getLogger(QueryRewriteSearcher.class.getName()); + + // HashMap which store the rewriter dicts + // It has the following format: + // HashMap<String(e.g. dictionary name, etc), + // Object(e.g. FSA, etc)>> + protected HashMap<String, Object> rewriterDicts = new HashMap<>(); + + /** + * Constructor for this rewriter. + * Prepare the data needed by the rewriter + * @param id Component ID (see vespa's search container doc for more detail) + * @param fileAcquirer Required param for retrieving file type config + * (see vespa's search container doc for more detail) + * @param config Config from vespa-services.xml (see vespa's search + * container doc for more detail) + */ + @Inject + protected QueryRewriteSearcher(ComponentId id, + FileAcquirer fileAcquirer, + RewritesConfig config) { + super(id); + RewriterUtils.log(logger, "In QueryRewriteSearcher(ComponentId id, " + + "FileAcquirer fileAcquirer, " + + "RewritesConfig config)"); + isOk = loadFSADicts(fileAcquirer, config, null); + isOk = isOk && configure(fileAcquirer, config, null); + if(isOk) { + RewriterUtils.log(logger, "Rewriter is configured properly"); + } else { + RewriterUtils.log(logger, "Rewriter is not configured properly"); + } + } + + /** + * Constructor for unit test. + * Prepare the data needed by the rewriter + * @param config Config from vespa-services.xml (see vespa's search + * container doc for more detail) + * @param fileList pairs of file name and file handler for unit tests + */ + protected QueryRewriteSearcher(RewritesConfig config, + HashMap<String, File> fileList) { + RewriterUtils.log(logger, "In QueryRewriteSearcher(RewritesConfig config, " + + "HashMap<String, File> fileList)"); + isOk = loadFSADicts(null, config, fileList); + isOk = isOk && configure(null, config, fileList); + if(isOk) { + RewriterUtils.log(logger, "Rewriter is configured properly"); + } else { + RewriterUtils.log(logger, "Rewriter is not configured properly"); + } + } + + /** + * Empty constructor. + * Do nothing at instance creation time + */ + protected QueryRewriteSearcher(ComponentId id) { + super(id); + RewriterUtils.log(logger, "In QueryRewriteSearcher(Component id)"); + RewriterUtils.log(logger, "Configuring rewriter: " + getRewriterName()); + isOk = true; + RewriterUtils.log(logger, "Rewriter is configured properly"); + } + + /** + * Empty constructor for unit test. + * Do nothing at instance creation time + */ + protected QueryRewriteSearcher() { + RewriterUtils.log(logger, "In QueryRewriteSearcher()"); + RewriterUtils.log(logger, "Configuring rewriter: " + getRewriterName()); + isOk = true; + RewriterUtils.log(logger, "Rewriter is configured properly"); + } + + /** + * Load the dicts specified in vespa-services.xml + * + * @param fileAcquirer Required param for retrieving file type config + * (see vespa's search container doc for more detail) + * @param config Config from vespa-services.xml (see vespa's search + * container doc for more detail) + * @param fileList pairs of file name and file handler for unit tests + * @return boolean true if loaded successfully, false otherwise + */ + private boolean loadFSADicts(FileAcquirer fileAcquirer, + RewritesConfig config, + HashMap<String, File> fileList) + throws RuntimeException { + + // Check if getRewriterName method is properly implemented + String rewriterName = getRewriterName(); + if(rewriterName==null) { + RewriterUtils.error(logger, "Rewriter required method is not properly implemented: "); + return false; + } + + RewriterUtils.log(logger, "Configuring rewriter: " + rewriterName); + + // Check if there's no config need to be loaded + if(config==null || (fileAcquirer==null && fileList==null)) { + RewriterUtils.log(logger, "No FSA dictionary file need to be loaded"); + return true; + } + + // Check if config contains the FSADict param + if(config.fsaDict()==null) { + RewriterUtils.error(logger, "FSADict is not properly set in config"); + return false; + } + + RewriterUtils.log(logger, "Loading rewriter dictionaries"); + + // Retrieve FSA names and paths + ListIterator<FsaDict> fsaList = config.fsaDict().listIterator(); + + // Load default dictionaries if no user dictionaries is configured + if(!fsaList.hasNext()) { + RewriterUtils.log(logger, "Loading default dictionaries"); + HashMap<String, String> defaultFSAs = getDefaultFSAs(); + + if(defaultFSAs==null) { + RewriterUtils.log(logger, "No default FSA dictionary is configured"); + return true; + } + Iterator<Map.Entry<String, String>> defaultFSAList = defaultFSAs.entrySet().iterator(); + while(defaultFSAList.hasNext()) { + try{ + Map.Entry<String, String> currFSA = defaultFSAList.next(); + String fsaName = currFSA.getKey(); + String fsaPath = currFSA.getValue(); + + RewriterUtils.log(logger, + "FSA file location for " + fsaName + ": " + fsaPath); + + // Load FSA + FSA fsa = RewriterUtils.loadFSA(RewriterConstants.DEFAULT_DICT_DIR + fsaPath, null); + + // Store FSA into dictionary map + rewriterDicts.put(fsaName, fsa); + } catch (IOException e) { + RewriterUtils.error(logger, "Error loading FSA dictionary: " + + e.getMessage()); + return false; + } + } + } else { + // Load user configured dictionaries + while(fsaList.hasNext()) { + try{ + FsaDict currFSA = fsaList.next(); + // fsaName and fsaPath are not null + // or else vespa config server would not have been + // able to start up + String fsaName = currFSA.name(); + FileReference fsaPath = currFSA.path(); + + RewriterUtils.log(logger, + "FSA file location for " + fsaName + ": " + fsaPath); + + // Retrieve FSA File handler + File fsaFile = null; + if(fileAcquirer!=null) { + fsaFile = fileAcquirer.waitFor(fsaPath, 5, TimeUnit.MINUTES); + } else if(fileList!=null) { + fsaFile = fileList.get(fsaName); + } + + if(fsaFile==null) { + RewriterUtils.error(logger, "Error loading FSA dictionary file handler"); + return false; + } + + // Load FSA + FSA fsa = RewriterUtils.loadFSA(fsaFile, null); + + // Store FSA into dictionary map + rewriterDicts.put(fsaName, fsa); + } catch (InterruptedException e1) { + RewriterUtils.error(logger, "Error loading FSA dictionary file handler: " + + e1.getMessage()); + return false; + } catch (IOException e2) { + RewriterUtils.error(logger, "Error loading FSA dictionary: " + + e2.getMessage()); + return false; + } + } + } + RewriterUtils.log(logger, "Successfully loaded rewriter dictionaries"); + return true; + } + + /** + * Perform instance creation time configuration besides the + * default FSA loading + * + * @param fileAcquirer Required param for retrieving file type config + * (see vespa's search container doc for more detail) + * @param config Config from vespa-services.xml (see vespa's search + * container doc for more detail) + * @param fileList pairs of file name and file handler for unit tests + * @return boolean true if loaded successfully, false otherwise + */ + public abstract boolean configure(FileAcquirer fileAcquirer, + RewritesConfig config, + HashMap<String, File> fileList) + throws RuntimeException; + + /** + * Perform main rewrite logics for this searcher<br> + * - Skip to next rewriter if query is previously + * rewritten and getSkipRewriterIfRewritten() is + * true for this rewriter<br> + * - Execute rewriter's main rewrite logic<br> + * - Pass to the next rewriter the query to be used + * for dictionary retrieval<br> + */ + public @Override Result search(Query query, Execution execution) { + RewriterUtils.log(logger, query, "Executing " + getRewriterName()); + + // Check if rewriter is properly initialized + if(!isOk) { + RewriterUtils.error(logger, query, "Rewriter is not properly initialized"); + return execution.search(query); + } + + RewriterUtils.log(logger, query, "Original query: " + query.toDetailString()); + + // Retrieve metadata passed by previous rewriter + HashMap<String, Object> rewriteMeta = RewriterUtils.getRewriteMeta(query); + + // This key would be updated by each rewriter to specify + // the key to be used for dict retrieval in next + // rewriter downstream. This controls whether the + // next rewriter should use the rewritten query or the + // original query for dict retrieval. e.g. rewriters + // following misspell rewriter should use the rewritten + // query by misspell rewriter for dict retrieval + String prevDictKey = (String)rewriteMeta.get(RewriterConstants.DICT_KEY); + + // Whether the query has been rewritten + Boolean prevRewritten = (Boolean)rewriteMeta.get(RewriterConstants.REWRITTEN); + + // Check if rewriter should be skipped if the query + // has been rewritten + if(prevRewritten && getSkipRewriterIfRewritten()) { + RewriterUtils.log(logger, query, "Skipping rewriter since the " + + "query has been rewritten"); + return execution.search(query); + } + + // Store rewriter result + HashMap<String, Object> rewriterResult = null; + Query originalQueryObj = query.clone(); + + try { + // Execute rewriter's main rewrite logic + rewriterResult = rewrite(query, prevDictKey); + + } catch (RuntimeException e) { + RewriterUtils.error(logger, originalQueryObj, "Error executing this rewriter, " + + "skipping to next rewriter: " + e.getMessage()); + return execution.search(originalQueryObj); + } + + // Check if rewriter result is set properly + if(rewriterResult==null) { + RewriterUtils.error(logger, originalQueryObj, "Rewriter result are not set properly, " + + "skipping to next rewriter"); + return execution.search(originalQueryObj); + } + + // Retrieve results from rewriter + Boolean rewritten = (Boolean)rewriterResult.get(RewriterConstants.REWRITTEN); + String dictKey = (String)rewriterResult.get(RewriterConstants.DICT_KEY); + + if(rewritten==null || dictKey==null) { + RewriterUtils.error(logger, originalQueryObj, "Rewriter result are not set properly, " + + "skipping to next rewriter"); + return execution.search(originalQueryObj); + } + + // Retrieve results from rewriter + rewriteMeta.put(RewriterConstants.REWRITTEN, (rewritten || prevRewritten)); + rewriteMeta.put(RewriterConstants.DICT_KEY, dictKey); + + // Pass metadata to the next rewriter + RewriterUtils.setRewriteMeta(query, rewriteMeta); + + RewriterUtils.log(logger, query, "Final query: " + query.toDetailString()); + + return execution.search(query); + } + + /** + * Perform the main rewrite logic + * + * @param query Query object from searcher + * @param dictKey the key passed from previous rewriter + * to be treated as "original query from user" + * For example, if previous is misspell rewriter, + * it would pass the corrected query as the + * "original query from user". For other rewriters which + * add variants, abbr, etc to the query, the original + * query should be passed as a key. This rewriter could + * still choose to ignore this key. This key + * is not the rewritten query itself. For example, + * if original query is (willl smith) and the + * rewritten query is (willl smith) OR (will smith) + * the key to be passed could be (will smith) + * @return HashMap which contains the key value pairs:<br> + * - whether this query has been rewritten by this + * rewriter<br> + * key: rewritten<br> + * value: true or false<br> + * - the key to be treated as "original query from user" in next + * rewriter downstream, for example, misspell rewriter + * would pass the corrected query as the "original query from + * user" to the next rewriter. For other rewriters which + * add variants, abbr, etc to the query, the original + * query should be passed as a key. This key is not necessarily + * consumed by the next rewriter. The next rewriter + * can still choose to ignore this key.<br> + * key: newDictKey<br> + * value: new dict key<br> + */ + protected abstract HashMap<String, Object> rewrite(Query query, + String dictKey) throws RuntimeException; + + /** + * Check whether rewriter should be skipped if + * the query has been rewritten by other rewriter + * + * @return boolean Whether rewriter should be skipped + */ + protected abstract boolean getSkipRewriterIfRewritten(); + + /** + * Retrieve rewriter name + * It should match the name used in query profile + * + * @return Name of the rewriter + */ + public abstract String getRewriterName(); + + /** + * Get default FSA dictionary names + * + * @return Pair of FSA dictionary name and filename + */ + public abstract HashMap<String, String> getDefaultFSAs(); + + /** + * Get config parameter value set in query profile + * + * @param query Query object from the searcher + * @param paramName parameter to be retrieved + * @return parameter value or null if not found + */ + protected String getQPConfig(Query query, + String paramName) { + return RewriterUtils.getQPConfig(query, getRewriterName(), paramName); + } + + /** + * Retrieve rewrite from FSA given the original query + * + * @param query Query object from searcher + * @param dictName FSA dictionary name + * @param key The original query used to retrieve rewrite + * from the dictionary + * @return String The retrieved rewrites, null if query + * doesn't exist + */ + protected String getRewriteFromFSA(Query query, + String dictName, + String key) throws RuntimeException { + return RewriterUtils.getRewriteFromFSA(query, rewriterDicts, dictName, key); + } +} diff --git a/container-search/src/main/java/com/yahoo/search/query/rewrite/RewriterConstants.java b/container-search/src/main/java/com/yahoo/search/query/rewrite/RewriterConstants.java new file mode 100644 index 00000000000..45ce08de9d5 --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/rewrite/RewriterConstants.java @@ -0,0 +1,55 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.query.rewrite; + +import com.yahoo.processing.request.CompoundName; +import com.yahoo.vespa.defaults.Defaults; + +/** + * Contains common constant strings used by rewriters + * + * @author Karen Sze Wing Lee + */ +public class RewriterConstants { + + /** Config flag for addUnitToOriginalQuery */ + public static final String ORIGINAL_AS_UNIT = "OriginalAsUnit"; + + /** Config flag for addUnitEquivToOriginalQuery */ + public static final String ORIGINAL_AS_UNIT_EQUIV = "OriginalAsUnitEquiv"; + + /** Config flag for addRewritesAsEquiv(false) */ + public static final String REWRITES_AS_EQUIV = "RewritesAsEquiv"; + + /** Config flag for addRewritesAsEquiv(true) */ + public static final String REWRITES_AS_UNIT_EQUIV = "RewritesAsUnitEquiv"; + + /** Config flag for addExpansions */ + public static final String PARTIAL_PHRASE_MATCH = "PartialPhraseMatch"; + + /** Config flag for max number of rewrites added per rewriter */ + public static final String MAX_REWRITES = "MaxRewrites"; + + /** Config flag for considering QSS Rewrite in spell correction */ + public static final String QSS_RW = "QSSRewrite"; + + /** Config flag for considering QSS Suggest in spell correction */ + public static final String QSS_SUGG = "QSSSuggest"; + + /** Config flag for expansion index name */ + public static final String EXPANSION_INDEX = "ExpansionIndex"; + + /** Name for market chain retrieval from user param */ + public static final String REWRITER_CHAIN = "QRWChain"; + + /** Name for rewrite metadata retrieval from query properties */ + public static final CompoundName REWRITE_META = new CompoundName("RewriteMeta"); + + /** Name for rewritten field retrieval from query properties */ + public static final String REWRITTEN = "Rewritten"; + + /** Name for new dictionary key field retrieval from query properties */ + public static final String DICT_KEY = "DictKey"; + + /** Default dictionaries dir */ + public static final String DEFAULT_DICT_DIR = Defaults.getDefaults().vespaHome() + "share/qrw_data/"; +} diff --git a/container-search/src/main/java/com/yahoo/search/query/rewrite/RewriterFeatures.java b/container-search/src/main/java/com/yahoo/search/query/rewrite/RewriterFeatures.java new file mode 100644 index 00000000000..0a5110dbd7e --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/rewrite/RewriterFeatures.java @@ -0,0 +1,651 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.query.rewrite; + +import java.util.*; +import java.util.logging.Logger; + +import com.yahoo.prelude.IndexFacts; +import com.yahoo.prelude.query.parser.CustomParser; +import com.yahoo.search.*; +import com.yahoo.search.query.*; +import com.yahoo.prelude.query.*; +import com.yahoo.prelude.querytransform.PhraseMatcher; +import com.yahoo.prelude.querytransform.PhraseMatcher.Phrase; +import com.yahoo.search.query.parser.ParserEnvironment; +import com.yahoo.search.query.parser.ParserFactory; + +/** + * Contains commonly used rewriter features + * + * @author Karen Sze Wing Lee + */ +public class RewriterFeatures { + + private static final Logger logger = Logger.getLogger(RewriterFeatures.class.getName()); + + /** + * <p>Add proximity boosting to original query by modifying + * the query tree directly</p> + * e.g. original Query Tree: (AND aa bb)<br> + * if keepOriginalQuery: true<br> + * new Query tree: (OR (AND aa bb) "aa bb")<br> + * if keepOriginalQuery: false<br> + * new Query Tree: "aa bb"<br><br> + * + * original Query Tree: (OR (AND aa bb) (AND cc dd))<br> + * boostingQuery: cc dd<br> + * if keepOriginalQuery: true<br> + * new Query Tree: (OR (AND aa bb) (AND cc dd) "cc dd")<br> + * if keepOriginalQuery: false<br> + * new Query Tree: (OR (AND aa bb) "cc dd") <br> + * + * @param query Query object from searcher + * @param boostingQuery query to be boosted + * @param keepOriginalQuery whether to keep original unboosted query as equiv + * @return Modified Query object, return original query object + * on error + */ + public static Query addUnitToOriginalQuery(Query query, String boostingQuery, + boolean keepOriginalQuery) + throws RuntimeException { + RewriterUtils.log(logger, query, "Adding proximity boosting to [" + boostingQuery + "]"); + + Model queryModel = query.getModel(); + QueryTree qTree = queryModel.getQueryTree(); + Item oldRoot = qTree.getRoot(); + + if (oldRoot == null) { + RewriterUtils.error(logger, query, "Error retrieving query tree root"); + throw new RuntimeException("Error retrieving query tree root"); + } + + // Convert original query to query tree item + Item origQueryItem = convertStringToQTree(query, boostingQuery); + + // Boost proximity by phrasing the original query + // query tree structure: (AND aa bb) + if (oldRoot instanceof AndItem && + oldRoot.equals(origQueryItem)) { + PhraseItem phrase = convertAndToPhrase((AndItem)oldRoot); + + if(!keepOriginalQuery) { + qTree.setRoot(phrase); + } else { + OrItem newRoot = new OrItem(); + newRoot.addItem(oldRoot); + newRoot.addItem(phrase); + qTree.setRoot(newRoot); + queryModel.setType(Query.Type.ADVANCED); //set type=adv + } + RewriterUtils.log(logger, query, "Added proximity boosting successfully"); + return query; + + // query tree structure: (OR (AND aa bb) (AND cc dd)) + } else if (oldRoot instanceof OrItem && + ((OrItem)oldRoot).getItemIndex(origQueryItem)!=-1 && + origQueryItem instanceof AndItem) { + + // Remove original unboosted query + if(!keepOriginalQuery) + ((OrItem)oldRoot).removeItem(origQueryItem); + + // Check if the tree already contained the phrase item + PhraseItem pI = convertAndToPhrase((AndItem)origQueryItem); + if(((OrItem)oldRoot).getItemIndex(pI)==-1) { + ((OrItem)oldRoot).addItem(convertAndToPhrase((AndItem)origQueryItem)); + RewriterUtils.log(logger, query, "Added proximity boosting successfully"); + return query; + } + } + RewriterUtils.log(logger, query, "No proximity boosting added"); + return query; + } + + /** + * <p>Add query expansion to the query tree</p> + * e.g. origQuery: aa bb<br> + * matchingStr: aa bb<br> + * rewrite: cc dd, ee ff<br> + * if addUnitToRewrites: false<br> + * new query tree: (OR (AND aa bb) (AND cc dd) (AND ee ff))<br> + * if addUnitToRewrites: true<br> + * new query tree: (OR (AND aa bb) "cc dd" "ee ff") <br> + * + * @param query Query object from searcher + * @param matchingStr string used to retrieve the rewrite + * @param rewrites The rewrite string retrieved from + * dictionary + * @param addUnitToRewrites Whether to add unit to rewrites + * @param maxNumRewrites Max number of rewrites to be added, + * 0 if no limit + * @return Modified Query object, return original query object + * on error + */ + public static Query addRewritesAsEquiv(Query query, String matchingStr, + String rewrites, + boolean addUnitToRewrites, + int maxNumRewrites) throws RuntimeException { + String normalizedQuery = RewriterUtils.getNormalizedOriginalQuery(query); + + RewriterUtils.log(logger, query, + "Adding rewrites [" + rewrites + + "] to the query [" + normalizedQuery + "]"); + if (rewrites.equalsIgnoreCase(normalizedQuery) || rewrites.equalsIgnoreCase("n/a")) { + RewriterUtils.log(logger, query, "No rewrite added"); + return query; + } + + Model queryModel = query.getModel(); + QueryTree qTree = queryModel.getQueryTree(); + Item oldRoot = qTree.getRoot(); + + if (oldRoot == null) { + RewriterUtils.error(logger, query, "Error retrieving query tree root"); + throw new RuntimeException("Error retrieving query tree root"); + } + + StringTokenizer rewrite_list = new StringTokenizer(rewrites, "\t"); + Item rI = null; + + // Convert matching string to query tree item + Item matchingStrItem = convertStringToQTree(query, matchingStr); + PhraseItem matchingStrPhraseItem = null; + if(matchingStrItem instanceof AndItem) { + matchingStrPhraseItem = convertAndToPhrase(((AndItem)matchingStrItem)); + } + + // Add rewrites as OR item to the query tree + // Only should rewrite in this case: + // - origQuery: (OR (AND aa bb) (AND cc dd)) + // - matchingStr: (AND aa bb) + // Or in this case: + // - origQuery: (AND aa bb) + // - matching Str: (AND aa bb) + // Should not rewrite in this case: + // - origQuery: (OR (AND cc (OR dd (AND aa bb)) ee) + // - matchingStr: (AND aa bb) + // - for this case, should use getNonOverlappingMatches instead + OrItem newRoot; + if(oldRoot instanceof OrItem) { + if(((OrItem)oldRoot).getItemIndex(matchingStrItem)==-1) { + RewriterUtils.log(logger, query, "Whole query matching is used, skipping rewrite"); + return query; + } + newRoot = (OrItem)oldRoot; + } else if(oldRoot.equals(matchingStrItem) || oldRoot.equals(matchingStrPhraseItem)) { + newRoot = new OrItem(); + newRoot.addItem(oldRoot); + } else { + RewriterUtils.log(logger, query, "Whole query matching is used, skipping rewrite"); + return query; + } + int numRewrites = 0; + while(rewrite_list.hasMoreTokens() && + (maxNumRewrites==0 || numRewrites < maxNumRewrites)) { + rI = convertStringToQTree(query, rewrite_list.nextToken()); + if(addUnitToRewrites && rI instanceof AndItem) { + rI = convertAndToPhrase((AndItem)rI); + } + if(newRoot.getItemIndex(rI)==-1) { + newRoot.addItem(rI); + numRewrites++; + } else { + RewriterUtils.log(logger, query, "Rewrite already exist, skipping"); + } + } + qTree.setRoot(newRoot); + queryModel.setType(Query.Type.ADVANCED); //set type=adv + RewriterUtils.log(logger, query, "Added rewrite successfully"); + + return query; + } + + /** + * <p>Retrieve the longest, from left to right non overlapping full + * phrase substrings in query based on FSA dictionary</p> + * + * e.g. query: ((modern AND new AND york AND city AND travel) OR travel) AND + * ((sunny AND travel AND agency) OR nyc)<br> + * dictionary: <br> + * mny\tmodern new york<br> + * mo\tmodern<br> + * modern\tn/a<br> + * modern\tnew york\tn/a<br> + * new york\tn/a<br> + * new york city\tn/a<br> + * new york city travel\tn/a<br> + * new york company\tn/a<br> + * ny\tnew york<br> + * nyc\tnew york city\tnew york company<br> + * nyct\tnew york city travel<br> + * ta\ttravel agency<br> + * travel agency\tn/a<br> + * return: nyc + * @param phraseMatcher PhraseMatcher object loaded with FSA dict + * @param query Query object from the searcher + * @return Matching phrases + */ + public static Set<PhraseMatcher.Phrase> getNonOverlappingFullPhraseMatches(PhraseMatcher phraseMatcher, + Query query) + throws RuntimeException { + RewriterUtils.log(logger, query, "Retrieving longest non-overlapping full phrase matches"); + if(phraseMatcher==null) + return null; + + Item root = query.getModel().getQueryTree().getRoot(); + List<PhraseMatcher.Phrase> matches = phraseMatcher.matchPhrases(root); + if (matches==null || matches.isEmpty()) + return null; + + Set<PhraseMatcher.Phrase> resultMatches = new HashSet<>(); + ListIterator<Phrase> matchesIter = matches.listIterator(); + + // Iterate through all matches + while(matchesIter.hasNext()) { + PhraseMatcher.Phrase phrase = matchesIter.next(); + RewriterUtils.log(logger, query, "Working on phrase: " + phrase); + CompositeItem currOwner = phrase.getOwner(); + + // Check if this is full phrase + // If phrase is not an AND item, only keep those that are single word + // in order to eliminate cases such as (new RANK york) from being treated + // as match if only new york but not new or york is in the dictionary + if((currOwner!=null && + ((phrase.isComplete() && currOwner instanceof AndItem) || + (phrase.getLength()==1 && currOwner instanceof OrItem) || + (phrase.getLength()==1 && currOwner instanceof RankItem && phrase.getStartIndex()==0))) || + (currOwner==null && phrase.getLength()==1)) { + resultMatches.add(phrase); + RewriterUtils.log(logger, query, "Keeping phrase: " + phrase); + } + } + + RewriterUtils.log(logger, query, "Successfully Retrieved longest non-overlapping full phrase matches"); + return resultMatches; + } + + + /** + * <p>Retrieve the longest, from left to right non overlapping partial + * phrase substrings in query based on FSA dictionary</p> + * + * e.g. query: ((modern AND new AND york AND city AND travel) OR travel) AND + * ((sunny AND travel AND agency) OR nyc)<br> + * dictionary: <br> + * mny\tmodern new york<br> + * mo\tmodern<br> + * modern\tn/a<br> + * modern new york\tn/a<br> + * new york\tn/a<br> + * new york city\tn/a<br> + * new york city travel\tn/a<br> + * new york company\tn/a<br> + * ny\tnew york<br> + * nyc\tnew york city\tnew york company<br> + * nyct\tnew york city travel<br> + * ta\ttravel agency<br> + * travel agency\tn/a<br> + * return: <br> + * modern<br> + * new york city travel<br> + * travel agency<br> + * nyc<br> + * @param phraseMatcher PhraseMatcher object loaded with FSA dict + * @param query Query object from the searcher + * @return Matching phrases + */ + public static Set<PhraseMatcher.Phrase> getNonOverlappingPartialPhraseMatches(PhraseMatcher phraseMatcher, + Query query) + throws RuntimeException { + RewriterUtils.log(logger, query, "Retrieving longest non-overlapping partial phrase matches"); + if(phraseMatcher==null) + return null; + + Item root = query.getModel().getQueryTree().getRoot(); + List<PhraseMatcher.Phrase> matches = phraseMatcher.matchPhrases(root); + if (matches==null || matches.isEmpty()) + return null; + + Set<PhraseMatcher.Phrase> resultMatches = new HashSet<>(); + ArrayList<PhraseMatcher.Phrase> phrasesInSubTree = new ArrayList<>(); + CompositeItem prevOwner = null; + ListIterator<PhraseMatcher.Phrase> matchesIter = matches.listIterator(); + + // Iterate through all matches + while(matchesIter.hasNext()) { + PhraseMatcher.Phrase phrase = matchesIter.next(); + RewriterUtils.log(logger, query, "Working on phrase: " + phrase); + CompositeItem currOwner = phrase.getOwner(); + + // Check if previous is AND item and this phrase is in a different item + // If so, work on the previous set to eliminate overlapping matches + if(!phrasesInSubTree.isEmpty() && currOwner!=null && + prevOwner!=null && !currOwner.equals(prevOwner)) { + RewriterUtils.log(logger, query, "Previous phrase is in different AND item"); + List<PhraseMatcher.Phrase> subTreeMatches + = getNonOverlappingMatchesInAndItem(phrasesInSubTree, query); + if(subTreeMatches==null) { + RewriterUtils.error(logger, query, "Error retrieving matches from subtree"); + throw new RuntimeException("Error retrieving matches from subtree"); + } + resultMatches.addAll(subTreeMatches); + phrasesInSubTree.clear(); + } + + // Check if this is an AND item + if(currOwner!=null && currOwner instanceof AndItem) { + phrasesInSubTree.add(phrase); + // If phrase is not an AND item, only keep those that are single word + // in order to eliminate cases such as (new RANK york) from being treated + // as match if only new york but not new or york is in the dictionary + } else if (phrase.getLength()==1 && + !(currOwner!=null && currOwner instanceof RankItem && phrase.getStartIndex()!=0)) { + resultMatches.add(phrase); + } + + prevOwner = currOwner; + } + + // Check if last item is AND item + // If so, work on the previous set to elimate overlapping matches + if(!phrasesInSubTree.isEmpty()) { + RewriterUtils.log(logger, query, "Last phrase is in AND item"); + List<PhraseMatcher.Phrase> subTreeMatches + = getNonOverlappingMatchesInAndItem(phrasesInSubTree, query); + if(subTreeMatches==null) { + RewriterUtils.error(logger, query, "Error retrieving matches from subtree"); + throw new RuntimeException("Error retrieving matches from subtree"); + } + resultMatches.addAll(subTreeMatches); + } + RewriterUtils.log(logger, query, "Successfully Retrieved longest non-overlapping partial phrase matches"); + return resultMatches; + } + + /** + * <p>Retrieve the longest, from left to right non overlapping substrings in + * AndItem based on FSA dictionary</p> + * + * e.g. subtree: (modern AND new AND york AND city AND travel)<br> + * dictionary:<br> + * mny\tmodern new york<br> + * mo\tmodern<br> + * modern\tn/a<br> + * modern new york\tn/a<br> + * new york\tn/a<br> + * new york city\tn/a<br> + * new york city travel\tn/a<br> + * new york company\tn/a<br> + * ny\tnew york<br> + * nyc\tnew york city\tnew york company<br> + * nyct\tnew york city travel<br> + * allMatches:<br> + * modern<br> + * modern new york<br> + * new york<br> + * new york city<br> + * new york city travel<br> + * return: <br> + * modern<br> + * new york city travel<br> + * @param allMatches All matches within the subtree + * @param query Query object from the searcher + * @return Matching phrases + */ + public static List<PhraseMatcher.Phrase> getNonOverlappingMatchesInAndItem( + List<PhraseMatcher.Phrase> allMatches, + Query query) + throws RuntimeException { + RewriterUtils.log(logger, query, "Retrieving longest non-overlapping matches in subtree"); + + if (allMatches==null || allMatches.isEmpty()) + return null; + + if(allMatches.size()==1) { + RewriterUtils.log(logger, query, "Only one match in subtree"); + return allMatches; + } + + // Phrase are sorted based on length, if both have the + // same length, the lefter one ranks higher + RewriterUtils.log(logger, query, "Sorting the phrases"); + PhraseLength phraseLength = new PhraseLength(); + Collections.sort(allMatches, phraseLength); + + // Create a bitset with length equal to the number of + // items in the subtree + int numWords = allMatches.get(0).getOwner().getItemCount(); + BitSet matchPos = new BitSet(numWords); + + // Removing matches that are overlapping with previously selected ones + RewriterUtils.log(logger, query, "Removing matches that are overlapping " + + "with previously selected ones"); + ListIterator<Phrase> allMatchesIter = allMatches.listIterator(); + while(allMatchesIter.hasNext()) { + PhraseMatcher.Phrase currMatch = allMatchesIter.next(); + PhraseMatcher.Phrase.MatchIterator matchIter = currMatch.itemIterator(); + if(matchIter.hasNext() && matchIter.next().isFilter()) { + RewriterUtils.log(logger, query, "Removing filter item" + currMatch); + allMatchesIter.remove(); + continue; + } + + BitSet currMatchPos = new BitSet(numWords); + currMatchPos.set(currMatch.getStartIndex(), + currMatch.getLength()+currMatch.getStartIndex()); + if(currMatchPos.intersects(matchPos)) { + RewriterUtils.log(logger, query, "Removing " + currMatch); + allMatchesIter.remove(); + } else { + RewriterUtils.log(logger, query, "Keeping " + currMatch); + matchPos.or(currMatchPos); + } + } + return allMatches; + } + + /** + * <p>Add Expansions to the matching phrases</p> + * + * e.g. Query: nyc travel agency<br> + * matching phrase: nyc\tnew york city\tnew york company + * travel agency\tn/a<br> + * if expandIndex is not null and removeOriginal is true<br> + * New Query: ((new york city) OR ([expandIndex]:new york city) + * OR (new york company) OR + * ([expandIndex]:new york company)) AND + * ((travel agency) OR ([expandIndex]:travel agency))<br> + * if expandIndex is null and removeOriginal is true<br> + * New Query: ((new york city) OR (new york company)) AND + * travel agency<br> + * if expandIndex is null and removeOriginal is false<br> + * New Query: (nyc OR (new york city) OR (new york company)) AND + * travel agency<br> + * + * @param query Query object from searcher + * @param matches Set of longest non-overlapping matches + * @param expandIndex Name of expansion index or null if + * default index + * @param maxNumRewrites Max number of rewrites to be added, + * 0 if no limit + * @param removeOriginal Whether to remove the original matching phrase + * @param addUnitToRewrites Whether to add rewrite as phrase + */ + public static Query addExpansions(Query query, Set<PhraseMatcher.Phrase> matches, + String expandIndex, int maxNumRewrites, + boolean removeOriginal, boolean addUnitToRewrites) + throws RuntimeException { + + if(matches==null) { + RewriterUtils.log(logger, query, "No expansions to be added"); + return query; + } + + RewriterUtils.log(logger, query, "Adding expansions to matching phrases"); + Model queryModel = query.getModel(); + QueryTree qTree = queryModel.getQueryTree(); + Iterator<Phrase> matchesIter = matches.iterator(); + CompositeItem parent = null; + + // Iterate through all matches + while(matchesIter.hasNext()) { + PhraseMatcher.Phrase match = matchesIter.next(); + RewriterUtils.log(logger, query, "Working on phrase: " + match); + + // Retrieve expansion phrases + String expansionStr = match.getData(); + if(expansionStr.equalsIgnoreCase("n/a") && expandIndex==null) { + continue; + } + StringTokenizer expansions = new StringTokenizer(expansionStr,"\t"); + + // Create this structure for all expansions of this match + // (OR (AND expandsion1) indexName:expansion1 + // (AND expansion2) indexName:expansion2..) + OrItem expansionGrp = new OrItem(); + int numRewrites = 0; + String matchStr = convertMatchToString(match); + while(expansions.hasMoreTokens() && + (maxNumRewrites==0 || numRewrites < maxNumRewrites)) { + String expansion = expansions.nextToken(); + RewriterUtils.log(logger, query, "Working on expansion: " + expansion); + if(expansion.equalsIgnoreCase("n/a")) { + expansion = matchStr; + } + // (AND expansion) or "expansion" + Item expansionItem = convertStringToQTree(query, expansion); + if(addUnitToRewrites && expansionItem instanceof AndItem) { + expansionItem = convertAndToPhrase((AndItem)expansionItem); + } + expansionGrp.addItem(expansionItem); + + if(expandIndex!=null) { + // indexName:expansion + WordItem expansionIndexItem = new WordItem(expansion, expandIndex); + expansionGrp.addItem(expansionIndexItem); + } + numRewrites++; + RewriterUtils.log(logger, query, "Adding expansion: " + expansion); + } + + if(!removeOriginal) { + //(AND original) + Item matchItem = convertStringToQTree(query, matchStr); + if(expansionGrp.getItemIndex(matchItem)==-1) { + expansionGrp.addItem(matchItem); + } + } + + parent = match.getOwner(); + int matchIndex = match.getStartIndex(); + if(parent!=null) { + // Remove matching phrase from original query + for(int i=0; i<match.getLength(); i++) { + parent.removeItem(matchIndex); + } + // Adding back expansions + parent.addItem(matchIndex, expansionGrp); + } else { + RewriterUtils.log(logger, query, "Single root item"); + // If there's no parent, i.e. single root item + qTree.setRoot(expansionGrp); + break; + } + } + + // Not root single item + if(parent!=null) { + // Cleaning up the query after rewrite to remove redundant tags + // e.g. (AND (OR (AND a b) c)) => (OR (AND a b) c) + String cleanupError = QueryCanonicalizer.canonicalize(qTree); + if(cleanupError!=null) { + RewriterUtils.error(logger, query, "Error canonicalizing query tree"); + throw new RuntimeException("Error canonicalizing query tree"); + } + } + queryModel.setType(Query.Type.ADVANCED); //set type=adv + RewriterUtils.log(logger, query, "Successfully added expansions to matching phrases"); + return query; + } + + /** + * Convert Match to String + * + * @param phrase Match from PhraseMatcher + * @return String format of the phrase + */ + public static String convertMatchToString(PhraseMatcher.Phrase phrase) { + StringBuilder buffer = new StringBuilder(); + for (Iterator<Item> i = phrase.itemIterator(); i.hasNext();) { + buffer.append(i.next().toString()); + if (i.hasNext()) { + buffer.append(" "); + } + } + return buffer.toString(); + } + + /** + * Convert String to query tree + * + * @param stringToParse The string to be converted to a + * query tree + * @param query Query object from searcher + * @return Item The resulting query tree + */ + static Item convertStringToQTree(Query query, String stringToParse) { + RewriterUtils.log(logger, query, "Converting string [" + stringToParse + "] to query tree"); + if(stringToParse==null) { + return new NullItem(); + } + Model model = query.getModel(); + CustomParser parser = (CustomParser) ParserFactory.newInstance(model.getType(), + ParserEnvironment.fromExecutionContext(query.getModel().getExecution().context())); + IndexFacts indexFacts = new IndexFacts(); + Item item = parser.parse(stringToParse, null, model.getParsingLanguage(), + indexFacts.newSession(model.getSources(), model.getRestrict()), + model.getDefaultIndex()); + RewriterUtils.log(logger, query, "Converted string: [" + item.toString() + "]"); + return item; + } + + /** + * Convert AndItem to PhraseItem<br> + * + * e.g. (AND a b) to "a b" + * @param andItem query tree to be converted + * @return converted PhraseItem + */ + private static PhraseItem convertAndToPhrase(AndItem andItem) { + PhraseItem result = new PhraseItem(); + Iterator<Item> subItems = andItem.getItemIterator(); + while(subItems.hasNext()) { + Item curr = (subItems.next()); + if(curr instanceof IntItem) { + WordItem numItem = new WordItem(((IntItem)curr).stringValue()); + result.addItem(numItem); + } else { + result.addItem(curr); + } + } + return result; + } + + /** + * Class for comparing phrase. + * A phrase is larger if its length is longer. + * If both phrases are of the same length, the lefter one + * is considered larger + */ + private static class PhraseLength implements Comparator<PhraseMatcher.Phrase> { + public int compare(PhraseMatcher.Phrase phrase1, PhraseMatcher.Phrase phrase2) { + if((phrase2.getLength()>phrase1.getLength()) || + (phrase2.getLength()==phrase1.getLength() && + phrase2.getStartIndex()<=phrase1.getStartIndex())) { + return 1; + } else { + return -1; + } + } + } +} diff --git a/container-search/src/main/java/com/yahoo/search/query/rewrite/RewriterUtils.java b/container-search/src/main/java/com/yahoo/search/query/rewrite/RewriterUtils.java new file mode 100644 index 00000000000..26ead8de5e5 --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/rewrite/RewriterUtils.java @@ -0,0 +1,334 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.query.rewrite; + +import com.yahoo.fsa.FSA; +import com.yahoo.log.LogLevel; +import com.yahoo.search.Query; +import com.yahoo.search.intent.model.IntentModel; +import com.yahoo.search.intent.model.InterpretationNode; +import com.yahoo.text.interpretation.Annotations; +import com.yahoo.text.interpretation.Modification; + +import java.io.File; +import java.io.IOException; +import java.util.HashMap; +import java.util.logging.Logger; + +import static com.yahoo.language.LinguisticsCase.toLowerCase; + +/** + * Contains common utilities used by rewriters + * + * @author Karen Sze Wing Lee + */ +public class RewriterUtils { + + private static final Logger utilsLogger = Logger.getLogger(RewriterUtils.class.getName()); + + // Tracelevel for debug log of this rewriter + private static final int TRACELEVEL = 3; + + /** + * Load FSA from file + * + * @param file FSA dictionary file object + * @param query Query object from the searcher, could be null if not available + * @return FSA The FSA object for the input file path + */ + public static FSA loadFSA(File file, Query query) throws IOException { + log(utilsLogger, query, "Loading FSA file"); + String filePath = null; + + try { + filePath = file.getAbsolutePath(); + } catch (SecurityException e1) { + error(utilsLogger, query, "No read access for the FSA file"); + throw new IOException("No read access for the FSA file"); + } + + FSA fsa = loadFSA(filePath, query); + + return fsa; + } + + /** + * Load FSA from file + * + * @param filename FSA dictionary file path + * @param query Query object from the searcher, could be null if not available + * @return FSA The FSA object for the input file path + */ + public static FSA loadFSA(String filename, Query query) throws IOException { + log(utilsLogger, query, "Loading FSA file from: " + filename); + + if(!new File(filename).exists()) { + error(utilsLogger, query, "File does not exist : " + filename); + throw new IOException("File does not exist : " + filename); + } + + FSA fsa; + try { + fsa = new FSA(filename); + } catch (RuntimeException e) { + error(utilsLogger, query, "Invalid FSA file"); + throw new IOException("Invalid FSA file"); + } + + if (!fsa.isOk()) { + error(utilsLogger, query, "Unable to load FSA file from : " + filename); + throw new IOException("Not able to load FSA file from : " + filename); + } + log(utilsLogger, query, "Loaded FSA successfully from file : " + filename); + return fsa; + } + + /** + * Retrieve rewrite from FSA given the original query + * + * @param query Query object from searcher + * @param dictName FSA dictionary name + * @param rewriterDicts list of rewriter dictionaries + * It has the following format: + * HashMap<dictionary name, FSA> + * @param key The original query used to retrieve rewrite + * from the dictionary + * @return String The retrieved rewrites, null if query + * doesn't exist + */ + public static String getRewriteFromFSA(Query query, + HashMap<String, Object> rewriterDicts, + String dictName, + String key) throws RuntimeException { + if(rewriterDicts==null) { + error(utilsLogger, query, "HashMap containing rewriter dicts is null"); + throw new RuntimeException("HashMap containing rewriter dicts is null"); + } + + FSA fsa = (FSA)rewriterDicts.get(dictName); + + if(fsa==null) { + error(utilsLogger, query, "Error retrieving FSA dictionary: " + dictName); + throw new RuntimeException("Error retrieving FSA dictionary: " + dictName); + } + + String result = null; + result = fsa.lookup(key); + log(utilsLogger, query, "Retrieved rewrite: " + result); + + return result; + } + + /** + * Get config parameter value set in query profile + * + * @param query Query object from the searcher + * @param rewriterName Name of the rewriter + * @param paramName parameter to be retrieved + * @return parameter value or null if not found + */ + public static String getQPConfig(Query query, + String rewriterName, + String paramName) { + log(utilsLogger, query, "Retrieving config parameter value of: " + + rewriterName + "." + paramName); + + return getUserParam(query, rewriterName + "." + paramName); + } + + /** + * Get rewriter chain value + * + * @param query Query object from the searcher + * @return parameter value or null if not found + */ + public static String getRewriterChain(Query query) { + log(utilsLogger, query, "Retrieving rewriter chain value: " + + RewriterConstants.REWRITER_CHAIN); + + return getUserParam(query, RewriterConstants.REWRITER_CHAIN); + } + + /** + * Get user param value + * + * @param query Query object from the searcher + * @param paramName parameter to be retrieved + * + * @return parameter value or null if not found + */ + public static String getUserParam(Query query, String paramName) { + log(utilsLogger, query, "Retrieving user param value: " + paramName); + + if(paramName==null) { + error(utilsLogger, query, "Parameter name is null"); + return null; + } + + String paramValue = null; + paramValue = query.properties().getString(paramName); + log(utilsLogger, query, "Param value retrieved is: " + paramValue); + + return paramValue; + } + + /** + * Retrieve metadata passed by previous rewriter + * from query properties + * Initialize values if this is the first rewriter + * + * @param query Query object from the searcher + * @return hashmap containing the metadata + */ + public static HashMap<String, Object> getRewriteMeta(Query query) { + log(utilsLogger, query, "Retrieving metadata passed by previous rewriter"); + + @SuppressWarnings("unchecked") + HashMap<String, Object> rewriteMeta = (HashMap<String, Object>) query + .properties().get(RewriterConstants.REWRITE_META); + + if(rewriteMeta==null) { + log(utilsLogger, query, "No metadata available from previous rewriter"); + rewriteMeta = new HashMap<>(); + rewriteMeta.put(RewriterConstants.REWRITTEN, false); + rewriteMeta.put(RewriterConstants.DICT_KEY, getNormalizedOriginalQuery(query)); + } else { + if((Boolean)rewriteMeta.get(RewriterConstants.REWRITTEN)) { + log(utilsLogger, query, "Query has been rewritten by previous rewriters"); + } else { + log(utilsLogger, query, "Query has not been rewritten by previous rewriters"); + } + log(utilsLogger, query, "Dict key passed by previous rewriter: " + + rewriteMeta.get(RewriterConstants.DICT_KEY)); + } + + return rewriteMeta; + } + + /** + * Pass metadata to the next rewriter through query properties + * + * @param query Query object from the searcher + * @param metadata HashMap containing the metadata + */ + public static void setRewriteMeta(Query query, HashMap<String, Object> metadata) { + log(utilsLogger, query, "Passing metadata to the next rewriter"); + + query.properties().set(RewriterConstants.REWRITE_META, metadata); + log(utilsLogger, query, "Successfully passed metadata to the next rewriter"); + } + + + /** + * Retrieve spell corrected query with highest score from QLAS + * + * @param query Query object from the searcher + * @param qss_rw Whether to consider qss_rw modification + * @param qss_sugg Whether ot consider qss_sugg modification + * @return Spell corrected query or null if not found + */ + public static String getSpellCorrected(Query query, + boolean qss_rw, + boolean qss_sugg) + throws RuntimeException { + log(utilsLogger, query, "Retrieving spell corrected query"); + + // Retrieve Intent Model + IntentModel intentModel = IntentModel.getFrom(query); + if(intentModel==null) { + error(utilsLogger, query, "Unable to retrieve intent model"); + throw new RuntimeException("Not able to retrieve intent model"); + } + + double max_score = 0; + String spellCorrected = null; + + // Iterate through all interpretations to get a spell corrected + // query with highest score + for (InterpretationNode interpretationNode : intentModel.children()) { + Modification modification = interpretationNode.getInterpretation() + .getModification(); + Annotations annotations = modification.getAnnotation(); + Double score = annotations.getDouble("score"); + + // Check if it's higher than the max score + if(score!=null && score>max_score) { + Boolean isQSSRewrite = annotations.getBoolean("qss_rw"); + Boolean isQSSSuggest = annotations.getBoolean("qss_sugg"); + + // Check if it's qss_rw or qss_sugg + if((qss_rw && isQSSRewrite!=null && isQSSRewrite) || + (qss_sugg && isQSSSuggest!=null && isQSSSuggest)) { + max_score = score; + spellCorrected = modification.getText(); + } + } + } + + if(spellCorrected!=null) { + log(utilsLogger, query, "Successfully retrieved spell corrected query: " + + spellCorrected); + } else { + log(utilsLogger, query, "No spell corrected query is retrieved"); + } + + return spellCorrected; + } + + /** + * Retrieve normalized original query from query object + * + * @param query Query object from searcher + * @return normalized query + */ + public static String getNormalizedOriginalQuery(Query query) { + return toLowerCase(query.getModel().getQueryString()).trim(); + } + + /** + * Log message + * + * @param logger Logger used for this msg + * @param msg Log message + */ + public static void log(Logger logger, String msg) { + logger.log(LogLevel.DEBUG, logger.getName() + ": " + msg); + } + + /** + * Log message + * + * @param logger Logger used for this msg + * @param query Query object from searcher + * @param msg Log message + */ + public static void log(Logger logger, Query query, String msg) { + if(query!=null) { + query.trace(logger.getName() + ": " + msg, true, TRACELEVEL); + } + logger.log(LogLevel.DEBUG, logger.getName() + ": " + msg); + } + + /** + * Print error message + * + * @param logger Logger used for this msg + * @param msg Error message + */ + public static void error(Logger logger, String msg) { + logger.severe(logger.getName() + ": " + msg); + } + + /** + * Print error message + * + * @param logger Logger used for this msg + * @param query Query object from searcher + * @param msg Error message + */ + public static void error(Logger logger, Query query, String msg) { + if(query!=null) { + query.trace(logger.getName() + ": " + msg, true, TRACELEVEL); + } + logger.severe(logger.getName() + ": " + msg); + } +} diff --git a/container-search/src/main/java/com/yahoo/search/query/rewrite/SearchChainDispatcherSearcher.java b/container-search/src/main/java/com/yahoo/search/query/rewrite/SearchChainDispatcherSearcher.java new file mode 100644 index 00000000000..589696c4e77 --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/rewrite/SearchChainDispatcherSearcher.java @@ -0,0 +1,74 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.query.rewrite; + +import com.yahoo.component.chain.Chain; +import com.yahoo.component.chain.dependencies.After; +import com.yahoo.component.chain.dependencies.Provides; +import com.yahoo.search.*; +import com.yahoo.search.searchchain.Execution; +import com.yahoo.component.ComponentId; + +import java.util.logging.Logger; + +/** + * Execute rewriter search chain specified by the user. + * It's inteneded to be used for executing rewriter search chains + * for different markets. + * + * @author Karen Sze Wing Lee + */ +@Provides("SearchChainDispatcher") +@After("QLAS") +public class SearchChainDispatcherSearcher extends Searcher { + + protected final Logger logger = Logger.getLogger(SearchChainDispatcherSearcher.class.getName()); + + /** + * Constructor for this searcher + * @param id Component ID (see vespa's search container doc for more detail) + */ + public SearchChainDispatcherSearcher(ComponentId id) { + super(id); + } + + /** + * Constructor for unit test + */ + public SearchChainDispatcherSearcher() { + } + + /** + * Execute another search chain specified by the user<br> + * - Retrieve search chain specified by the user through + * param<br> + * - Execute specified search chain if exist + */ + public @Override Result search(Query query, Execution execution) { + RewriterUtils.log(logger, query, "Entering SearchChainDispatcherSearcher"); + + // Retrieve search chain specified by user through REWRITER_CHAIN + String rewriterChain = RewriterUtils.getRewriterChain(query); + + // Skipping to next searcher if no rewriter chain is specified + if(rewriterChain==null || rewriterChain.equals("")) { + RewriterUtils.log(logger, query, "No rewriter chain is specified, " + + "skipping to the next searcher"); + return execution.search(query); + } + + // Execute rewriter search chain + RewriterUtils.log(logger, query, "Redirecting to chain " + rewriterChain); + Chain<Searcher> myChain = execution.searchChainRegistry().getChain(rewriterChain); + if(myChain==null) { + RewriterUtils.log(logger, query, "Invalid search chain specified, " + + "skipping to the next searcher"); + return execution.search(query); + } + new Execution(myChain, execution.context()).search(query); + RewriterUtils.log(logger, query, "Finish executing search chain " + rewriterChain); + + // Continue down the chain ignoring the result from REWRITER_CHAIN + // since the rewriters only modify the query but not the result + return execution.search(query); + } +} diff --git a/container-search/src/main/java/com/yahoo/search/query/rewrite/package-info.java b/container-search/src/main/java/com/yahoo/search/query/rewrite/package-info.java new file mode 100644 index 00000000000..c435ed45623 --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/rewrite/package-info.java @@ -0,0 +1,7 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +@ExportPackage +@PublicApi +package com.yahoo.search.query.rewrite; + +import com.yahoo.api.annotations.PublicApi; +import com.yahoo.osgi.annotation.ExportPackage; diff --git a/container-search/src/main/java/com/yahoo/search/query/rewrite/rewriters/GenericExpansionRewriter.java b/container-search/src/main/java/com/yahoo/search/query/rewrite/rewriters/GenericExpansionRewriter.java new file mode 100644 index 00000000000..3d57675c4ab --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/rewrite/rewriters/GenericExpansionRewriter.java @@ -0,0 +1,213 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.query.rewrite.rewriters; + +import java.io.*; +import java.util.*; +import java.util.logging.Logger; + +import com.google.inject.Inject; +import com.yahoo.component.chain.dependencies.Provides; +import com.yahoo.fsa.FSA; +import com.yahoo.search.query.rewrite.*; +import com.yahoo.search.*; +import com.yahoo.component.ComponentId; +import com.yahoo.filedistribution.fileacquirer.FileAcquirer; +import com.yahoo.search.query.rewrite.RewritesConfig; +import com.yahoo.prelude.querytransform.PhraseMatcher; + +/** + * This rewriter would add rewrites to entities (e.g abbreviation, synonym, etc)<br> + * to boost precision + * - FSA dict: [normalized original query]\t[rewrite 1]\t[rewrite 2]\t[etc]<br> + * - Features:<br> + * RewritesAsUnitEquiv flag: add proximity boosted rewrites<br> + * PartialPhraseMatch flag: whether to match whole phrase or partial phrase<br> + * MaxRewrites flag: the maximum number of rewrites to be added<br> + * + * @author Karen Sze Wing Lee + */ +@Provides("GenericExpansionRewriter") +public class GenericExpansionRewriter extends QueryRewriteSearcher { + + // Flag for skipping this rewriter if the query has been rewritten + private final boolean SKIP_REWRITER_IF_REWRITTEN = false; + + // Name of the rewriter + public static final String REWRITER_NAME = "GenericExpansionRewriter"; + + // Generic expansion dictionary name + public static final String GENERIC_EXPAND_DICT = "GenericExpansion"; + + // Default generic expansion dictionary file name + public static final String GENERIC_EXPAND_DICT_FILENAME = "GenericExpansionRewriter.fsa"; + + // PhraseMatcher created from FSA dict + private PhraseMatcher phraseMatcher; + + private Logger logger; + + + /** + * Constructor for GenericExpansionRewriter. + * Load configs using default format + */ + @Inject + public GenericExpansionRewriter(ComponentId id, + FileAcquirer fileAcquirer, + RewritesConfig config) { + super(id, fileAcquirer, config); + } + + /** + * Constructor for GenericExpansionRewriter unit test. + * Load configs using default format + */ + public GenericExpansionRewriter(RewritesConfig config, + HashMap<String, File> fileList) { + super(config, fileList); + } + + /** + * Instance creation time config loading besides FSA. + * Create PhraseMatcher from FSA dict + */ + public boolean configure(FileAcquirer fileAcquirer, + RewritesConfig config, + HashMap<String, File> fileList) { + logger = Logger.getLogger(GenericExpansionRewriter.class.getName()); + FSA fsa = (FSA)rewriterDicts.get(GENERIC_EXPAND_DICT); + if(fsa==null) { + RewriterUtils.error(logger, "Error retrieving FSA dictionary: " + + GENERIC_EXPAND_DICT); + return false; + } + // Create Phrase Matcher + RewriterUtils.log(logger, "Creating PhraseMatcher"); + try { + phraseMatcher = new PhraseMatcher(fsa, false); + } catch (IllegalArgumentException e) { + RewriterUtils.error(logger, "Error creating phrase matcher"); + return false; + } + + // Match single word as well + phraseMatcher.setMatchSingleItems(true); + + // Return all matches instead of only the longest match + phraseMatcher.setMatchAll(true); + + return true; + } + + /** + * Main logic of rewriter<br> + * - Retrieve rewrites from FSA dict<br> + * - rewrite query using features that are enabled by user + */ + public HashMap<String, Object> rewrite(Query query, + String dictKey) throws RuntimeException { + + Boolean rewritten = false; + + // Pass the original dict key to the next rewriter + HashMap<String, Object> result = new HashMap<>(); + result.put(RewriterConstants.REWRITTEN, rewritten); + result.put(RewriterConstants.DICT_KEY, dictKey); + + RewriterUtils.log(logger, query, + "In GenericExpansionRewriter, query used for dict retrieval=[" + dictKey + "]"); + + // Retrieve flags for choosing between whole query match + // or partial query match + String partialPhraseMatch = getQPConfig(query, RewriterConstants.PARTIAL_PHRASE_MATCH); + + if(partialPhraseMatch==null) { + RewriterUtils.error(logger, query, "Required param " + RewriterConstants.PARTIAL_PHRASE_MATCH + + " is not set, skipping rewriter"); + throw new RuntimeException("Required param " + RewriterConstants.PARTIAL_PHRASE_MATCH + + " is not set, skipping rewriter"); + } + + // Retrieve max number of rewrites allowed + int maxNumRewrites = 0; + String maxNumRewritesStr = getQPConfig(query, RewriterConstants.MAX_REWRITES); + if(maxNumRewritesStr!=null) { + maxNumRewrites = Integer.parseInt(maxNumRewritesStr); + RewriterUtils.log(logger, query, + "Limiting max number of rewrites to: " + maxNumRewrites); + } else { + RewriterUtils.log(logger, query, "No limit on number of rewrites"); + } + + // Retrieve flags for choosing whether to add + // the rewrites as phrase, default to false + String rewritesAsUnitEquiv = getQPConfig(query, RewriterConstants.REWRITES_AS_UNIT_EQUIV); + if(rewritesAsUnitEquiv==null) { + rewritesAsUnitEquiv = "false"; + } + + Set<PhraseMatcher.Phrase> matches; + + // Partial Phrase Matching + if(partialPhraseMatch.equalsIgnoreCase("true")) { + RewriterUtils.log(logger, query, "Partial phrase matching"); + + // Retrieve longest non overlapping matches + matches = RewriterFeatures.getNonOverlappingPartialPhraseMatches(phraseMatcher, query); + + // Full Phrase Matching if set to anything else + } else { + RewriterUtils.log(logger, query, "Full phrase matching"); + + // Retrieve longest non overlapping matches + matches = RewriterFeatures.getNonOverlappingFullPhraseMatches(phraseMatcher, query); + } + + if(matches==null) { + return result; + } + + // Add expansions to the query + query = RewriterFeatures.addExpansions(query, matches, null, maxNumRewrites, false, + rewritesAsUnitEquiv.equalsIgnoreCase("true")); + + rewritten = true; + + RewriterUtils.log(logger, query, "GenericExpansionRewriter final query: " + query.toDetailString()); + + result.put(RewriterConstants.REWRITTEN, rewritten); + + return result; + } + + /** + * Get the flag which specifies whether this rewriter + * should be skipped if the query has been rewritten + * + * @return true if rewriter should be skipped, false + * otherwise + */ + public boolean getSkipRewriterIfRewritten() { + return SKIP_REWRITER_IF_REWRITTEN; + } + + /** + * Get the name of the rewriter + * + * @return Name of the rewriter + */ + public String getRewriterName() { + return REWRITER_NAME; + } + + /** + * Get default FSA dictionary names + * + * @return Pair of FSA dictionary name and filename + */ + public HashMap<String, String> getDefaultFSAs() { + HashMap<String, String> defaultDicts = new HashMap<>(); + defaultDicts.put(GENERIC_EXPAND_DICT, GENERIC_EXPAND_DICT_FILENAME); + return defaultDicts; + } +} diff --git a/container-search/src/main/java/com/yahoo/search/query/rewrite/rewriters/MisspellRewriter.java b/container-search/src/main/java/com/yahoo/search/query/rewrite/rewriters/MisspellRewriter.java new file mode 100644 index 00000000000..a1b46926cbd --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/rewrite/rewriters/MisspellRewriter.java @@ -0,0 +1,151 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.query.rewrite.rewriters; + +import java.io.*; +import java.util.*; +import java.util.logging.Logger; + +import com.google.inject.Inject; +import com.yahoo.component.chain.dependencies.After; +import com.yahoo.component.chain.dependencies.Provides; +import com.yahoo.search.query.rewrite.*; +import com.yahoo.search.*; +import com.yahoo.component.ComponentId; +import com.yahoo.filedistribution.fileacquirer.FileAcquirer; +import com.yahoo.search.query.rewrite.RewritesConfig; + +/** + * This rewriter would retrieve spell corrected query from QLAS and + * add it to the original query tree as equiv<br> + * - Features:<br> + * RewritesAsEquiv flag: add rewrites to original query as equiv + * + * @author Karen Sze Wing Lee + */ +@After("QLAS") +@Provides("MisspellRewriter") +public class MisspellRewriter extends QueryRewriteSearcher { + + // Flag for skipping this rewriter if the query has been rewritten + private final boolean SKIP_REWRITER_IF_REWRITTEN = false; + + // Name of the rewriter + public static final String REWRITER_NAME = "MisspellRewriter"; + + private Logger logger = Logger.getLogger(MisspellRewriter.class.getName()); + + /** + * Constructor for MisspellRewriter + */ + @Inject + public MisspellRewriter(ComponentId id) { + super(id); + } + + /** + * Constructor for MisspellRewriter unit test + */ + public MisspellRewriter() { + super(); + } + + /** + * Instance creation time config loading besides FSA. + * Empty for this rewriter + */ + public boolean configure(FileAcquirer fileAcquirer, + RewritesConfig config, + HashMap<String, File> fileList) { + return true; + } + + /** + * Main logic of rewriter<br> + * - Retrieve spell corrected query from QLAS<br> + * - Add spell corrected query as equiv + */ + public HashMap<String, Object> rewrite(Query query, + String dictKey) throws RuntimeException { + + Boolean rewritten = false; + + HashMap<String, Object> result = new HashMap<>(); + result.put(RewriterConstants.REWRITTEN, rewritten); + result.put(RewriterConstants.DICT_KEY, dictKey); + + RewriterUtils.log(logger, query, + "In MisspellRewriter"); + + // Retrieve flags for enabling the features + String qssRw = getQPConfig(query, RewriterConstants.QSS_RW); + String qssSugg = getQPConfig(query, RewriterConstants.QSS_SUGG); + + boolean isQSSRw = false; + boolean isQSSSugg = false; + + if(qssRw!=null) { + isQSSRw = qssRw.equalsIgnoreCase("true"); + } + if(qssSugg!=null) { + isQSSSugg = qssSugg.equalsIgnoreCase("true"); + } + + // Rewrite is not enabled + if(!isQSSRw && !isQSSSugg) { + return result; + } + + // Retrieve spell corrected query from QLAS + String rewrites = RewriterUtils.getSpellCorrected(query, isQSSRw, isQSSSugg); + + // No rewrites + if(rewrites==null) { + RewriterUtils.log(logger, query, "No rewrite is retrieved"); + return result; + } else { + RewriterUtils.log(logger, query, "Retrieved spell corrected query: " + + rewrites); + } + + // Adding rewrite to the query tree + query = RewriterFeatures.addRewritesAsEquiv(query, dictKey, rewrites, false, 0); + + rewritten = true; + RewriterUtils.log(logger, query, "MisspellRewriter final query: " + + query.toDetailString()); + + result.put(RewriterConstants.REWRITTEN, rewritten); + result.put(RewriterConstants.DICT_KEY, rewrites); + + return result; + } + + /** + * Get the flag which specifies whether this rewriter + * should be skipped if the query has been rewritten + * + * @return true if rewriter should be skipped, false + * otherwise + */ + public boolean getSkipRewriterIfRewritten() { + return SKIP_REWRITER_IF_REWRITTEN; + } + + /** + * Get the name of the rewriter + * + * @return Name of the rewriter + */ + public String getRewriterName() { + return REWRITER_NAME; + } + + /** + * Get default FSA dictionary names + * + * @return Pair of FSA dictionary name and filename + */ + public HashMap<String, String> getDefaultFSAs() { + return null; + } +} diff --git a/container-search/src/main/java/com/yahoo/search/query/rewrite/rewriters/NameRewriter.java b/container-search/src/main/java/com/yahoo/search/query/rewrite/rewriters/NameRewriter.java new file mode 100644 index 00000000000..5ecf7893c63 --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/rewrite/rewriters/NameRewriter.java @@ -0,0 +1,194 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.query.rewrite.rewriters; + +import java.io.*; +import java.util.*; +import java.util.logging.Logger; + +import com.google.inject.Inject; +import com.yahoo.component.chain.dependencies.Provides; +import com.yahoo.search.query.rewrite.*; +import com.yahoo.search.*; +import com.yahoo.component.ComponentId; +import com.yahoo.filedistribution.fileacquirer.FileAcquirer; +import com.yahoo.search.query.rewrite.RewritesConfig; + +/** + * This rewriter would add rewrites to name entities to boost precision<br> + * - FSA dict: [normalized original query]\t[rewrite 1]\t[rewrite 2]\t[etc]<br> + * - Features:<br> + * OriginalAsUnit flag: add proximity boosting to original query<br> + * RewritesAsUnitEquiv flag: add proximity boosted rewrites to original query<br> + * RewritesAsEquiv flag: add rewrites to original query<br> + * + * @author Karen Sze Wing Lee + */ +@Provides("NameRewriter") +public class NameRewriter extends QueryRewriteSearcher { + + // Flag for skipping this rewriter if the query has been rewritten + private final boolean SKIP_REWRITER_IF_REWRITTEN = false; + + // Name of the rewriter + public static final String REWRITER_NAME = "NameRewriter"; + + // Name entity expansion dictionary name + public static final String NAME_ENTITY_EXPAND_DICT = "NameEntityExpansion"; + + // Default Name entity expansion dictionary file name + public static final String NAME_ENTITY_EXPAND_DICT_FILENAME = "NameRewriter.fsa"; + + private Logger logger; + + /** + * Constructor for NameRewriter<br> + * Load configs using default format + */ + @Inject + public NameRewriter(ComponentId id, + FileAcquirer fileAcquirer, + RewritesConfig config) { + super(id, fileAcquirer, config); + } + + /** + * Constructor for NameRewriter unit test<br> + * Load configs using default format + */ + public NameRewriter(RewritesConfig config, + HashMap<String, File> fileList) { + super(config, fileList); + } + + /** + * Instance creation time config loading besides FSA<br> + * Empty for this rewriter + */ + public boolean configure(FileAcquirer fileAcquirer, + RewritesConfig config, + HashMap<String, File> fileList) { + logger = Logger.getLogger(NameRewriter.class.getName()); + return true; + } + + /** + * Main logic of rewriter<br> + * - Retrieve rewrites from FSA dict<br> + * - rewrite query using features that are enabled by user + */ + public HashMap<String, Object> rewrite(Query query, + String dictKey) throws RuntimeException { + + Boolean rewritten = false; + + // Pass the original dict key to the next rewriter + HashMap<String, Object> result = new HashMap<>(); + result.put(RewriterConstants.REWRITTEN, rewritten); + result.put(RewriterConstants.DICT_KEY, dictKey); + + RewriterUtils.log(logger, query, + "In NameRewriter, query used for dict retrieval=[" + dictKey + "]"); + + // Retrieve rewrite from FSA dict using normalized query + String rewrites = super.getRewriteFromFSA(query, NAME_ENTITY_EXPAND_DICT, dictKey); + RewriterUtils.log(logger, query, "Retrieved rewrites: " + rewrites); + + // No rewrites + if(rewrites==null) { + RewriterUtils.log(logger, query, "No rewrite is retrieved"); + return result; + } + + // Retrieve max number of rewrites allowed + int maxNumRewrites = 0; + String maxNumRewritesStr = getQPConfig(query, RewriterConstants.MAX_REWRITES); + if(maxNumRewritesStr!=null) { + maxNumRewrites = Integer.parseInt(maxNumRewritesStr); + RewriterUtils.log(logger, query, + "Limiting max number of rewrites to: " + maxNumRewrites); + } else { + RewriterUtils.log(logger, query, "No limit on number of rewrites"); + } + + // Retrieve flags for enabling the features + String originalAsUnit = getQPConfig(query, RewriterConstants.ORIGINAL_AS_UNIT); + String originalAsUnitEquiv = getQPConfig(query, RewriterConstants.ORIGINAL_AS_UNIT_EQUIV); + String rewritesAsUnitEquiv = getQPConfig(query, RewriterConstants.REWRITES_AS_UNIT_EQUIV); + String rewritesAsEquiv = getQPConfig(query, RewriterConstants.REWRITES_AS_EQUIV); + + // Add proximity boosting to original query and keeping + // the original query if it's enabled + if(originalAsUnitEquiv!=null && originalAsUnitEquiv.equalsIgnoreCase("true")) { + RewriterUtils.log(logger, query, "OriginalAsUnitEquiv is enabled"); + query = RewriterFeatures.addUnitToOriginalQuery(query, dictKey, true); + RewriterUtils.log(logger, query, + "Query after OriginalAsUnitEquiv: " + query.toDetailString()); + rewritten = true; + + // Add proximity boosting to original query + // if it's enabled + } else if(originalAsUnit!=null && originalAsUnit.equalsIgnoreCase("true")) { + RewriterUtils.log(logger, query, "OriginalAsUnit is enabled"); + query = RewriterFeatures.addUnitToOriginalQuery(query, dictKey, false); + RewriterUtils.log(logger, query, + "Query after OriginalAsUnit: " + query.toDetailString()); + rewritten = true; + } + + // Add rewrites as unit equiv if it's enabled + if(rewritesAsUnitEquiv!=null && rewritesAsUnitEquiv.equalsIgnoreCase("true")) { + RewriterUtils.log(logger, query, "RewritesAsUnitEquiv is enabled"); + //query = RewriterFeatures.addRewritesAsEquiv(query, dictKey, rewrites, true, maxNumRewrites); + query = RewriterFeatures.addRewritesAsEquiv(query, dictKey, rewrites, true, maxNumRewrites); + RewriterUtils.log(logger, query, + "Query after RewritesAsUnitEquiv: " + query.toDetailString()); + rewritten = true; + + // Add rewrites as equiv if it's enabled + } else if(rewritesAsEquiv!=null && rewritesAsEquiv.equalsIgnoreCase("true")) { + RewriterUtils.log(logger, query, "RewritesAsEquiv is enabled"); + //query = RewriterFeatures.addRewritesAsEquiv(query, dictKey, rewrites, false, maxNumRewrites); + query = RewriterFeatures.addRewritesAsEquiv(query, dictKey, rewrites, false, maxNumRewrites); + RewriterUtils.log(logger, query, + "Query after RewritesAsEquiv: " + query.toDetailString()); + rewritten = true; + } + + RewriterUtils.log(logger, query, "NameRewriter final query: " + query.toDetailString()); + + result.put(RewriterConstants.REWRITTEN, rewritten); + + return result; + } + + /** + * Get the flag which specifies whether this rewriter. + * should be skipped if the query has been rewritten + * + * @return true if rewriter should be skipped, false + * otherwise + */ + public boolean getSkipRewriterIfRewritten() { + return SKIP_REWRITER_IF_REWRITTEN; + } + + /** + * Get the name of the rewriter + * + * @return Name of the rewriter + */ + public String getRewriterName() { + return REWRITER_NAME; + } + + /** + * Get default FSA dictionary names + * + * @return Pair of FSA dictionary name and filename + */ + public HashMap<String, String> getDefaultFSAs() { + HashMap<String, String> defaultDicts = new HashMap<>(); + defaultDicts.put(NAME_ENTITY_EXPAND_DICT, NAME_ENTITY_EXPAND_DICT_FILENAME); + return defaultDicts; + } +} diff --git a/container-search/src/main/java/com/yahoo/search/query/rewrite/rewriters/package-info.java b/container-search/src/main/java/com/yahoo/search/query/rewrite/rewriters/package-info.java new file mode 100644 index 00000000000..bfbb73f661e --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/rewrite/rewriters/package-info.java @@ -0,0 +1,7 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +@ExportPackage +@PublicApi +package com.yahoo.search.query.rewrite.rewriters; + +import com.yahoo.api.annotations.PublicApi; +import com.yahoo.osgi.annotation.ExportPackage; diff --git a/container-search/src/main/java/com/yahoo/search/query/textserialize/TextSerialize.java b/container-search/src/main/java/com/yahoo/search/query/textserialize/TextSerialize.java new file mode 100644 index 00000000000..bac9f2af237 --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/textserialize/TextSerialize.java @@ -0,0 +1,41 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.query.textserialize; + +import com.yahoo.prelude.query.Item; +import com.yahoo.search.query.textserialize.item.ItemContext; +import com.yahoo.search.query.textserialize.item.ItemFormHandler; +import com.yahoo.search.query.textserialize.parser.ParseException; +import com.yahoo.search.query.textserialize.parser.Parser; +import com.yahoo.search.query.textserialize.parser.TokenMgrError; +import com.yahoo.search.query.textserialize.serializer.QueryTreeSerializer; + +import java.io.StringReader; + +/** + * @author tonytv + * Facade + * Allows serializing/deserializing a query to the programmatic format. + */ +public class TextSerialize { + public static Item parse(String serializedQuery) { + try { + ItemContext context = new ItemContext(); + Object result = new Parser(new StringReader(serializedQuery.replace("'", "\"")), new ItemFormHandler(), context).start(); + context.connectItems(); + + if (!(result instanceof Item)) { + throw new RuntimeException("The serialized query '" + serializedQuery + "' did not evaluate to an Item" + + "(type = " + result.getClass() + ")"); + } + return (Item) result; + } catch (ParseException e) { + throw new RuntimeException(e); + } catch (TokenMgrError e) { + throw new RuntimeException(e); + } + } + + public static String serialize(Item item) { + return new QueryTreeSerializer().serialize(item); + } +} diff --git a/container-search/src/main/java/com/yahoo/search/query/textserialize/item/AndNotRestConverter.java b/container-search/src/main/java/com/yahoo/search/query/textserialize/item/AndNotRestConverter.java new file mode 100644 index 00000000000..c4e54ca748d --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/textserialize/item/AndNotRestConverter.java @@ -0,0 +1,54 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.query.textserialize.item; + +import com.yahoo.prelude.query.Item; +import com.yahoo.prelude.query.NotItem; + +import java.util.List; + +import static com.yahoo.search.query.textserialize.item.ListUtil.butFirst; +import static com.yahoo.search.query.textserialize.item.ListUtil.first; + +/** + * @author tonytv + */ +public class AndNotRestConverter extends CompositeConverter<NotItem> { + static final String andNotRest = "AND-NOT-REST"; + + public AndNotRestConverter() { + super(NotItem.class); + } + + @Override + protected void addChildren(NotItem item, ItemArguments arguments, ItemContext context) { + if (firstIsNull(arguments.children)) { + addNegativeItems(item, arguments.children); + } else { + addItems(item, arguments.children); + } + } + + private void addNegativeItems(NotItem notItem, List<Object> children) { + for (Object child: butFirst(children)) { + TypeCheck.ensureInstanceOf(child, Item.class); + notItem.addNegativeItem((Item) child); + } + } + + private void addItems(NotItem notItem, List<Object> children) { + for (Object child : children) { + TypeCheck.ensureInstanceOf(child, Item.class); + notItem.addItem((Item) child); + } + } + + + private boolean firstIsNull(List<Object> children) { + return !children.isEmpty() && first(children) == null; + } + + @Override + protected String getFormName(Item item) { + return andNotRest; + } +} diff --git a/container-search/src/main/java/com/yahoo/search/query/textserialize/item/CompositeConverter.java b/container-search/src/main/java/com/yahoo/search/query/textserialize/item/CompositeConverter.java new file mode 100644 index 00000000000..7f7c5e48d0a --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/textserialize/item/CompositeConverter.java @@ -0,0 +1,66 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.query.textserialize.item; + +import com.yahoo.prelude.query.CompositeItem; +import com.yahoo.prelude.query.Item; +import com.yahoo.search.query.textserialize.serializer.DispatchForm; +import com.yahoo.search.query.textserialize.serializer.ItemIdMapper; + +import java.util.ListIterator; + +/** + * @author tonytv + */ +public class CompositeConverter<T extends CompositeItem> implements ItemFormConverter { + private final Class<T> itemClass; + + public CompositeConverter(Class<T> itemClass) { + this.itemClass = itemClass; + } + + @Override + public Object formToItem(String name, ItemArguments arguments, ItemContext itemContext) { + T item = newInstance(); + addChildren(item, arguments, itemContext); + return item; + } + + protected void addChildren(T item, ItemArguments arguments, ItemContext itemContext) { + for (Object child : arguments.children) { + item.addItem(asItem(child)); + } + ItemInitializer.initialize(item, arguments, itemContext); + } + + private static Item asItem(Object child) { + if (!(child instanceof Item) && child != null) { + throw new RuntimeException("Expected query item, but got '" + child.toString() + + "' [" + child.getClass().getName() + "]"); + } + return (Item) child; + } + + private T newInstance() { + try { + return itemClass.newInstance(); + } catch (InstantiationException | IllegalAccessException e) { + throw new RuntimeException(e); + } + } + + @Override + public DispatchForm itemToForm(Item item, ItemIdMapper itemIdMapper) { + CompositeItem compositeItem = (CompositeItem) item; + + DispatchForm form = new DispatchForm(getFormName(item)); + for (ListIterator<Item> i = compositeItem.getItemIterator(); i.hasNext() ;) { + form.addChild(i.next()); + } + ItemInitializer.initializeForm(form, item, itemIdMapper); + return form; + } + + protected String getFormName(Item item) { + return item.getItemType().name(); + } +} diff --git a/container-search/src/main/java/com/yahoo/search/query/textserialize/item/ExactStringConverter.java b/container-search/src/main/java/com/yahoo/search/query/textserialize/item/ExactStringConverter.java new file mode 100644 index 00000000000..4b68ecfe5a9 --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/textserialize/item/ExactStringConverter.java @@ -0,0 +1,15 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.query.textserialize.item; + +import com.yahoo.prelude.query.ExactstringItem; + +/** + * @author balder + */ +// TODO: balder to fix javadoc +public class ExactStringConverter extends WordConverter { + @Override + ExactstringItem newTermItem(String word) { + return new ExactstringItem(word); + } +} diff --git a/container-search/src/main/java/com/yahoo/search/query/textserialize/item/IntConverter.java b/container-search/src/main/java/com/yahoo/search/query/textserialize/item/IntConverter.java new file mode 100644 index 00000000000..43b96d17773 --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/textserialize/item/IntConverter.java @@ -0,0 +1,20 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.query.textserialize.item; + +import com.yahoo.prelude.query.IntItem; +import com.yahoo.prelude.query.TermItem; + +/** + * @author tonytv + */ +public class IntConverter extends TermConverter { + @Override + IntItem newTermItem(String word) { + return new IntItem(word); + } + + @Override + protected String getValue(TermItem item) { + return ((IntItem)item).getNumber(); + } +} diff --git a/container-search/src/main/java/com/yahoo/search/query/textserialize/item/ItemArguments.java b/container-search/src/main/java/com/yahoo/search/query/textserialize/item/ItemArguments.java new file mode 100644 index 00000000000..50cc9c42773 --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/textserialize/item/ItemArguments.java @@ -0,0 +1,26 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.query.textserialize.item; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static com.yahoo.search.query.textserialize.item.ListUtil.firstInstanceOf; + +/** + * @author tonytv + */ +public class ItemArguments { + public final Map<?, ?> properties; + public final List<Object> children; + + public ItemArguments(List<Object> arguments) { + if (firstInstanceOf(arguments, Map.class)) { + properties = (Map<?, ?>) ListUtil.first(arguments); + children = ListUtil.rest(arguments); + } else { + properties = Collections.emptyMap(); + children = arguments; + } + } +} diff --git a/container-search/src/main/java/com/yahoo/search/query/textserialize/item/ItemContext.java b/container-search/src/main/java/com/yahoo/search/query/textserialize/item/ItemContext.java new file mode 100644 index 00000000000..fd21b4e02e1 --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/textserialize/item/ItemContext.java @@ -0,0 +1,49 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.query.textserialize.item; + +import com.yahoo.prelude.query.Item; +import com.yahoo.prelude.query.TaggableItem; + +import java.util.HashMap; +import java.util.IdentityHashMap; +import java.util.Map; + +/** + * @author tonytv + */ +public class ItemContext { + private class Connectivity { + final String id; + final double strength; + + public Connectivity(String id, double strength) { + this.id = id; + this.strength = strength; + } + } + + private final Map<String, Item> itemById = new HashMap<>(); + private final Map<TaggableItem, Connectivity> connectivityByItem = new IdentityHashMap<>(); + + + public void setItemId(String id, Item item) { + itemById.put(id, item); + } + + public void setConnectivity(TaggableItem item, String id, Double strength) { + connectivityByItem.put(item, new Connectivity(id, strength)); + } + + public void connectItems() { + for (Map.Entry<TaggableItem, Connectivity> entry : connectivityByItem.entrySet()) { + entry.getKey().setConnectivity(getItem(entry.getValue().id), entry.getValue().strength); + } + } + + private Item getItem(String id) { + Item item = itemById.get(id); + if (item == null) + throw new IllegalArgumentException("No item with id '" + id + "'."); + return item; + } +} diff --git a/container-search/src/main/java/com/yahoo/search/query/textserialize/item/ItemExecutorRegistry.java b/container-search/src/main/java/com/yahoo/search/query/textserialize/item/ItemExecutorRegistry.java new file mode 100644 index 00000000000..20ef9f4e5cc --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/textserialize/item/ItemExecutorRegistry.java @@ -0,0 +1,71 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.query.textserialize.item; + +import com.yahoo.prelude.query.AndItem; +import com.yahoo.prelude.query.CompositeItem; +import com.yahoo.prelude.query.EquivItem; +import com.yahoo.prelude.query.Item; +import com.yahoo.prelude.query.NearItem; +import com.yahoo.prelude.query.ONearItem; +import com.yahoo.prelude.query.OrItem; +import com.yahoo.prelude.query.PhraseItem; +import com.yahoo.prelude.query.RankItem; + +import java.util.HashMap; +import java.util.Map; + +/** + * @author tonytv + */ +public class ItemExecutorRegistry { + + private static final Map<String, ItemFormConverter> executorsByName = new HashMap<>(); + static { + register(Item.ItemType.AND, createCompositeConverter(AndItem.class)); + register(Item.ItemType.OR, createCompositeConverter(OrItem.class)); + register(Item.ItemType.RANK, createCompositeConverter(RankItem.class)); + register(Item.ItemType.PHRASE, createCompositeConverter(PhraseItem.class)); + register(Item.ItemType.EQUIV, createCompositeConverter(EquivItem.class)); + + register(AndNotRestConverter.andNotRest, new AndNotRestConverter()); + + register(Item.ItemType.NEAR, new NearConverter(NearItem.class)); + register(Item.ItemType.ONEAR, new NearConverter(ONearItem.class)); + + register(Item.ItemType.WORD, new WordConverter()); + register(Item.ItemType.INT, new IntConverter()); + register(Item.ItemType.PREFIX, new PrefixConverter()); + register(Item.ItemType.SUBSTRING, new SubStringConverter()); + register(Item.ItemType.EXACT, new ExactStringConverter()); + register(Item.ItemType.SUFFIX, new SuffixConverter()); + } + + private static <T extends CompositeItem> ItemFormConverter createCompositeConverter(Class<T> itemClass) { + return new CompositeConverter<>(itemClass); + } + + private static void register(Item.ItemType type, ItemFormConverter executor) { + register(type.toString(), executor); + } + + private static void register(String type, ItemFormConverter executor) { + executorsByName.put(type, executor); + } + + public static ItemFormConverter getByName(String name) { + ItemFormConverter executor = executorsByName.get(name); + ensureNotNull(executor, name); + return executor; + } + + private static void ensureNotNull(ItemFormConverter executor, String name) { + if (executor == null) { + throw new RuntimeException("No item type named '" + name + "'."); + } + } + + public static ItemFormConverter getByType(Item.ItemType itemType) { + String name = (itemType == Item.ItemType.NOT) ? AndNotRestConverter.andNotRest : itemType.name(); + return getByName(name); + } +} diff --git a/container-search/src/main/java/com/yahoo/search/query/textserialize/item/ItemFormConverter.java b/container-search/src/main/java/com/yahoo/search/query/textserialize/item/ItemFormConverter.java new file mode 100644 index 00000000000..256ad569686 --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/textserialize/item/ItemFormConverter.java @@ -0,0 +1,14 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.query.textserialize.item; + +import com.yahoo.prelude.query.Item; +import com.yahoo.search.query.textserialize.serializer.DispatchForm; +import com.yahoo.search.query.textserialize.serializer.ItemIdMapper; + +/** + * @author tonytv + */ +public interface ItemFormConverter { + Object formToItem(String name, ItemArguments arguments, ItemContext context); + DispatchForm itemToForm(Item item, ItemIdMapper itemIdMapper); +} diff --git a/container-search/src/main/java/com/yahoo/search/query/textserialize/item/ItemFormHandler.java b/container-search/src/main/java/com/yahoo/search/query/textserialize/item/ItemFormHandler.java new file mode 100644 index 00000000000..81b13a107c8 --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/textserialize/item/ItemFormHandler.java @@ -0,0 +1,17 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.query.textserialize.item; + +import com.yahoo.search.query.textserialize.parser.DispatchFormHandler; + +import java.util.List; + +/** + * @author tonytv + */ +public class ItemFormHandler implements DispatchFormHandler{ + @Override + public Object dispatch(String name, List<Object> arguments, Object dispatchContext) { + ItemFormConverter executor = ItemExecutorRegistry.getByName(name); + return executor.formToItem(name, new ItemArguments(arguments), (ItemContext)dispatchContext); + } +} diff --git a/container-search/src/main/java/com/yahoo/search/query/textserialize/item/ItemInitializer.java b/container-search/src/main/java/com/yahoo/search/query/textserialize/item/ItemInitializer.java new file mode 100644 index 00000000000..ae54165abef --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/textserialize/item/ItemInitializer.java @@ -0,0 +1,137 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.query.textserialize.item; + +import com.yahoo.prelude.query.IndexedItem; +import com.yahoo.prelude.query.Item; +import com.yahoo.prelude.query.TaggableItem; +import com.yahoo.search.query.textserialize.serializer.DispatchForm; +import com.yahoo.search.query.textserialize.serializer.ItemIdMapper; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +/** + * @author tonytv + */ +public class ItemInitializer { + private static final String indexProperty = "index"; + private static final String idProperty = "id"; + private static final String significanceProperty = "significance"; + private static final String uniqueIdProperty = "uniqueId"; + private static final String weightProperty = "weight"; + + public static void initialize(Item item, ItemArguments arguments, ItemContext itemContext) { + storeIdInContext(item, arguments.properties, itemContext); + + Object weight = arguments.properties.get(weightProperty); + if (weight != null) { + TypeCheck.ensureInstanceOf(weight, Number.class); + item.setWeight(((Number)weight).intValue()); + } + + if (item instanceof TaggableItem) { + initializeTaggableItem((TaggableItem)item, arguments, itemContext); + } + + if (item instanceof IndexedItem) { + initializeIndexedItem((IndexedItem)item, arguments, itemContext); + } + } + + private static void storeIdInContext(Item item, Map<?, ?> properties, ItemContext itemContext) { + Object id = properties.get("id"); + if (id != null) { + TypeCheck.ensureInstanceOf(id, String.class); + itemContext.setItemId((String) id, item); + } + } + + private static void initializeTaggableItem(TaggableItem item, ItemArguments arguments, ItemContext itemContext) { + Object connectivity = arguments.properties.get("connectivity"); + if (connectivity != null) { + storeConnectivityInContext(item, connectivity, itemContext); + } + + Object significance = arguments.properties.get(significanceProperty); + if (significance != null) { + TypeCheck.ensureInstanceOf(significance, Number.class); + item.setSignificance(((Number)significance).doubleValue()); + } + + Object uniqueId = arguments.properties.get(uniqueIdProperty); + if (uniqueId != null) { + TypeCheck.ensureInstanceOf(uniqueId, Number.class); + item.setUniqueID(((Number)uniqueId).intValue()); + } + } + + private static void initializeIndexedItem(IndexedItem indexedItem, ItemArguments arguments, ItemContext itemContext) { + Object index = arguments.properties.get(indexProperty); + if (index != null) { + TypeCheck.ensureInstanceOf(index, String.class); + indexedItem.setIndexName((String) index); + } + } + + private static void storeConnectivityInContext(TaggableItem item, Object connectivity, ItemContext itemContext) { + TypeCheck.ensureInstanceOf(connectivity, List.class); + List<?> connectivityList = (List<?>) connectivity; + if (connectivityList.size() != 2) { + throw new IllegalArgumentException("Expected two elements for connectivity, got " + connectivityList.size()); + } + + Object id = connectivityList.get(0); + Object strength = connectivityList.get(1); + + TypeCheck.ensureInstanceOf(id, String.class); + TypeCheck.ensureInstanceOf(strength, Number.class); + + itemContext.setConnectivity(item, (String)id, ((Number)strength).doubleValue()); + } + + public static void initializeForm(DispatchForm form, Item item, ItemIdMapper itemIdMapper) { + if (item.getWeight() != Item.DEFAULT_WEIGHT) { + form.setProperty(weightProperty, item.getWeight()); + } + + if (item instanceof IndexedItem) { + initializeIndexedForm(form, (IndexedItem) item); + } + if (item instanceof TaggableItem) { + initializeTaggableForm(form, (TaggableItem) item, itemIdMapper); + } + initializeFormWithIdIfConnected(form, item, itemIdMapper); + } + + private static void initializeFormWithIdIfConnected(DispatchForm form, Item item, ItemIdMapper itemIdMapper) { + if (item.hasConnectivityBackLink()) { + form.setProperty(idProperty, itemIdMapper.getId(item)); + } + } + + @SuppressWarnings("unchecked") + private static void initializeTaggableForm(DispatchForm form, TaggableItem taggableItem, ItemIdMapper itemIdMapper) { + Item connectedItem = taggableItem.getConnectedItem(); + if (connectedItem != null) { + form.setProperty("connectivity", + Arrays.asList(itemIdMapper.getId(connectedItem), taggableItem.getConnectivity())); + } + + if (taggableItem.hasExplicitSignificance()) { + form.setProperty(significanceProperty, taggableItem.getSignificance()); + } + + if (taggableItem.hasUniqueID()) { + form.setProperty(uniqueIdProperty, taggableItem.getUniqueID()); + } + } + + private static void initializeIndexedForm(DispatchForm form, IndexedItem item) { + String index = item.getIndexName(); + if (!index.isEmpty()) { + form.setProperty(indexProperty, index); + } + } + +} diff --git a/container-search/src/main/java/com/yahoo/search/query/textserialize/item/ListUtil.java b/container-search/src/main/java/com/yahoo/search/query/textserialize/item/ListUtil.java new file mode 100644 index 00000000000..9349b01a3bc --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/textserialize/item/ListUtil.java @@ -0,0 +1,33 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.query.textserialize.item; + +import java.util.*; + +/** + * @author tonytv + */ +public class ListUtil { + public static <T> List<T> rest(List<T> list) { + return list.subList(1, list.size()); + } + + public static <T> T first(Collection<T> collection) { + return collection.iterator().next(); + } + + public static boolean firstInstanceOf(Collection<?> collection, @SuppressWarnings("rawtypes") Class c) { + return !collection.isEmpty() && c.isInstance(first(collection)); + } + + public static <T> List<T> butFirst(List<T> list) { + return list.subList(1, list.size()); + } + + public static <T> Iterable<T> butFirst(final Collection<T> collection) { + return () -> { + Iterator<T> i = collection.iterator(); + i.next(); + return i; + }; + } +} diff --git a/container-search/src/main/java/com/yahoo/search/query/textserialize/item/NearConverter.java b/container-search/src/main/java/com/yahoo/search/query/textserialize/item/NearConverter.java new file mode 100644 index 00000000000..3be8d3d1c65 --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/textserialize/item/NearConverter.java @@ -0,0 +1,44 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.query.textserialize.item; + +import com.yahoo.prelude.query.Item; +import com.yahoo.prelude.query.NearItem; +import com.yahoo.search.query.textserialize.serializer.DispatchForm; +import com.yahoo.search.query.textserialize.serializer.ItemIdMapper; + +/** + * @author tonytv + */ +@SuppressWarnings("rawtypes") +public class NearConverter extends CompositeConverter { + final private String distanceProperty = "distance";; + + @SuppressWarnings("unchecked") + public NearConverter(Class<? extends NearItem> nearItemClass) { + super(nearItemClass); + } + + @Override + public Object formToItem(String name, ItemArguments arguments, ItemContext itemContext) { + NearItem nearItem = (NearItem) super.formToItem(name, arguments, itemContext); + setDistance(nearItem, arguments); + return nearItem; + } + + private void setDistance(NearItem nearItem, ItemArguments arguments) { + Object distance = arguments.properties.get(distanceProperty); + if (distance != null) { + TypeCheck.ensureInteger(distance); + nearItem.setDistance(((Number)distance).intValue()); + } + } + + @Override + public DispatchForm itemToForm(Item item, ItemIdMapper itemIdMapper) { + DispatchForm dispatchForm = super.itemToForm(item, itemIdMapper); + + NearItem nearItem = (NearItem)item; + dispatchForm.setProperty(distanceProperty, nearItem.getDistance()); + return dispatchForm; + } +} diff --git a/container-search/src/main/java/com/yahoo/search/query/textserialize/item/PrefixConverter.java b/container-search/src/main/java/com/yahoo/search/query/textserialize/item/PrefixConverter.java new file mode 100644 index 00000000000..cb3a6c1943c --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/textserialize/item/PrefixConverter.java @@ -0,0 +1,14 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.query.textserialize.item; + +import com.yahoo.prelude.query.PrefixItem; + +/** + * @author tonytv + */ +public class PrefixConverter extends WordConverter { + @Override + PrefixItem newTermItem(String word) { + return new PrefixItem(word); + } +} diff --git a/container-search/src/main/java/com/yahoo/search/query/textserialize/item/SubStringConverter.java b/container-search/src/main/java/com/yahoo/search/query/textserialize/item/SubStringConverter.java new file mode 100644 index 00000000000..e61a189684f --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/textserialize/item/SubStringConverter.java @@ -0,0 +1,14 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.query.textserialize.item; + +import com.yahoo.prelude.query.SubstringItem; + +/** + * @author tonytv + */ +public class SubStringConverter extends WordConverter { + @Override + SubstringItem newTermItem(String word) { + return new SubstringItem(word); + } +} diff --git a/container-search/src/main/java/com/yahoo/search/query/textserialize/item/SuffixConverter.java b/container-search/src/main/java/com/yahoo/search/query/textserialize/item/SuffixConverter.java new file mode 100644 index 00000000000..4390e3464d2 --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/textserialize/item/SuffixConverter.java @@ -0,0 +1,14 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.query.textserialize.item; + +import com.yahoo.prelude.query.SuffixItem; + +/** + * @author tonytv + */ +public class SuffixConverter extends WordConverter { + @Override + SuffixItem newTermItem(String word) { + return new SuffixItem(word); + } +} diff --git a/container-search/src/main/java/com/yahoo/search/query/textserialize/item/TermConverter.java b/container-search/src/main/java/com/yahoo/search/query/textserialize/item/TermConverter.java new file mode 100644 index 00000000000..8bc6cba7f67 --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/textserialize/item/TermConverter.java @@ -0,0 +1,53 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.query.textserialize.item; + +import com.yahoo.prelude.query.Item; +import com.yahoo.prelude.query.TermItem; +import com.yahoo.search.query.textserialize.serializer.DispatchForm; +import com.yahoo.search.query.textserialize.serializer.ItemIdMapper; + +/** + * @author tonytv + */ +public abstract class TermConverter implements ItemFormConverter { + @Override + public Object formToItem(String name, ItemArguments arguments, ItemContext context) { + ensureOnlyOneChild(arguments); + String word = getWord(arguments); + + TermItem item = newTermItem(word); + ItemInitializer.initialize(item, arguments, context); + return item; + } + + abstract TermItem newTermItem(String word); + + + private void ensureOnlyOneChild(ItemArguments arguments) { + if (arguments.children.size() != 1) { + throw new IllegalArgumentException("Expected exactly one argument, got '" + + arguments.children.toString() + "'"); + } + } + + private String getWord(ItemArguments arguments) { + Object word = arguments.children.get(0); + + if (!(word instanceof String)) { + throw new RuntimeException("Expected string, got '" + word + "' [" + word.getClass().getName() + "]."); + } + return (String)word; + } + + @Override + public DispatchForm itemToForm(Item item, ItemIdMapper itemIdMapper) { + TermItem termItem = (TermItem)item; + + DispatchForm form = new DispatchForm(termItem.getItemType().name()); + ItemInitializer.initializeForm(form, item, itemIdMapper); + form.addChild(getValue(termItem)); + return form; + } + + protected abstract String getValue(TermItem item); +} diff --git a/container-search/src/main/java/com/yahoo/search/query/textserialize/item/TypeCheck.java b/container-search/src/main/java/com/yahoo/search/query/textserialize/item/TypeCheck.java new file mode 100644 index 00000000000..a6e38d288a4 --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/textserialize/item/TypeCheck.java @@ -0,0 +1,27 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.query.textserialize.item; + +import com.yahoo.protect.Validator; + +/** + * @author tonytv + */ +public class TypeCheck { + public static void ensureInstanceOf(Object object, Class<?> c) { + Validator.ensureInstanceOf(expectationString(c.getName(), object.getClass().getSimpleName()), + object, c); + } + + public static void ensureInteger(Object value) { + ensureInstanceOf(value, Number.class); + Number number = (Number)value; + + int intValue = number.intValue(); + if (intValue != number.doubleValue()) + throw new IllegalArgumentException("Invalid integer '" + number + "'"); + } + + private static String expectationString(String expected, String got) { + return "Expected " + expected + ", but got " + got; + } +} diff --git a/container-search/src/main/java/com/yahoo/search/query/textserialize/item/WordConverter.java b/container-search/src/main/java/com/yahoo/search/query/textserialize/item/WordConverter.java new file mode 100644 index 00000000000..dce33e392ae --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/textserialize/item/WordConverter.java @@ -0,0 +1,20 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.query.textserialize.item; + +import com.yahoo.prelude.query.TermItem; +import com.yahoo.prelude.query.WordItem; + +/** + * @author tonytv + */ +public class WordConverter extends TermConverter { + @Override + WordItem newTermItem(String word) { + return new WordItem(word); + } + + @Override + protected String getValue(TermItem item) { + return ((WordItem)item).getWord(); + } +} diff --git a/container-search/src/main/java/com/yahoo/search/query/textserialize/package-info.java b/container-search/src/main/java/com/yahoo/search/query/textserialize/package-info.java new file mode 100644 index 00000000000..1e1d3052731 --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/textserialize/package-info.java @@ -0,0 +1,7 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +@ExportPackage +@PublicApi +package com.yahoo.search.query.textserialize; + +import com.yahoo.api.annotations.PublicApi; +import com.yahoo.osgi.annotation.ExportPackage; diff --git a/container-search/src/main/java/com/yahoo/search/query/textserialize/parser/.gitignore b/container-search/src/main/java/com/yahoo/search/query/textserialize/parser/.gitignore new file mode 100644 index 00000000000..add88bd6807 --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/textserialize/parser/.gitignore @@ -0,0 +1,7 @@ +/TokenMgrError.java +/Token.java +/SimpleCharStream.java +/ParserTokenManager.java +/ParserConstants.java +/ParseException.java +/Parser.java diff --git a/container-search/src/main/java/com/yahoo/search/query/textserialize/parser/DispatchFormHandler.java b/container-search/src/main/java/com/yahoo/search/query/textserialize/parser/DispatchFormHandler.java new file mode 100644 index 00000000000..33c8e36bd57 --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/textserialize/parser/DispatchFormHandler.java @@ -0,0 +1,11 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.query.textserialize.parser; + +import java.util.List; + +/** + * @author tonytv + */ +public interface DispatchFormHandler { + Object dispatch(String name, List<Object> arguments, Object dispatchContext); +} diff --git a/container-search/src/main/java/com/yahoo/search/query/textserialize/serializer/DispatchForm.java b/container-search/src/main/java/com/yahoo/search/query/textserialize/serializer/DispatchForm.java new file mode 100644 index 00000000000..091efa0a01b --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/textserialize/serializer/DispatchForm.java @@ -0,0 +1,56 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.query.textserialize.serializer; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * @author tonytv + */ +public class DispatchForm { + private final String name; + public final Map<Object, Object> properties = new LinkedHashMap<>(); + public final List<Object> children = new ArrayList<>(); + + public DispatchForm(String name) { + this.name = name; + } + + public void addChild(Object child) { + children.add(child); + } + + /** + * Only public for the purpose of testing. + */ + public String serialize(ItemIdMapper itemIdMapper) { + StringBuilder builder = new StringBuilder(); + builder.append('(').append(name); + + serializeProperties(builder, itemIdMapper); + serializeChildren(builder, itemIdMapper); + + builder.append(')'); + return builder.toString(); + } + + private void serializeProperties(StringBuilder builder, ItemIdMapper itemIdMapper) { + if (properties.isEmpty()) + return; + + builder.append(' ').append(Serializer.serializeMap(properties, itemIdMapper)); + } + + + private void serializeChildren(StringBuilder builder, ItemIdMapper itemIdMapper) { + for (Object child : children) { + builder.append(' ').append(Serializer.serialize(child, itemIdMapper)); + } + } + + public void setProperty(Object key, Object value) { + properties.put(key, value); + } +} diff --git a/container-search/src/main/java/com/yahoo/search/query/textserialize/serializer/ItemIdMapper.java b/container-search/src/main/java/com/yahoo/search/query/textserialize/serializer/ItemIdMapper.java new file mode 100644 index 00000000000..c32a7f52c0a --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/textserialize/serializer/ItemIdMapper.java @@ -0,0 +1,33 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.query.textserialize.serializer; + +import com.yahoo.prelude.query.Item; + +import java.util.IdentityHashMap; +import java.util.Map; + +/** + * @author tonytv + */ +public class ItemIdMapper { + private final Map<Item, String> idByItem = new IdentityHashMap<>(); + private int idCounter = 0; + + public String getId(Item item) { + String id = idByItem.get(item); + if (id != null) { + return id; + } else { + idByItem.put(item, generateId(item)); + return getId(item); + } + } + + private String generateId(Item item) { + return item.getName() + "_" + nextCount(); + } + + private int nextCount() { + return idCounter++; + } +} diff --git a/container-search/src/main/java/com/yahoo/search/query/textserialize/serializer/QueryTreeSerializer.java b/container-search/src/main/java/com/yahoo/search/query/textserialize/serializer/QueryTreeSerializer.java new file mode 100644 index 00000000000..e3090930369 --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/textserialize/serializer/QueryTreeSerializer.java @@ -0,0 +1,16 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.query.textserialize.serializer; + +import com.yahoo.prelude.query.Item; +import com.yahoo.search.query.textserialize.item.ItemExecutorRegistry; + + +/** + * @author tonytv + */ +public class QueryTreeSerializer { + public String serialize(Item root) { + ItemIdMapper itemIdMapper = new ItemIdMapper(); + return ItemExecutorRegistry.getByType(root.getItemType()).itemToForm(root, itemIdMapper).serialize(itemIdMapper); + } +} diff --git a/container-search/src/main/java/com/yahoo/search/query/textserialize/serializer/Serializer.java b/container-search/src/main/java/com/yahoo/search/query/textserialize/serializer/Serializer.java new file mode 100644 index 00000000000..e8352254551 --- /dev/null +++ b/container-search/src/main/java/com/yahoo/search/query/textserialize/serializer/Serializer.java @@ -0,0 +1,79 @@ +// Copyright 2016 Yahoo Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.search.query.textserialize.serializer; + +import com.yahoo.prelude.query.Item; +import com.yahoo.search.query.textserialize.item.ItemExecutorRegistry; + +import java.util.List; +import java.util.Map; + +import static com.yahoo.search.query.textserialize.item.ListUtil.butFirst; +import static com.yahoo.search.query.textserialize.item.ListUtil.first; + +/** + * @author tonytv + */ +class Serializer { + static String serialize(Object child, ItemIdMapper itemIdMapper) { + if (child instanceof DispatchForm) { + return ((DispatchForm) child).serialize(itemIdMapper); + } else if (child instanceof Item) { + return serializeItem((Item) child, itemIdMapper); + } else if (child instanceof String) { + return serializeString((String) child); + } else if (child instanceof Number) { + return child.toString(); + } else if (child instanceof Map) { + return serializeMap((Map<?, ?>)child, itemIdMapper); + } else if (child instanceof List) { + return serializeList((List<?>)child, itemIdMapper); + } else { + throw new IllegalArgumentException("Can't serialize type " + child.getClass()); + } + } + + private static String serializeString(String string) { + return '"' + string.replace("\\", "\\\\").replace("\"", "\\\"") + '"'; + } + + static String serializeList(List<?> list, ItemIdMapper itemIdMapper) { + StringBuilder builder = new StringBuilder(); + builder.append('['); + + if (!list.isEmpty()) { + builder.append(serialize(first(list), itemIdMapper)); + + for (Object element : butFirst(list)) { + builder.append(", ").append(serialize(element, itemIdMapper)); + } + } + + builder.append(']'); + return builder.toString(); + } + + static String serializeMap(Map<?, ?> map, ItemIdMapper itemIdMapper) { + StringBuilder builder = new StringBuilder(); + builder.append("{"); + + if (!map.isEmpty()) { + serializeEntry(builder, first(map.entrySet()), itemIdMapper); + for (Map.Entry<?, ?> entry : butFirst(map.entrySet())) { + builder.append(", "); + serializeEntry(builder, entry, itemIdMapper); + } + } + + builder.append('}'); + return builder.toString(); + } + + static void serializeEntry(StringBuilder builder, Map.Entry<?, ?> entry, ItemIdMapper itemIdMapper) { + builder.append(serialize(entry.getKey(), itemIdMapper)).append(' '). + append(serialize(entry.getValue(), itemIdMapper)); + } + + static String serializeItem(Item item, ItemIdMapper itemIdMapper) { + return ItemExecutorRegistry.getByType(item.getItemType()).itemToForm(item, itemIdMapper).serialize(itemIdMapper); + } +} |