| /* |
| * 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.util.List; |
| import java.util.ArrayList; |
| import java.util.Map; |
| import java.util.Optional; |
| import java.util.stream.Collectors; |
| |
| import org.apache.solr.common.SolrException; |
| import org.apache.solr.common.params.SolrParams; |
| import org.apache.solr.common.util.NamedList; |
| import org.apache.solr.common.util.StrUtils; |
| import org.apache.solr.search.FunctionQParser; |
| import org.apache.solr.request.SolrQueryRequest; |
| import org.apache.solr.schema.IndexSchema; |
| import org.apache.solr.search.QParser; |
| import org.apache.solr.search.SyntaxError; |
| |
| import static org.apache.solr.common.params.CommonParams.SORT; |
| |
| abstract class FacetParser<FacetRequestT extends FacetRequest> { |
| protected FacetRequestT facet; |
| protected FacetParser<?> parent; |
| protected String key; |
| |
| public FacetParser(FacetParser<?> parent, String key) { |
| this.parent = parent; |
| this.key = key; |
| } |
| |
| public String getKey() { |
| return key; |
| } |
| |
| public String getPathStr() { |
| if (parent == null) { |
| return "/" + key; |
| } |
| return parent.getKey() + "/" + key; |
| } |
| |
| protected RuntimeException err(String msg) { |
| return new SolrException(SolrException.ErrorCode.BAD_REQUEST, msg + " , path="+getPathStr()); |
| } |
| |
| public abstract FacetRequest parse(Object o) throws SyntaxError; |
| |
| // TODO: put the FacetRequest on the parser object? |
| public void parseSubs(Object o) throws SyntaxError { |
| if (o==null) return; |
| if (o instanceof Map) { |
| @SuppressWarnings({"unchecked"}) |
| Map<String,Object> m = (Map<String, Object>) o; |
| for (Map.Entry<String,Object> entry : m.entrySet()) { |
| String key = entry.getKey(); |
| Object value = entry.getValue(); |
| |
| if ("processEmpty".equals(key)) { |
| facet.processEmpty = getBoolean(m, "processEmpty", false); |
| continue; |
| } |
| |
| // "my_prices" : { "range" : { "field":... |
| // key="my_prices", value={"range":.. |
| |
| Object parsedValue = parseFacetOrStat(key, value); |
| |
| // TODO: have parseFacetOrStat directly add instead of return? |
| if (parsedValue instanceof FacetRequest) { |
| facet.addSubFacet(key, (FacetRequest)parsedValue); |
| } else if (parsedValue instanceof AggValueSource) { |
| facet.addStat(key, (AggValueSource)parsedValue); |
| } else { |
| throw err("Unknown facet type key=" + key + " class=" + (parsedValue == null ? "null" : parsedValue.getClass().getName())); |
| } |
| } |
| } else { |
| // facet : my_field? |
| throw err("Expected map for facet/stat"); |
| } |
| } |
| |
| public Object parseFacetOrStat(String key, Object o) throws SyntaxError { |
| |
| if (o instanceof String) { |
| return parseStringFacetOrStat(key, (String)o); |
| } |
| |
| if (!(o instanceof Map)) { |
| throw err("expected Map but got " + o); |
| } |
| |
| // The type can be in a one element map, or inside the args as the "type" field |
| // { "query" : "foo:bar" } |
| // { "range" : { "field":... } } |
| // { "type" : range, field : myfield, ... } |
| @SuppressWarnings({"unchecked"}) |
| Map<String,Object> m = (Map<String,Object>)o; |
| String type; |
| Object args; |
| |
| if (m.size() == 1) { |
| Map.Entry<String,Object> entry = m.entrySet().iterator().next(); |
| type = entry.getKey(); |
| args = entry.getValue(); |
| // throw err("expected facet/stat type name, like {range:{... but got " + m); |
| } else { |
| // type should be inside the map as a parameter |
| Object typeObj = m.get("type"); |
| if (!(typeObj instanceof String)) { |
| throw err("expected facet/stat type name, like {type:range, field:price, ...} but got " + typeObj); |
| } |
| type = (String)typeObj; |
| args = m; |
| } |
| |
| return parseFacetOrStat(key, type, args); |
| } |
| |
| public Object parseFacetOrStat(String key, String type, Object args) throws SyntaxError { |
| // TODO: a place to register all these facet types? |
| |
| switch (type) { |
| case "field": |
| case "terms": |
| return new FacetFieldParser(this, key).parse(args); |
| case "query": |
| return new FacetQueryParser(this, key).parse(args); |
| case "range": |
| return new FacetRangeParser(this, key).parse(args); |
| case "heatmap": |
| return new FacetHeatmap.Parser(this, key).parse(args); |
| case "func": |
| return parseStat(key, args); |
| } |
| |
| throw err("Unknown facet or stat. key=" + key + " type=" + type + " args=" + args); |
| } |
| |
| public Object parseStringFacetOrStat(String key, String s) throws SyntaxError { |
| // "avg(myfield)" |
| return parseStat(key, s); |
| // TODO - simple string representation of facets |
| } |
| |
| /** Parses simple strings like "avg(x)" in the context of optional local params (may be null) */ |
| private AggValueSource parseStatWithParams(String key, SolrParams localparams, String stat) throws SyntaxError { |
| SolrQueryRequest req = getSolrRequest(); |
| FunctionQParser parser = new FunctionQParser(stat, localparams, req.getParams(), req); |
| AggValueSource agg = parser.parseAgg(FunctionQParser.FLAG_DEFAULT); |
| return agg; |
| } |
| |
| /** Parses simple strings like "avg(x)" or robust Maps that may contain local params */ |
| private AggValueSource parseStat(String key, Object args) throws SyntaxError { |
| assert null != args; |
| |
| if (args instanceof CharSequence) { |
| // Both of these variants are already unpacked for us in this case, and use no local params... |
| // 1) x:{func:'min(foo)'} |
| // 2) x:'min(foo)' |
| return parseStatWithParams(key, null, args.toString()); |
| } |
| |
| if (args instanceof Map) { |
| @SuppressWarnings({"unchecked"}) |
| final Map<String,Object> statMap = (Map<String,Object>)args; |
| return parseStatWithParams(key, jsonToSolrParams(statMap), statMap.get("func").toString()); |
| } |
| |
| throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, |
| "Stats must be specified as either a simple string, or a json Map"); |
| |
| } |
| |
| |
| private FacetRequest.Domain getDomain() { |
| if (facet.domain == null) { |
| facet.domain = new FacetRequest.Domain(); |
| } |
| return facet.domain; |
| } |
| |
| protected void parseCommonParams(Object o) { |
| if (o instanceof Map) { |
| @SuppressWarnings({"unchecked"}) |
| Map<String,Object> m = (Map<String,Object>)o; |
| List<String> excludeTags = getStringList(m, "excludeTags"); |
| if (excludeTags != null) { |
| getDomain().excludeTags = excludeTags; |
| } |
| |
| Object domainObj = m.get("domain"); |
| if (domainObj instanceof Map) { |
| @SuppressWarnings({"unchecked"}) |
| Map<String, Object> domainMap = (Map<String, Object>)domainObj; |
| FacetRequest.Domain domain = getDomain(); |
| |
| excludeTags = getStringList(domainMap, "excludeTags"); |
| if (excludeTags != null) { |
| domain.excludeTags = excludeTags; |
| } |
| |
| if (domainMap.containsKey("query")) { |
| domain.explicitQueries = parseJSONQueryStruct(domainMap.get("query")); |
| if (null == domain.explicitQueries) { |
| throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, |
| "'query' domain can not be null or empty"); |
| } else if (null != domain.excludeTags) { |
| throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, |
| "'query' domain can not be combined with 'excludeTags'"); |
| } |
| } |
| |
| String blockParent = getString(domainMap, "blockParent", null); |
| String blockChildren = getString(domainMap, "blockChildren", null); |
| |
| if (blockParent != null) { |
| domain.toParent = true; |
| domain.parents = blockParent; |
| } else if (blockChildren != null) { |
| domain.toChildren = true; |
| domain.parents = blockChildren; |
| } |
| |
| FacetRequest.Domain.JoinField.createJoinField(domain, domainMap); |
| FacetRequest.Domain.GraphField.createGraphField(domain, domainMap); |
| |
| Object filterOrList = domainMap.get("filter"); |
| if (filterOrList != null) { |
| assert domain.filters == null; |
| domain.filters = parseJSONQueryStruct(filterOrList); |
| } |
| |
| } else if (domainObj != null) { |
| throw err("Expected Map for 'domain', received " + domainObj.getClass().getSimpleName() + "=" + domainObj); |
| } |
| } |
| } |
| |
| /** returns null on null input, otherwise returns a list of the JSON query structures -- either |
| * directly from the raw (list) input, or if raw input is a not a list then it encapsulates |
| * it in a new list. |
| */ |
| @SuppressWarnings({"unchecked"}) |
| private List<Object> parseJSONQueryStruct(Object raw) { |
| List<Object> result = null; |
| if (null == raw) { |
| return result; |
| } else if (raw instanceof List) { |
| result = (List<Object>) raw; |
| } else { |
| result = new ArrayList<>(1); |
| result.add(raw); |
| } |
| return result; |
| } |
| |
| public String getField(Map<String,Object> args) { |
| Object fieldName = args.get("field"); // TODO: pull out into defined constant |
| if (fieldName == null) { |
| fieldName = args.get("f"); // short form |
| } |
| if (fieldName == null) { |
| throw err("Missing 'field'"); |
| } |
| |
| if (!(fieldName instanceof String)) { |
| throw err("Expected string for 'field', got" + fieldName); |
| } |
| |
| return (String)fieldName; |
| } |
| |
| |
| public Long getLongOrNull(Map<String,Object> args, String paramName, boolean required) { |
| Object o = args.get(paramName); |
| if (o == null) { |
| if (required) { |
| throw err("Missing required parameter '" + paramName + "'"); |
| } |
| return null; |
| } |
| if (!(o instanceof Long || o instanceof Integer || o instanceof Short || o instanceof Byte)) { |
| throw err("Expected integer type for param '"+paramName + "' but got " + o); |
| } |
| |
| return ((Number)o).longValue(); |
| } |
| |
| public long getLong(Map<String,Object> args, String paramName, long defVal) { |
| Object o = args.get(paramName); |
| if (o == null) { |
| return defVal; |
| } |
| if (!(o instanceof Long || o instanceof Integer || o instanceof Short || o instanceof Byte)) { |
| throw err("Expected integer type for param '"+paramName + "' but got " + o.getClass().getSimpleName() + " = " + o); |
| } |
| |
| return ((Number)o).longValue(); |
| } |
| |
| public Double getDoubleOrNull(Map<String,Object> args, String paramName, boolean required) { |
| Object o = args.get(paramName); |
| if (o == null) { |
| if (required) { |
| throw err("Missing required parameter '" + paramName + "'"); |
| } |
| return null; |
| } |
| if (!(o instanceof Number)) { |
| throw err("Expected double type for param '" + paramName + "' but got " + o); |
| } |
| |
| return ((Number)o).doubleValue(); |
| } |
| |
| public boolean getBoolean(Map<String,Object> args, String paramName, boolean defVal) { |
| Object o = args.get(paramName); |
| if (o == null) { |
| return defVal; |
| } |
| // TODO: should we be more flexible and accept things like "true" (strings)? |
| // Perhaps wait until the use case comes up. |
| if (!(o instanceof Boolean)) { |
| throw err("Expected boolean type for param '"+paramName + "' but got " + o.getClass().getSimpleName() + " = " + o); |
| } |
| |
| return (Boolean)o; |
| } |
| |
| public Boolean getBooleanOrNull(Map<String, Object> args, String paramName) { |
| Object o = args.get(paramName); |
| |
| if (o != null && !(o instanceof Boolean)) { |
| throw err("Expected boolean type for param '"+paramName + "' but got " + o.getClass().getSimpleName() + " = " + o); |
| } |
| return (Boolean) o; |
| } |
| |
| |
| public String getString(Map<String,Object> args, String paramName, String defVal) { |
| Object o = args.get(paramName); |
| if (o == null) { |
| return defVal; |
| } |
| if (!(o instanceof String)) { |
| throw err("Expected string type for param '"+paramName + "' but got " + o.getClass().getSimpleName() + " = " + o); |
| } |
| |
| return (String)o; |
| } |
| |
| public Object getVal(Map<String, Object> args, String paramName, boolean required) { |
| Object o = args.get(paramName); |
| if (o == null && required) { |
| throw err("Missing required parameter: '" + paramName + "'"); |
| } |
| return o; |
| } |
| |
| public List<String> getStringList(Map<String,Object> args, String paramName) { |
| return getStringList(args, paramName, true); |
| } |
| |
| @SuppressWarnings({"unchecked"}) |
| public List<String> getStringList(Map<String, Object> args, String paramName, boolean decode) { |
| Object o = args.get(paramName); |
| if (o == null) { |
| return null; |
| } |
| if (o instanceof List) { |
| return (List<String>)o; |
| } |
| if (o instanceof String) { |
| return StrUtils.splitSmart((String)o, ",", decode).stream() |
| .map(String::trim) |
| .filter(s -> !s.isEmpty()) |
| .collect(Collectors.toList()); |
| } |
| |
| throw err("Expected list of string or comma separated string values for '" + paramName + |
| "', received " + o.getClass().getSimpleName() + "=" + o); |
| } |
| |
| public IndexSchema getSchema() { |
| return parent.getSchema(); |
| } |
| |
| public SolrQueryRequest getSolrRequest() { |
| return parent.getSolrRequest(); |
| } |
| |
| /** |
| * Helper that handles the possibility of map values being lists |
| * NOTE: does *NOT* fail on map values that are sub-maps (ie: nested json objects) |
| */ |
| @SuppressWarnings({"unchecked", "rawtypes"}) |
| public static SolrParams jsonToSolrParams(Map jsonObject) { |
| // HACK, but NamedList already handles the list processing for us... |
| NamedList<String> nl = new NamedList<>(); |
| nl.addAll(jsonObject); |
| return SolrParams.toSolrParams(nl); |
| } |
| |
| // TODO Make this private (or at least not static) and introduce |
| // a newInstance method on FacetParser that returns one of these? |
| static class FacetTopParser extends FacetParser<FacetQuery> { |
| private SolrQueryRequest req; |
| |
| public FacetTopParser(SolrQueryRequest req) { |
| super(null, "facet"); |
| this.facet = new FacetQuery(); |
| this.req = req; |
| } |
| |
| @Override |
| public FacetQuery parse(Object args) throws SyntaxError { |
| parseSubs(args); |
| return facet; |
| } |
| |
| @Override |
| public SolrQueryRequest getSolrRequest() { |
| return req; |
| } |
| |
| @Override |
| public IndexSchema getSchema() { |
| return req.getSchema(); |
| } |
| } |
| |
| static class FacetQueryParser extends FacetParser<FacetQuery> { |
| public FacetQueryParser(@SuppressWarnings("rawtypes") FacetParser parent, String key) { |
| super(parent, key); |
| facet = new FacetQuery(); |
| } |
| |
| @Override |
| public FacetQuery parse(Object arg) throws SyntaxError { |
| parseCommonParams(arg); |
| |
| String qstring = null; |
| if (arg instanceof String) { |
| // just the field name... |
| qstring = (String)arg; |
| |
| } else if (arg instanceof Map) { |
| @SuppressWarnings({"unchecked"}) |
| Map<String, Object> m = (Map<String, Object>) arg; |
| qstring = getString(m, "q", null); |
| if (qstring == null) { |
| qstring = getString(m, "query", null); |
| } |
| |
| // OK to parse subs before we have parsed our own query? |
| // as long as subs don't need to know about it. |
| parseSubs( m.get("facet") ); |
| } else if (arg != null) { |
| // something lke json.facet.facet.query=2 |
| throw err("Expected string/map for facet query, received " + arg.getClass().getSimpleName() + "=" + arg); |
| } |
| |
| // TODO: substats that are from defaults!!! |
| |
| if (qstring != null) { |
| QParser parser = QParser.getParser(qstring, getSolrRequest()); |
| parser.setIsFilter(true); |
| facet.q = parser.getQuery(); |
| } |
| |
| return facet; |
| } |
| } |
| |
| /*** not a separate type of parser for now... |
| static class FacetBlockParentParser extends FacetParser<FacetBlockParent> { |
| public FacetBlockParentParser(FacetParser parent, String key) { |
| super(parent, key); |
| facet = new FacetBlockParent(); |
| } |
| |
| @Override |
| public FacetBlockParent parse(Object arg) throws SyntaxError { |
| parseCommonParams(arg); |
| |
| if (arg instanceof String) { |
| // just the field name... |
| facet.parents = (String)arg; |
| |
| } else if (arg instanceof Map) { |
| Map<String, Object> m = (Map<String, Object>) arg; |
| facet.parents = getString(m, "parents", null); |
| |
| parseSubs( m.get("facet") ); |
| } |
| |
| return facet; |
| } |
| } |
| ***/ |
| |
| static class FacetFieldParser extends FacetParser<FacetField> { |
| @SuppressWarnings({"rawtypes"}) |
| public FacetFieldParser(FacetParser parent, String key) { |
| super(parent, key); |
| facet = new FacetField(); |
| } |
| |
| public FacetField parse(Object arg) throws SyntaxError { |
| parseCommonParams(arg); |
| if (arg instanceof String) { |
| // just the field name... |
| facet.field = (String)arg; |
| |
| } else if (arg instanceof Map) { |
| @SuppressWarnings({"unchecked"}) |
| Map<String, Object> m = (Map<String, Object>) arg; |
| facet.field = getField(m); |
| facet.offset = getLong(m, "offset", facet.offset); |
| facet.limit = getLong(m, "limit", facet.limit); |
| facet.overrequest = (int) getLong(m, "overrequest", facet.overrequest); |
| facet.overrefine = (int) getLong(m, "overrefine", facet.overrefine); |
| if (facet.limit == 0) facet.offset = 0; // normalize. an offset with a limit of non-zero isn't useful. |
| facet.mincount = getLong(m, "mincount", facet.mincount); |
| facet.missing = getBoolean(m, "missing", facet.missing); |
| facet.numBuckets = getBoolean(m, "numBuckets", facet.numBuckets); |
| facet.prefix = getString(m, "prefix", facet.prefix); |
| facet.allBuckets = getBoolean(m, "allBuckets", facet.allBuckets); |
| facet.method = FacetField.FacetMethod.fromString(getString(m, "method", null)); |
| facet.cacheDf = (int)getLong(m, "cacheDf", facet.cacheDf); |
| |
| // TODO: pull up to higher level? |
| facet.refine = FacetRequest.RefineMethod.fromObj(m.get("refine")); |
| |
| facet.perSeg = getBooleanOrNull(m, "perSeg"); |
| |
| // facet.sort may depend on a facet stat... |
| // should we be parsing / validating this here, or in the execution environment? |
| Object o = m.get("facet"); |
| parseSubs(o); |
| |
| facet.sort = parseAndValidateSort(facet, m, SORT); |
| facet.prelim_sort = parseAndValidateSort(facet, m, "prelim_sort"); |
| } else if (arg != null) { |
| // something like json.facet.facet.field=2 |
| throw err("Expected string/map for facet field, received " + arg.getClass().getSimpleName() + "=" + arg); |
| } |
| |
| if (null == facet.sort) { |
| facet.sort = FacetRequest.FacetSort.COUNT_DESC; |
| } |
| |
| return facet; |
| } |
| |
| /** |
| * Parses, validates and returns the {@link FacetRequest.FacetSort} for given sortParam |
| * and facet field |
| * <p> |
| * Currently, supported sort specifications are 'mystat desc' OR {mystat: 'desc'} |
| * index - This is equivalent to 'index asc' |
| * count - This is equivalent to 'count desc' |
| * </p> |
| * |
| * @param facet {@link FacetField} for which sort needs to be parsed and validated |
| * @param args map containing the sortVal for given sortParam |
| * @param sortParam parameter for which sort needs to parsed and validated |
| * @return parsed facet sort |
| */ |
| private static FacetRequest.FacetSort parseAndValidateSort(FacetField facet, Map<String, Object> args, String sortParam) { |
| Object sort = args.get(sortParam); |
| if (sort == null) { |
| return null; |
| } |
| |
| FacetRequest.FacetSort facetSort = null; |
| |
| if (sort instanceof String) { |
| String sortStr = (String)sort; |
| if (sortStr.endsWith(" asc")) { |
| facetSort = new FacetRequest.FacetSort(sortStr.substring(0, sortStr.length()-" asc".length()), |
| FacetRequest.SortDirection.asc); |
| } else if (sortStr.endsWith(" desc")) { |
| facetSort = new FacetRequest.FacetSort(sortStr.substring(0, sortStr.length()-" desc".length()), |
| FacetRequest.SortDirection.desc); |
| } else { |
| facetSort = new FacetRequest.FacetSort(sortStr, |
| // default direction for "index" is ascending |
| ("index".equals(sortStr) |
| ? FacetRequest.SortDirection.asc |
| : FacetRequest.SortDirection.desc)); |
| } |
| } else if (sort instanceof Map) { |
| // { myvar : 'desc' } |
| @SuppressWarnings("unchecked") |
| Optional<Map.Entry<String,Object>> optional = ((Map<String,Object>)sort).entrySet().stream().findFirst(); |
| if (optional.isPresent()) { |
| Map.Entry<String, Object> entry = optional.get(); |
| facetSort = new FacetRequest.FacetSort(entry.getKey(), FacetRequest.SortDirection.fromObj(entry.getValue())); |
| } |
| } else { |
| throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, |
| "Expected string/map for '" + sortParam +"', received "+ sort.getClass().getSimpleName() + "=" + sort); |
| } |
| |
| Map<String, AggValueSource> facetStats = facet.facetStats; |
| // validate facet sort |
| boolean isValidSort = facetSort == null || |
| "index".equals(facetSort.sortVariable) || |
| "count".equals(facetSort.sortVariable) || |
| (facetStats != null && facetStats.containsKey(facetSort.sortVariable)); |
| |
| if (!isValidSort) { |
| throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, |
| "Invalid " + sortParam + " option '" + sort + "' for field '" + facet.field + "'"); |
| } |
| return facetSort; |
| } |
| |
| } |
| |
| } |