blob: 104a5719ed58a18d4254c7e0ecf3bf4279c44d74 [file] [log] [blame]
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.solr.search.facet;
import java.io.IOException;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import com.google.common.collect.Sets;
import org.apache.lucene.search.Query;
import org.apache.solr.common.SolrException;
import org.apache.solr.common.params.SolrParams;
import org.apache.solr.request.SolrQueryRequest;
import org.apache.solr.search.DocSet;
import org.apache.solr.search.JoinQParserPlugin;
import org.apache.solr.search.QueryContext;
import org.apache.solr.search.SolrConstantScoreQuery;
import org.apache.solr.search.SyntaxError;
import org.apache.solr.search.join.GraphQuery;
import org.apache.solr.search.join.GraphQueryParser;
import org.apache.solr.util.RTimer;
import static org.apache.solr.search.facet.FacetRequest.RefineMethod.NONE;
/**
* A request to do facets/stats that might itself be composed of sub-FacetRequests.
* This is a cornerstone of the facet module.
*
* @see #parse(SolrQueryRequest, Map)
*/
public abstract class FacetRequest {
/**
* Simple structure for encapsulating a sort variable and a direction
*/
public static final class FacetSort {
final String sortVariable;
final SortDirection sortDirection;
public FacetSort(final String sortVariable, final SortDirection sortDirection) {
assert null != sortVariable;
assert null != sortDirection;
this.sortVariable = sortVariable;
this.sortDirection = sortDirection;
}
public boolean equals(Object other) {
if (other instanceof FacetSort) {
final FacetSort that = (FacetSort) other;
return this.sortVariable.equals(that.sortVariable)
&& this.sortDirection.equals(that.sortDirection);
}
return false;
}
public int hashCode() {
return Objects.hash(sortVariable, sortDirection);
}
public String toString() {
return sortVariable + " " + sortDirection;
}
/**
* Commonly Re-used "count desc" (default)
*/
public static final FacetSort COUNT_DESC = new FacetSort("count", SortDirection.desc);
/**
* Commonly Re-used "index asc" (index order / streaming)
*/
public static final FacetSort INDEX_ASC = new FacetSort("index", SortDirection.asc);
}
public static enum SortDirection {
asc(-1),
desc(1);
private final int multiplier;
private SortDirection(int multiplier) {
this.multiplier = multiplier;
}
public static SortDirection fromObj(Object direction) {
if (direction == null) {
// should we just default either to desc/asc??
throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, "Missing Sort direction");
}
switch (direction.toString()) {
case "asc":
return asc;
case "desc":
return desc;
default:
throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, "Unknown Sort direction '" + direction + "'");
}
}
// asc==-1, desc==1
public int getMultiplier() {
return multiplier;
}
}
public static enum RefineMethod {
NONE,
SIMPLE;
// NONE is distinct from null since we may want to know if refinement was explicitly turned off.
public static FacetRequest.RefineMethod fromObj(Object method) {
if (method == null) return null;
if (method instanceof Boolean) {
return ((Boolean) method) ? SIMPLE : NONE;
}
if ("simple".equals(method)) {
return SIMPLE;
} else if ("none".equals(method)) {
return NONE;
} else {
throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, "Unknown RefineMethod method " + method);
}
}
}
protected Map<String, AggValueSource> facetStats; // per-bucket statistics
protected Map<String, FacetRequest> subFacets; // per-bucket sub-facets
protected boolean processEmpty;
protected Domain domain;
// domain changes
public static class Domain {
/**
* An explicit query domain, <em>ignoring all parent context</em>, expressed in JSON query format.
* Mutually exclusive to {@link #excludeTags}
*/
public List<Object> explicitQueries; // list of symbolic filters (JSON query format)
/**
* Specifies query/filter tags that should be excluded to re-compute the domain from the parent context.
* Mutually exclusive to {@link #explicitQueries}
*/
public List<String> excludeTags;
public JoinField joinField;
public GraphField graphField;
public boolean toParent;
public boolean toChildren;
public String parents; // identifies the parent filter... the full set of parent documents for any block join operation
public List<Object> filters; // list of symbolic filters (JSON query format)
// True if a starting set of documents can be mapped onto a different set of documents not originally in the starting set.
public boolean canTransformDomain() {
return toParent || toChildren
|| (explicitQueries != null) || (excludeTags != null) || (joinField != null);
}
// Can this domain become non-empty if the input domain is empty? This does not check any sub-facets (see canProduceFromEmpty for that)
public boolean canBecomeNonEmpty() {
return (explicitQueries != null) || (excludeTags != null);
}
/**
* Are we doing a query time join across other documents
*/
public static class JoinField {
private static final String FROM_PARAM = "from";
private static final String TO_PARAM = "to";
private static final String METHOD_PARAM = "method";
private static final Set<String> SUPPORTED_JOIN_PROPERTIES = Sets.newHashSet(FROM_PARAM, TO_PARAM, METHOD_PARAM);
public final String from;
public final String to;
public final String method;
private JoinField(String from, String to, String method) {
assert null != from;
assert null != to;
assert null != method;
this.from = from;
this.to = to;
this.method = method;
}
/**
* Given a <code>Domain</code>, and a (JSON) map specifying the configuration for that Domain,
* validates if a '<code>join</code>' is specified, and if so creates a <code>JoinField</code>
* and sets it on the <code>Domain</code>.
* <p>
* (params must not be null)
*/
public static void createJoinField(FacetRequest.Domain domain, Map<String, Object> domainMap) {
assert null != domain;
assert null != domainMap;
final Object queryJoin = domainMap.get("join");
if (null != queryJoin) {
// TODO: maybe allow simple string (instead of map) to mean "self join on this field name" ?
if (!(queryJoin instanceof Map)) {
throw new SolrException(SolrException.ErrorCode.BAD_REQUEST,
"'join' domain change requires a map containing the 'from' and 'to' fields");
}
@SuppressWarnings({"unchecked"})
final Map<String,String> join = (Map<String,String>) queryJoin;
if (! (join.containsKey(FROM_PARAM) && join.containsKey(TO_PARAM) &&
null != join.get(FROM_PARAM) && null != join.get(TO_PARAM)) ) {
throw new SolrException(SolrException.ErrorCode.BAD_REQUEST,
"'join' domain change requires non-null 'from' and 'to' field names");
}
for (String providedKey : join.keySet()) {
if (! SUPPORTED_JOIN_PROPERTIES.contains(providedKey)) {
final String supportedPropsStr = String.join(", ", SUPPORTED_JOIN_PROPERTIES);
final String message = String.format(Locale.ROOT,
"'join' domain change contains unexpected key [%s], only %s supported", providedKey, supportedPropsStr);
throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, message);
}
}
final String method = join.containsKey(METHOD_PARAM) ? join.get(METHOD_PARAM) : "index";
domain.joinField = new JoinField(join.get(FROM_PARAM), join.get(TO_PARAM), method);
}
}
/**
* Creates a Query that can be used to recompute the new "base" for this domain, relative to the
* current base of the FacetContext.
*/
public Query createDomainQuery(FacetContext fcontext) throws IOException {
// NOTE: this code lives here, instead of in FacetProcessor.handleJoin, in order to minimize
// the number of classes that have to know about the number of possible settings on the join
// (ie: if we add a score mode, or some other modifier to how the joins are done)
final SolrConstantScoreQuery fromQuery = new SolrConstantScoreQuery(fcontext.base.getTopFilter());
// this shouldn't matter once we're wrapped in a join query, but just in case it ever does...
fromQuery.setCache(false);
return JoinQParserPlugin.createJoinQuery(fromQuery, this.from, this.to, this.method);
}
}
/**
* Are we doing a query time graph across other documents
*/
public static class GraphField {
public final SolrParams localParams;
private GraphField(SolrParams localParams) {
assert null != localParams;
this.localParams = localParams;
}
/**
* Given a <code>Domain</code>, and a (JSON) map specifying the configuration for that Domain,
* validates if a '<code>graph</code>' is specified, and if so creates a <code>GraphField</code>
* and sets it on the <code>Domain</code>.
* <p>
* (params must not be null)
*/
public static void createGraphField(FacetRequest.Domain domain, Map<String, Object> domainMap) {
assert null != domain;
assert null != domainMap;
final Object queryGraph = domainMap.get("graph");
if (null != queryGraph) {
if (!(queryGraph instanceof Map)) {
throw new SolrException(SolrException.ErrorCode.BAD_REQUEST,
"'graph' domain change requires a map containing the 'from' and 'to' fields");
}
@SuppressWarnings({"unchecked"}) final Map<String, String> graph = (Map<String, String>) queryGraph;
if (!(graph.containsKey("from") && graph.containsKey("to") &&
null != graph.get("from") && null != graph.get("to"))) {
throw new SolrException(SolrException.ErrorCode.BAD_REQUEST,
"'graph' domain change requires non-null 'from' and 'to' field names");
}
domain.graphField = new GraphField(FacetParser.jsonToSolrParams(graph));
}
}
/**
* Creates a Query that can be used to recompute the new "base" for this domain, relative to the
* current base of the FacetContext.
*/
public Query createDomainQuery(FacetContext fcontext) throws IOException {
final SolrConstantScoreQuery fromQuery = new SolrConstantScoreQuery(fcontext.base.getTopFilter());
// this shouldn't matter once we're wrapped in a join query, but just in case it ever does...
fromQuery.setCache(false);
GraphQueryParser graphParser = new GraphQueryParser(null, localParams, null, fcontext.req);
try {
GraphQuery graphQuery = (GraphQuery) graphParser.parse();
graphQuery.setQ(fromQuery);
return graphQuery;
} catch (SyntaxError syntaxError) {
throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, syntaxError);
}
}
}
}
/**
* Factory method to parse a facet request tree. The outer keys are arbitrary labels and their values are
* facet request specifications. Will throw a {@link SolrException} if it fails to parse.
*
* @param req the overall request
* @param params a typed parameter structure (unlike SolrParams which are all string values).
*/
public static FacetRequest parse(SolrQueryRequest req, Map<String, Object> params) {
@SuppressWarnings({"rawtypes"})
FacetParser parser = new FacetParser.FacetTopParser(req);
try {
return parser.parse(params);
} catch (SyntaxError syntaxError) {
throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, syntaxError);
}
}
//TODO it would be nice if there was no distinction. If the top level request had "type" as special then there wouldn't be a need.
/**
* Factory method to parse out a rooted facet request tree that would normally go one level below a label.
* The params must contain a "type".
* This is intended to be useful externally, such as by {@link org.apache.solr.request.SimpleFacets}.
*
* @param req the overall request
* @param params a typed parameter structure (unlike SolrParams which are all string values).
*/
public static FacetRequest parseOneFacetReq(SolrQueryRequest req, Map<String, Object> params) {
@SuppressWarnings("rawtypes")
FacetParser parser = new FacetParser.FacetTopParser(req);
try {
return (FacetRequest) parser.parseFacetOrStat("", params);
} catch (SyntaxError syntaxError) {
throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, syntaxError);
}
}
public FacetRequest() {
facetStats = new LinkedHashMap<>();
subFacets = new LinkedHashMap<>();
}
public Map<String, AggValueSource> getFacetStats() {
return facetStats;
}
public Map<String, FacetRequest> getSubFacets() {
return subFacets;
}
/**
* Returns null if unset
*/
public RefineMethod getRefineMethod() {
return null;
}
public boolean doRefine() {
return !(getRefineMethod() == null || getRefineMethod() == NONE);
}
/**
* Returns true if this facet can return just some of the facet buckets that match all the criteria.
* This is normally true only for facets with a limit.
*/
public boolean returnsPartial() {
// TODO: should the default impl check processEmpty ?
return false;
}
/**
* Returns true if this facet, or any sub-facets can produce results from an empty domain.
*/
public boolean canProduceFromEmpty() {
if (domain != null && domain.canBecomeNonEmpty()) return true;
for (FacetRequest freq : subFacets.values()) {
if (freq.canProduceFromEmpty()) return true;
}
return false;
}
public void addStat(String key, AggValueSource stat) {
facetStats.put(key, stat);
}
public void addSubFacet(String key, FacetRequest facetRequest) {
subFacets.put(key, facetRequest);
}
@Override
public String toString() {
Map<String, Object> descr = getFacetDescription();
StringBuilder s = new StringBuilder("facet request: { ");
for (Map.Entry<String, Object> entry : descr.entrySet()) {
s.append(entry.getKey()).append(':').append(entry.getValue()).append(',');
}
s.append('}');
return s.toString();
}
/**
* Process this facet request against the given domain of docs.
* Note: this is currently used externally by {@link org.apache.solr.request.SimpleFacets}.
*/
public final Object process(SolrQueryRequest req, DocSet domain) throws IOException {
//TODO check for FacetDebugInfo? and if so set on fcontext
// rb.req.getContext().get("FacetDebugInfo");
//TODO should the SolrQueryRequest be held on the FacetRequest? It was created from parse(req,...) so is known.
FacetContext fcontext = new FacetContext();
fcontext.base = domain;
fcontext.req = req;
fcontext.searcher = req.getSearcher();
fcontext.qcontext = QueryContext.newContext(fcontext.searcher);
return process(fcontext);
}
/**
* Process the request with the facet context settings, a parameter-object.
*/
final Object process(FacetContext fcontext) throws IOException {
@SuppressWarnings("rawtypes")
FacetProcessor facetProcessor = createFacetProcessor(fcontext);
FacetDebugInfo debugInfo = fcontext.getDebugInfo();
if (debugInfo == null) {
facetProcessor.process();
} else {
if (fcontext.filter != null) {
debugInfo.setFilter(fcontext.filter.toString());
}
debugInfo.setReqDescription(getFacetDescription());
debugInfo.setProcessor(facetProcessor.getClass().getSimpleName());
debugInfo.putInfoItem("domainSize", (long) fcontext.base.size());
RTimer timer = new RTimer();
try {
facetProcessor.process();
} finally {
debugInfo.setElapse((long) timer.getTime());
}
}
return facetProcessor.getResponse();
}
@SuppressWarnings("rawtypes")
public abstract FacetProcessor createFacetProcessor(FacetContext fcontext);
public abstract FacetMerger createFacetMerger(Object prototype);
public abstract Map<String, Object> getFacetDescription();
}