All Downloads are FREE. Search and download functionalities are using the official Maven repository.

org.apache.solr.search.facet.FacetRequest Maven / Gradle / Ivy

There is a newer version: 9.7.0
Show newest version
/*
 * 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 static org.apache.solr.search.facet.FacetRequest.RefineMethod.NONE;

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 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.SyntaxError;
import org.apache.solr.search.WrappedQuery;
import org.apache.solr.search.join.GraphQuery;
import org.apache.solr.search.join.GraphQueryParser;
import org.apache.solr.util.RTimer;

/**
 * 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;
    }

    @Override
    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;
    }

    @Override
    public int hashCode() {
      return Objects.hash(sortVariable, sortDirection);
    }

    @Override
    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 facetStats; // per-bucket statistics
  protected Map subFacets; // per-bucket sub-facets
  protected boolean processEmpty;
  protected Domain domain;

  // domain changes
  public static class Domain {
    /**
     * An explicit query domain, ignoring all parent context, expressed in JSON query
     * format. Mutually exclusive to {@link #excludeTags}
     */
    public List 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 excludeTags;

    public JoinField joinField;
    public GraphField graphField;
    public boolean toParent;
    public boolean toChildren;
    // identifies the parent filter... the full set of parent documents for any block join operation
    public String parents;
    public List 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 SUPPORTED_JOIN_PROPERTIES =
          Set.of(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 Domain, and a (JSON) map specifying the configuration for that Domain,
       * validates if a 'join' is specified, and if so creates a JoinField
       * and sets it on the Domain.
       *
       * 

(params must not be null) */ public static void createJoinField( FacetRequest.Domain domain, Map 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 join = (Map) 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) { // 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 Query fromQuery = fcontext.base.makeQuery(); WrappedQuery wrappedFromQuery = new WrappedQuery(fromQuery); // this shouldn't matter once we're wrapped in a join query, but just in case it ever // does... wrappedFromQuery.setCache(false); return JoinQParserPlugin.createJoinQuery(wrappedFromQuery, 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 Domain, and a (JSON) map specifying the configuration for that Domain, * validates if a 'graph' is specified, and if so creates a GraphField * and sets it on the Domain. * *

(params must not be null) */ public static void createGraphField( FacetRequest.Domain domain, Map 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 graph = (Map) 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) { final Query fromQuery = fcontext.base.makeQuery(); WrappedQuery wrappedFromQuery = new WrappedQuery(fromQuery); // this shouldn't matter once we're wrapped in a join query, but just in case it ever // does... wrappedFromQuery.setCache(false); GraphQueryParser graphParser = new GraphQueryParser(null, localParams, null, fcontext.req); try { GraphQuery graphQuery = (GraphQuery) graphParser.parse(); graphQuery.setQ(wrappedFromQuery); 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 params) { 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 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 getFacetStats() { return facetStats; } public Map 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 descr = getFacetDescription(); StringBuilder s = new StringBuilder("facet request: { "); for (Map.Entry 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 { 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(); } public abstract FacetProcessor createFacetProcessor( FacetContext fcontext); public abstract FacetMerger createFacetMerger(Object prototype); public abstract Map getFacetDescription(); }