/* * 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.handler.component; import org.apache.lucene.util.BytesRefBuilder; import org.apache.solr.common.StringUtils; import org.apache.solr.common.params.RequiredSolrParams; import org.apache.solr.schema.SchemaField; import org.apache.solr.schema.FieldType; import org.apache.solr.search.SolrIndexSearcher; import org.apache.solr.search.DocSet; import org.apache.solr.search.SyntaxError; import org.apache.solr.util.PivotListEntry; import org.apache.solr.common.SolrException; import org.apache.solr.common.util.NamedList; import org.apache.solr.common.util.SimpleOrderedMap; import org.apache.solr.common.util.StrUtils; import org.apache.solr.common.SolrException.ErrorCode; import org.apache.solr.common.params.SolrParams; import org.apache.solr.common.params.ShardParams; import org.apache.solr.common.params.FacetParams; import org.apache.solr.common.params.StatsParams; import org.apache.solr.request.SimpleFacets; import org.apache.solr.request.SolrQueryRequest; import org.apache.lucene.search.Query; import java.io.IOException; import java.util.ArrayList; import java.util.Collections; import java.util.Deque; import java.util.LinkedHashMap; import java.util.LinkedList; import java.util.List; import java.util.Map; /** * Processes all Pivot facet logic for a single node -- both non-distrib, and per-shard */ public class PivotFacetProcessor extends SimpleFacets { public static final String QUERY = "query"; public static final String RANGE = "range"; protected SolrParams params; public PivotFacetProcessor(SolrQueryRequest req, DocSet docs, SolrParams params, ResponseBuilder rb) { super(req, docs, params, rb); this.params = params; } /** * Processes all of the specified {@link FacetParams#FACET_PIVOT} strings, generating * a complete response tree for each pivot. The values in this response will either * be the complete tree of fields and values for the specified pivot in the local index, * or the requested refinements if the pivot params include the {@link PivotFacet#REFINE_PARAM} */ public SimpleOrderedMap<List<NamedList<Object>>> process(String[] pivots) throws IOException { if (!rb.doFacets || pivots == null) return null; // rb._statsInfo may be null if stats=false, ie: refine requests // if that's the case, but we need to refine w/stats, then we'll lazy init our // own instance of StatsInfo StatsInfo statsInfo = rb._statsInfo; SimpleOrderedMap<List<NamedList<Object>>> pivotResponse = new SimpleOrderedMap<>(); for (String pivotList : pivots) { final ParsedParams parsed; try { parsed = this.parseParams(FacetParams.FACET_PIVOT, pivotList); } catch (SyntaxError e) { throw new SolrException(ErrorCode.BAD_REQUEST, e); } List<String> pivotFields = StrUtils.splitSmart(parsed.facetValue, ",", true); if( pivotFields.size() < 1 ) { throw new SolrException( ErrorCode.BAD_REQUEST, "Pivot Facet needs at least one field name: " + pivotList); } else { SolrIndexSearcher searcher = rb.req.getSearcher(); for (String fieldName : pivotFields) { SchemaField sfield = searcher.getSchema().getField(fieldName); if (sfield == null) { throw new SolrException(ErrorCode.BAD_REQUEST, "\"" + fieldName + "\" is not a valid field name in pivot: " + pivotList); } } } // start by assuming no local params... String refineKey = null; // no local => no refinement List<StatsField> statsFields = Collections.emptyList(); // no local => no stats List<FacetComponent.FacetBase> facetQueries = Collections.emptyList(); List<RangeFacetRequest> facetRanges = Collections.emptyList(); if (null != parsed.localParams) { // we might be refining.. refineKey = parsed.localParams.get(PivotFacet.REFINE_PARAM); String statsLocalParam = parsed.localParams.get(StatsParams.STATS); if (null != refineKey && null != statsLocalParam && null == statsInfo) { // we are refining and need to compute stats, // but stats component hasn't inited StatsInfo (because we // don't need/want top level stats when refining) so we lazy init // our own copy of StatsInfo statsInfo = new StatsInfo(rb); } statsFields = getTaggedStatsFields(statsInfo, statsLocalParam); try { FacetComponent.FacetContext facetContext = FacetComponent.FacetContext.getFacetContext(req); String taggedQueries = parsed.localParams.get(QUERY); if (StringUtils.isEmpty(taggedQueries)) { facetQueries = Collections.emptyList(); } else { List<String> localParamValue = StrUtils.splitSmart(taggedQueries, ','); if (localParamValue.size() > 1) { String msg = QUERY + " local param of " + FacetParams.FACET_PIVOT + "may not include tags separated by a comma - please use a common tag on all " + FacetParams.FACET_QUERY + " params you wish to compute under this pivot"; throw new SolrException(ErrorCode.BAD_REQUEST, msg); } taggedQueries = localParamValue.get(0); facetQueries = facetContext.getQueryFacetsForTag(taggedQueries); } String taggedRanges = parsed.localParams.get(RANGE); if (StringUtils.isEmpty(taggedRanges)) { facetRanges = Collections.emptyList(); } else { List<String> localParamValue = StrUtils.splitSmart(taggedRanges, ','); if (localParamValue.size() > 1) { String msg = RANGE + " local param of " + FacetParams.FACET_PIVOT + "may not include tags separated by a comma - please use a common tag on all " + FacetParams.FACET_RANGE + " params you wish to compute under this pivot"; throw new SolrException(ErrorCode.BAD_REQUEST, msg); } taggedRanges = localParamValue.get(0); facetRanges = facetContext.getRangeFacetRequestsForTag(taggedRanges); } } catch (IllegalStateException e) { throw new SolrException(ErrorCode.SERVER_ERROR, "Faceting context not set, cannot calculate pivot values"); } } if (null != refineKey) { String[] refinementValuesByField = params.getParams(PivotFacet.REFINE_PARAM + refineKey); for(String refinements : refinementValuesByField){ pivotResponse.addAll(processSingle(pivotFields, refinements, statsFields, parsed, facetQueries, facetRanges)); } } else{ pivotResponse.addAll(processSingle(pivotFields, null, statsFields, parsed, facetQueries, facetRanges)); } } return pivotResponse; } /** * Process a single branch of refinement values for a specific pivot * @param pivotFields the ordered list of fields in this pivot * @param refinements the comma separate list of refinement values corresponding to each field in the pivot, or null if there are no refinements * @param statsFields List of {@link StatsField} instances to compute for each pivot value * @param facetQueries the list of facet queries hung under this pivot * @param facetRanges the list of facet ranges hung under this pivot */ private SimpleOrderedMap<List<NamedList<Object>>> processSingle (List<String> pivotFields, String refinements, List<StatsField> statsFields, final ParsedParams parsed, List<FacetComponent.FacetBase> facetQueries, List<RangeFacetRequest> facetRanges) throws IOException { SolrIndexSearcher searcher = rb.req.getSearcher(); SimpleOrderedMap<List<NamedList<Object>>> pivotResponse = new SimpleOrderedMap<>(); String field = pivotFields.get(0); SchemaField sfield = searcher.getSchema().getField(field); Deque<String> fnames = new LinkedList<>(); for( int i = pivotFields.size()-1; i>1; i-- ) { fnames.push( pivotFields.get(i) ); } NamedList<Integer> facetCounts; Deque<String> vnames = new LinkedList<>(); if (null != refinements) { // All values, split by the field they should go to List<String> refinementValuesByField = PivotFacetHelper.decodeRefinementValuePath(refinements); for( int i=refinementValuesByField.size()-1; i>0; i-- ) { vnames.push(refinementValuesByField.get(i));//Only for [1] and on } String firstFieldsValues = refinementValuesByField.get(0); facetCounts = new NamedList<>(); facetCounts.add(firstFieldsValues, getSubsetSize(parsed.docs, sfield, firstFieldsValues)); } else { // no refinements needed facetCounts = this.getTermCountsForPivots(field, parsed); } if(pivotFields.size() > 1) { String subField = pivotFields.get(1); pivotResponse.add(parsed.key, doPivots(facetCounts, field, subField, fnames, vnames, parsed, statsFields, facetQueries, facetRanges)); } else { pivotResponse.add(parsed.key, doPivots(facetCounts, field, null, fnames, vnames, parsed, statsFields, facetQueries, facetRanges)); } return pivotResponse; } /** * returns the {@link StatsField} instances that should be computed for a pivot * based on the 'stats' local params used. * * @return A list of StatsFields to compute for this pivot, or the empty list if none */ private static List<StatsField> getTaggedStatsFields(StatsInfo statsInfo, String statsLocalParam) { if (null == statsLocalParam || null == statsInfo) { return Collections.emptyList(); } List<StatsField> fields = new ArrayList<>(7); List<String> statsAr = StrUtils.splitSmart(statsLocalParam, ','); // TODO: for now, we only support a single tag name - we reserve using // ',' as a possible delimiter for logic related to only computing stats // at certain levels -- see SOLR-6663 if (1 < statsAr.size()) { String msg = StatsParams.STATS + " local param of " + FacetParams.FACET_PIVOT + "may not include tags separated by a comma - please use a common tag on all " + StatsParams.STATS_FIELD + " params you wish to compute under this pivot"; throw new SolrException(ErrorCode.BAD_REQUEST, msg); } for(String stat : statsAr) { fields.addAll(statsInfo.getStatsFieldsByTag(stat)); } return fields; } /** * Recursive function to compute all the pivot counts for the values under the specified field */ protected List<NamedList<Object>> doPivots(NamedList<Integer> superFacets, String field, String subField, Deque<String> fnames, Deque<String> vnames, ParsedParams parsed, List<StatsField> statsFields, List<FacetComponent.FacetBase> facetQueries, List<RangeFacetRequest> facetRanges) throws IOException { boolean isShard = rb.req.getParams().getBool(ShardParams.IS_SHARD, false); SolrIndexSearcher searcher = rb.req.getSearcher(); // TODO: optimize to avoid converting to an external string and then having to convert back to internal below SchemaField sfield = searcher.getSchema().getField(field); FieldType ftype = sfield.getType(); String nextField = fnames.poll(); // re-usable BytesRefBuilder for conversion of term values to Objects BytesRefBuilder termval = new BytesRefBuilder(); List<NamedList<Object>> values = new ArrayList<>( superFacets.size() ); for (Map.Entry<String, Integer> kv : superFacets) { // Only sub-facet if parent facet has positive count - still may not be any values for the sub-field though if (kv.getValue() >= getMinCountForField(field)) { final String fieldValue = kv.getKey(); final int pivotCount = kv.getValue(); SimpleOrderedMap<Object> pivot = new SimpleOrderedMap<>(); pivot.add( "field", field ); if (null == fieldValue) { pivot.add( "value", null ); } else { ftype.readableToIndexed(fieldValue, termval); pivot.add( "value", ftype.toObject(sfield, termval.get()) ); } pivot.add( "count", pivotCount ); final DocSet subset = getSubset(parsed.docs, sfield, fieldValue); addPivotQueriesAndRanges(pivot, params, subset, facetQueries, facetRanges); if( subField != null ) { NamedList<Integer> facetCounts; if(!vnames.isEmpty()){ String val = vnames.pop(); facetCounts = new NamedList<>(); facetCounts.add(val, getSubsetSize(subset, searcher.getSchema().getField(subField), val)); } else { facetCounts = this.getTermCountsForPivots(subField, parsed.withDocs(subset)); } if (facetCounts.size() >= 1) { pivot.add( "pivot", doPivots( facetCounts, subField, nextField, fnames, vnames, parsed.withDocs(subset), statsFields, facetQueries, facetRanges) ); } } if ((isShard || 0 < pivotCount) && ! statsFields.isEmpty()) { Map<String, StatsValues> stv = new LinkedHashMap<>(); for (StatsField statsField : statsFields) { stv.put(statsField.getOutputKey(), statsField.computeLocalStatsValues(subset)); } pivot.add("stats", StatsComponent.convertToResponse(stv)); } values.add( pivot ); } } // put the field back on the list fnames.push( nextField ); return values; } /** * Given a base docset, computes the size of the subset of documents corresponding to the specified pivotValue * * @param base the set of documents to evaluate relative to * @param field the field type used by the pivotValue * @param pivotValue String representation of the value, may be null (ie: "missing") */ private int getSubsetSize(DocSet base, SchemaField field, String pivotValue) throws IOException { FieldType ft = field.getType(); if ( null == pivotValue ) { Query query = ft.getRangeQuery(null, field, null, null, false, false); DocSet hasVal = searcher.getDocSet(query); return base.andNotSize(hasVal); } else { Query query = ft.getFieldQuery(null, field, pivotValue); return searcher.numDocs(query, base); } } /** * Given a base docset, computes the subset of documents corresponding to the specified pivotValue * * @param base the set of documents to evaluate relative to * @param field the field type used by the pivotValue * @param pivotValue String representation of the value, may be null (ie: "missing") */ private DocSet getSubset(DocSet base, SchemaField field, String pivotValue) throws IOException { FieldType ft = field.getType(); if ( null == pivotValue ) { Query query = ft.getRangeQuery(null, field, null, null, false, false); DocSet hasVal = searcher.getDocSet(query); return base.andNot(hasVal); } else { Query query = ft.getFieldQuery(null, field, pivotValue); return searcher.getDocSet(query, base); } } /** * Add facet.queries and facet.ranges to the pivot response if needed * * @param pivot * Pivot in which to inject additional data * @param params * Query parameters. * @param docs * DocSet of the current pivot to use for computing sub-counts * @param facetQueries * Tagged facet queries should have to be included, must not be null * @param facetRanges * Taged facet ranges should have to be included, must not be null * @throws IOException * If searcher has issues finding numDocs. */ protected void addPivotQueriesAndRanges(NamedList<Object> pivot, SolrParams params, DocSet docs, List<FacetComponent.FacetBase> facetQueries, List<RangeFacetRequest> facetRanges) throws IOException { assert null != facetQueries; assert null != facetRanges; if ( ! facetQueries.isEmpty()) { SimpleFacets facets = new SimpleFacets(req, docs, params); NamedList<Integer> res = new SimpleOrderedMap<>(); for (FacetComponent.FacetBase facetQuery : facetQueries) { try { ParsedParams parsed = getParsedParams(params, docs, facetQuery); facets.getFacetQueryCount(parsed, res); } catch (SyntaxError e) { throw new SolrException(ErrorCode.BAD_REQUEST, "Invalid " + FacetParams.FACET_QUERY + " (" + facetQuery.facetStr + ") cause: " + e.getMessage(), e); } } pivot.add(PivotListEntry.QUERIES.getName(), res); } if ( ! facetRanges.isEmpty()) { RangeFacetProcessor rangeFacetProcessor = new RangeFacetProcessor(req, docs, params, null); NamedList<Object> resOuter = new SimpleOrderedMap<>(); for (RangeFacetRequest rangeFacet : facetRanges) { try { rangeFacetProcessor.getFacetRangeCounts(rangeFacet, resOuter); } catch (SyntaxError e) { throw new SolrException(ErrorCode.BAD_REQUEST, "Invalid " + FacetParams.FACET_RANGE + " (" + rangeFacet.facetStr + ") cause: " + e.getMessage(), e); } } pivot.add(PivotListEntry.RANGES.getName(), resOuter); } } private ParsedParams getParsedParams(SolrParams params, DocSet docs, FacetComponent.FacetBase facet) { SolrParams wrapped = SolrParams.wrapDefaults(facet.localParams, global); SolrParams required = new RequiredSolrParams(params); return new ParsedParams(facet.localParams, wrapped, required, facet.facetOn, docs, facet.getKey(), facet.getTags(), -1); } private int getMinCountForField(String fieldname){ return params.getFieldInt(fieldname, FacetParams.FACET_PIVOT_MINCOUNT, 1); } }