This is an automated email from the ASF dual-hosted git repository. dsmiley pushed a commit to branch branch_10x in repository https://gitbox.apache.org/repos/asf/solr.git
commit c40315451ad6faab4956ce0782519742703d3386 Author: Mikhail Khludnev <[email protected]> AuthorDate: Sat Jul 25 13:02:12 2026 +0300 SOLR-13764: Introduce IntervalsQParserPlugin (#4582) Co-authored-by: Sonu Sharma @ercsonusharma <[email protected]> (cherry picked from commit 763410f246c5ecdf38474d1d0d573c3aaa46f0a7) --- .gitignore | 1 + .../SOLR-13764-intervals-query-parser.yml | 13 + .../request/json/JsonConsumerQParserPlugin.java | 26 + .../solr/request/json/JsonQueryConverter.java | 29 +- .../org/apache/solr/request/json/RequestUtil.java | 13 +- .../apache/solr/search/IntervalsQParserPlugin.java | 675 +++++++++++++++++++ .../java/org/apache/solr/search/QParserPlugin.java | 1 + .../apache/solr/search/DistributedQParserTest.java | 224 +++++++ .../org/apache/solr/search/QueryEqualityTest.java | 10 + .../solr/search/TestIntervalsQParserPlugin.java | 492 ++++++++++++++ .../query-guide/pages/intervals-query-parser.adoc | 738 +++++++++++++++++++++ .../modules/query-guide/pages/other-parsers.adoc | 9 + .../modules/query-guide/querying-nav.adoc | 1 + .../src/java/org/apache/solr/SolrTestCaseJ4.java | 15 + 14 files changed, 2234 insertions(+), 13 deletions(-) diff --git a/.gitignore b/.gitignore index 73bd851ebbd..9b9fe7a32c8 100644 --- a/.gitignore +++ b/.gitignore @@ -46,6 +46,7 @@ CLAUDE.md GEMINI.md .gemini/ +.vscode/ # WANT TO ADD MORE? You can tell Git without adding to this file: # See https://git-scm.com/docs/gitignore # In particular, if you have tools you use, add to $GIT_DIR/info/exclude or use core.excludesFile diff --git a/changelog/unreleased/SOLR-13764-intervals-query-parser.yml b/changelog/unreleased/SOLR-13764-intervals-query-parser.yml new file mode 100644 index 00000000000..6ffad7c0dd4 --- /dev/null +++ b/changelog/unreleased/SOLR-13764-intervals-query-parser.yml @@ -0,0 +1,13 @@ +# See https://github.com/apache/solr/blob/main/dev-docs/changelog.adoc +title: Add Intervals Query Parser +type: added +authors: + - name: Mikhail Khludnev, + nick: mkhludnev + url: https://home.apache.org/phonebook.html?uid=mkhl + - name: Sonu Sharma + nick: ercsonusharma + url: https://www.linkedin.com/in/ercsonu/ +links: + - name: SOLR-13764 + url: https://issues.apache.org/jira/browse/SOLR-13764 diff --git a/solr/core/src/java/org/apache/solr/request/json/JsonConsumerQParserPlugin.java b/solr/core/src/java/org/apache/solr/request/json/JsonConsumerQParserPlugin.java new file mode 100644 index 00000000000..84d6fb6be71 --- /dev/null +++ b/solr/core/src/java/org/apache/solr/request/json/JsonConsumerQParserPlugin.java @@ -0,0 +1,26 @@ +/* + * 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.request.json; + +import org.apache.solr.search.QParserPlugin; + +/** + * Marker interface for {@link QParserPlugin}s that consume raw JSON query values directly, rather + * than a stringified local params representation. + */ +public interface JsonConsumerQParserPlugin {} diff --git a/solr/core/src/java/org/apache/solr/request/json/JsonQueryConverter.java b/solr/core/src/java/org/apache/solr/request/json/JsonQueryConverter.java index e612f99221a..7435fcc768f 100644 --- a/solr/core/src/java/org/apache/solr/request/json/JsonQueryConverter.java +++ b/solr/core/src/java/org/apache/solr/request/json/JsonQueryConverter.java @@ -20,6 +20,9 @@ package org.apache.solr.request.json; import java.util.List; import java.util.Map; import org.apache.solr.common.SolrException; +import org.apache.solr.common.util.Utils; +import org.apache.solr.request.SolrQueryRequest; +import org.apache.solr.search.QParserPlugin; /** * Convert json query object to local params. @@ -29,10 +32,11 @@ import org.apache.solr.common.SolrException; class JsonQueryConverter { private int numParams = 0; - String toLocalParams(Object jsonQueryObject, Map<String, String[]> additionalParams) { + String toLocalParams( + SolrQueryRequest req, Object jsonQueryObject, Map<String, String[]> additionalParams) { if (jsonQueryObject instanceof String) return jsonQueryObject.toString(); StringBuilder builder = new StringBuilder(); - buildLocalParams(builder, jsonQueryObject, true, additionalParams); + buildLocalParams(req, builder, jsonQueryObject, true, additionalParams); return builder.toString(); } @@ -48,6 +52,7 @@ class JsonQueryConverter { // parameter like 42, or a sub-query) @SuppressWarnings({"unchecked"}) private void buildLocalParams( + SolrQueryRequest req, StringBuilder builder, Object val, boolean isQParser, @@ -104,7 +109,7 @@ class JsonQueryConverter { StringBuilder sb = new StringBuilder(); sb.append("{!tag=").append(tagName).append("}"); sb.append(taggedQueryObject.toString()); - buildLocalParams(builder, sb.toString(), true, additionalParams); + buildLocalParams(req, builder, sb.toString(), true, additionalParams); return; } else if (taggedQueryObject instanceof Map) { map = (Map<String, Object>) taggedQueryObject; @@ -133,9 +138,17 @@ class JsonQueryConverter { if (tagName != null) { subBuilder.append("tag=").append(tagName).append(' '); } - buildLocalParams(subBuilder, subVal, false, additionalParams); - subBuilder.append("}"); - + QParserPlugin plugin = req.getCore().getQueryPlugin(qtype); + if (plugin instanceof JsonConsumerQParserPlugin) { + // hand the nested object to the parser as a JSON string via a dereferenced param + // ordinary param, forwarded to shards, re-parsed there + subBuilder + .append("}") + .append(/*putParam(*/ Utils.toJSONString(subVal, -1) /*, additionalParams)*/); + } else { + buildLocalParams(req, subBuilder, subVal, false, additionalParams); + subBuilder.append("}"); + } if (useSubBuilder) { builder.append('$').append(putParam(subBuilder.toString(), additionalParams)); } @@ -154,7 +167,7 @@ class JsonQueryConverter { } for (Object subVal : l) { builder.append(key).append("="); - buildLocalParams(builder, subVal, true, additionalParams); + buildLocalParams(req, builder, subVal, true, additionalParams); builder.append(" "); } } else { @@ -162,7 +175,7 @@ class JsonQueryConverter { key = "v"; } builder.append(key).append("="); - buildLocalParams(builder, entry.getValue(), true, additionalParams); + buildLocalParams(req, builder, entry.getValue(), true, additionalParams); builder.append(" "); } } diff --git a/solr/core/src/java/org/apache/solr/request/json/RequestUtil.java b/solr/core/src/java/org/apache/solr/request/json/RequestUtil.java index 39bc03dc562..59919f8ad81 100644 --- a/solr/core/src/java/org/apache/solr/request/json/RequestUtil.java +++ b/solr/core/src/java/org/apache/solr/request/json/RequestUtil.java @@ -246,7 +246,7 @@ public class RequestUtil { arr = true; isQuery = true; convertJsonPropertyToLocalParams( - newMap, jsonQueryConverter, queryJsonProperty, out, isQuery, arr); + req, newMap, jsonQueryConverter, queryJsonProperty, out, isQuery, arr); } continue; } else { @@ -262,7 +262,7 @@ public class RequestUtil { SolrException.ErrorCode.BAD_REQUEST, "Unknown top-level key in JSON request : " + key); } - convertJsonPropertyToLocalParams(newMap, jsonQueryConverter, entry, out, isQuery, arr); + convertJsonPropertyToLocalParams(req, newMap, jsonQueryConverter, entry, out, isQuery, arr); } } @@ -272,6 +272,7 @@ public class RequestUtil { } private static void convertJsonPropertyToLocalParams( + SolrQueryRequest req, Map<String, String[]> outMap, JsonQueryConverter jsonQueryConverter, Map.Entry<String, Object> jsonProperty, @@ -293,17 +294,19 @@ public class RequestUtil { for (int i = 0; i < jsonSize; i++) { Object v = lst.get(i); newval[existingSize + i] = - isQuery ? jsonQueryConverter.toLocalParams(v, outMap) : v.toString(); + isQuery ? jsonQueryConverter.toLocalParams(req, v, outMap) : v.toString(); } } else { newval[newval.length - 1] = - isQuery ? jsonQueryConverter.toLocalParams(val, outMap) : val.toString(); + isQuery ? jsonQueryConverter.toLocalParams(req, val, outMap) : val.toString(); } outMap.put(outKey, newval); } else { outMap.put( outKey, - new String[] {isQuery ? jsonQueryConverter.toLocalParams(val, outMap) : val.toString()}); + new String[] { + isQuery ? jsonQueryConverter.toLocalParams(req, val, outMap) : val.toString() + }); } } diff --git a/solr/core/src/java/org/apache/solr/search/IntervalsQParserPlugin.java b/solr/core/src/java/org/apache/solr/search/IntervalsQParserPlugin.java new file mode 100644 index 00000000000..02d696d87b3 --- /dev/null +++ b/solr/core/src/java/org/apache/solr/search/IntervalsQParserPlugin.java @@ -0,0 +1,675 @@ +/* + * 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; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import org.apache.lucene.analysis.Analyzer; +import org.apache.lucene.queries.intervals.IntervalQuery; +import org.apache.lucene.queries.intervals.Intervals; +import org.apache.lucene.queries.intervals.IntervalsSource; +import org.apache.lucene.search.Query; +import org.apache.lucene.util.BytesRef; +import org.apache.solr.common.SolrException; +import org.apache.solr.common.params.CommonParams; +import org.apache.solr.common.params.SolrParams; +import org.apache.solr.common.util.Utils; +import org.apache.solr.request.SolrQueryRequest; +import org.apache.solr.request.json.JsonConsumerQParserPlugin; +import org.apache.solr.schema.FieldType; +import org.apache.solr.schema.SchemaField; +import org.apache.solr.schema.TextField; + +/** + * A query parser that builds interval queries from a JSON DSL description. Invoked with the syntax + * {@code {!intervals }<JSON>}. + */ +public class IntervalsQParserPlugin extends QParserPlugin implements JsonConsumerQParserPlugin { + public static final String NAME = "intervals"; + private static final int DEFAULT_FUZZY_MAX_EXPANSIONS = Intervals.DEFAULT_MAX_EXPANSIONS; + + /** Syntax reminder included in exceptions thrown for malformed/missing input. */ + private static final String SYNTAX_HELP = "Expected syntax '{!intervals}<JSON>'"; + + private static final String ERROR_MSG = + "Specify an intervals field with 'use_field' string property or via local param or a query param"; + + @Override + public QParser createParser( + String qstr, SolrParams localParams, SolrParams params, SolrQueryRequest req) { + return new QParser(qstr, localParams, params, req) { + @Override + public Query parse() { + if (qstr == null || qstr.isEmpty() || qstr.charAt(0) != '{') { + throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, SYNTAX_HELP); + } + Map<String, Object> queryDefMap = + asStringObjectMap(Utils.fromJSONString(qstr), "intervals query"); + Object fieldVal = queryDefMap.remove("use_field"); + String field; + if (fieldVal != null) { + if (fieldVal instanceof String) { + field = (String) fieldVal; + } else { + throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, ERROR_MSG); + } + } else { + field = getParam(CommonParams.DF); + } + if (field == null || field.isEmpty()) { + throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, ERROR_MSG); + } + + SchemaField defaultField = req.getSchema().getField(field); + + IntervalsSource source = parseRuleObject(queryDefMap, defaultField); + return new IntervalQuery(defaultField.getName(), source); + } + + private IntervalsSource parseRuleObject( + Map<String, Object> ruleObject, SchemaField defaultField) { + if (ruleObject.size() != 1) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "Each rule object must contain exactly one rule key, got " + ruleObject.keySet()); + } + Map.Entry<String, Object> entry = ruleObject.entrySet().iterator().next(); + String ruleName = entry.getKey(); + Map<String, Object> ruleParams = + asStringObjectMap(entry.getValue(), "rule '" + ruleName + "'"); + + return switch (ruleName) { + case "match" -> parseMatchRule(ruleParams, defaultField); + case "prefix" -> parsePrefixRule(ruleParams, defaultField); + case "wildcard" -> parseWildcardRule(ruleParams, defaultField); + case "fuzzy" -> parseFuzzyRule(ruleParams, defaultField); + case "all_of" -> parseAllOfRule(ruleParams, defaultField); + case "any_of" -> parseAnyOfRule(ruleParams, defaultField); + case "term" -> parseTermRule(ruleParams, defaultField); + case "phrase" -> parsePhraseRule(ruleParams, defaultField); + case "regexp" -> parseRegexpRule(ruleParams, defaultField); + case "range" -> parseRangeRule(ruleParams, defaultField); + case "max_width" -> parseMaxWidthRule(ruleParams, defaultField); + case "extend" -> parseExtendRule(ruleParams, defaultField); + case "unordered_no_overlaps" -> parseUnorderedNoOverlapsRule(ruleParams, defaultField); + case "not_within" -> parseNotWithinRule(ruleParams, defaultField); + case "within" -> parseWithinRule(ruleParams, defaultField); + case "at_least" -> parseAtLeastRule(ruleParams, defaultField); + case "no_intervals" -> parseNoIntervalsRule(ruleParams); + default -> + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, "Unsupported intervals rule: " + ruleName); + }; + } + + private IntervalsSource parseMatchRule(Map<String, Object> params, SchemaField defaultField) { + String queryText = requireString(params, "query", "match"); + int maxGaps = getInt(params, "max_gaps", -1, "match"); + boolean ordered = getBoolean(params, "ordered", false, "match"); + String useField = getOptionalString(params, "use_field", "match"); + SchemaField analysisField = resolveField(useField, defaultField); + + Analyzer analyzer = resolveAnalyzer(params, analysisField, "match"); + IntervalsSource source; + try { + source = + Intervals.analyzedText( + queryText, analyzer, analysisField.getName(), maxGaps, ordered); + } catch (IOException e) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "Failed to analyze match query text for field '" + analysisField.getName() + "'", + e); + } + if (useField != null) { + source = Intervals.fixField(analysisField.getName(), source); + } + return applyFilter(source, params.get("filter"), defaultField); + } + + private IntervalsSource parsePrefixRule( + Map<String, Object> params, SchemaField defaultField) { + String prefix = requireString(params, "prefix", "prefix"); + String useField = getOptionalString(params, "use_field", "prefix"); + SchemaField field = resolveField(useField, defaultField); + Analyzer analyzer = resolveMultiTermAnalyzer(params, field, "prefix"); + String normalizedPrefix = normalizeMultiTerm(field.getName(), prefix, analyzer); + IntervalsSource source = Intervals.prefix(new BytesRef(normalizedPrefix)); + if (useField != null) { + source = Intervals.fixField(field.getName(), source); + } + return source; + } + + private IntervalsSource parseWildcardRule( + Map<String, Object> params, SchemaField defaultField) { + String pattern = requireString(params, "pattern", "wildcard"); + String useField = getOptionalString(params, "use_field", "wildcard"); + SchemaField field = resolveField(useField, defaultField); + Analyzer analyzer = resolveMultiTermAnalyzer(params, field, "wildcard"); + String normalizedPattern = normalizeMultiTerm(field.getName(), pattern, analyzer); + IntervalsSource source = Intervals.wildcard(new BytesRef(normalizedPattern)); + if (useField != null) { + source = Intervals.fixField(field.getName(), source); + } + return source; + } + + private IntervalsSource parseFuzzyRule(Map<String, Object> params, SchemaField defaultField) { + String term = requireString(params, "term", "fuzzy"); + String useField = getOptionalString(params, "use_field", "fuzzy"); + SchemaField field = resolveField(useField, defaultField); + Analyzer analyzer = resolveMultiTermAnalyzer(params, field, "fuzzy"); + String normalizedTerm = normalizeMultiTerm(field.getName(), term, analyzer); + + String fuzziness = getOptionalString(params, "fuzziness", "fuzzy"); + int maxEdits = resolveFuzziness(fuzziness, normalizedTerm); + int prefixLength = getInt(params, "prefix_length", 0, "fuzzy"); + if (prefixLength < 0) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "Rule 'fuzzy' requires a non-negative integer 'prefix_length'"); + } + boolean transpositions = getBoolean(params, "transpositions", true, "fuzzy"); + + IntervalsSource source = + Intervals.fuzzyTerm( + normalizedTerm, + maxEdits, + prefixLength, + transpositions, + DEFAULT_FUZZY_MAX_EXPANSIONS); + if (useField != null) { + source = Intervals.fixField(field.getName(), source); + } + return source; + } + + private IntervalsSource parseAllOfRule(Map<String, Object> params, SchemaField defaultField) { + List<IntervalsSource> intervals = parseIntervalsArray(params, defaultField, "all_of"); + boolean ordered = getBoolean(params, "ordered", false, "all_of"); + int maxGaps = getInt(params, "max_gaps", -1, "all_of"); + + IntervalsSource source = + ordered + ? Intervals.ordered(intervals.toArray(IntervalsSource[]::new)) + : Intervals.unordered(intervals.toArray(IntervalsSource[]::new)); + if (maxGaps >= 0) { + source = Intervals.maxgaps(maxGaps, source); + } + return applyFilter(source, params.get("filter"), defaultField); + } + + private IntervalsSource parseAnyOfRule(Map<String, Object> params, SchemaField defaultField) { + List<IntervalsSource> intervals = parseIntervalsArray(params, defaultField, "any_of"); + IntervalsSource source = Intervals.or(intervals); + return applyFilter(source, params.get("filter"), defaultField); + } + + private IntervalsSource parseTermRule(Map<String, Object> params, SchemaField defaultField) { + String value = requireString(params, "value", "term"); + String useField = getOptionalString(params, "use_field", "term"); + IntervalsSource source = Intervals.term(value); + if (useField != null) { + source = Intervals.fixField(resolveField(useField, defaultField).getName(), source); + } + return source; + } + + private IntervalsSource parsePhraseRule( + Map<String, Object> params, SchemaField defaultField) { + Object termsObj = params.get("terms"); + Object intervalsObj = params.get("intervals"); + if (termsObj != null && intervalsObj != null) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "Rule 'phrase' cannot specify both 'terms' and 'intervals'"); + } + if (termsObj == null && intervalsObj == null) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "Rule 'phrase' requires either 'terms' (string array) or 'intervals' (rule array)"); + } + if (termsObj != null) { + if (!(termsObj instanceof List<?>)) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "Rule 'phrase' requires 'terms' to be an array of strings"); + } + List<?> rawTerms = (List<?>) termsObj; + String[] terms = new String[rawTerms.size()]; + for (int i = 0; i < rawTerms.size(); i++) { + Object t = rawTerms.get(i); + if (!(t instanceof String)) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "Rule 'phrase' requires all 'terms' elements to be strings, got " + + describeType(t)); + } + terms[i] = (String) t; + } + return Intervals.phrase(terms); + } else { + List<IntervalsSource> intervals = parseIntervalsArray(params, defaultField, "phrase"); + return Intervals.phrase(intervals.toArray(IntervalsSource[]::new)); + } + } + + private IntervalsSource parseRegexpRule( + Map<String, Object> params, SchemaField defaultField) { + String pattern = requireString(params, "pattern", "regexp"); + String useField = getOptionalString(params, "use_field", "regexp"); + int maxExpansions = + getInt(params, "max_expansions", Intervals.DEFAULT_MAX_EXPANSIONS, "regexp"); + if (maxExpansions < 0) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "Rule 'regexp' requires a non-negative integer 'max_expansions', got " + + maxExpansions); + } + IntervalsSource source = Intervals.regexp(new BytesRef(pattern), maxExpansions); + if (useField != null) { + source = Intervals.fixField(resolveField(useField, defaultField).getName(), source); + } + return source; + } + + private IntervalsSource parseRangeRule(Map<String, Object> params, SchemaField defaultField) { + String lowerTermStr = getOptionalString(params, "lower_term", "range"); + String upperTermStr = getOptionalString(params, "upper_term", "range"); + boolean includeLower = getBoolean(params, "include_lower", true, "range"); + boolean includeUpper = getBoolean(params, "include_upper", false, "range"); + int maxExpansions = + getInt(params, "max_expansions", Intervals.DEFAULT_MAX_EXPANSIONS, "range"); + if (maxExpansions < 0) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "Rule 'range' requires a non-negative integer 'max_expansions', got " + + maxExpansions); + } + BytesRef lowerTerm = lowerTermStr == null ? null : new BytesRef(lowerTermStr); + BytesRef upperTerm = upperTermStr == null ? null : new BytesRef(upperTermStr); + return Intervals.range(lowerTerm, upperTerm, includeLower, includeUpper, maxExpansions); + } + + private IntervalsSource parseMaxWidthRule( + Map<String, Object> params, SchemaField defaultField) { + int width = getInt(params, "width", -1, "max_width"); + if (width < 0) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "Rule 'max_width' requires a non-negative integer 'width'"); + } + IntervalsSource source = parseNestedRule(params, "source", "max_width", defaultField); + return Intervals.maxwidth(width, source); + } + + private IntervalsSource parseExtendRule( + Map<String, Object> params, SchemaField defaultField) { + int before = getInt(params, "before", 0, "extend"); + int after = getInt(params, "after", 0, "extend"); + IntervalsSource source = parseNestedRule(params, "source", "extend", defaultField); + return Intervals.extend(source, before, after); + } + + private IntervalsSource parseUnorderedNoOverlapsRule( + Map<String, Object> params, SchemaField defaultField) { + List<IntervalsSource> intervals = + parseIntervalsArray(params, defaultField, "unordered_no_overlaps"); + if (intervals.size() != 2) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "Rule 'unordered_no_overlaps' requires exactly 2 intervals, got " + intervals.size()); + } + return Intervals.unorderedNoOverlaps(intervals.get(0), intervals.get(1)); + } + + private IntervalsSource parseNotWithinRule( + Map<String, Object> params, SchemaField defaultField) { + IntervalsSource source = parseNestedRule(params, "source", "not_within", defaultField); + int positions = getInt(params, "positions", -1, "not_within"); + if (positions < 0) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "Rule 'not_within' requires a non-negative integer 'positions'"); + } + IntervalsSource reference = + parseNestedRule(params, "reference", "not_within", defaultField); + return Intervals.notWithin(source, positions, reference); + } + + private IntervalsSource parseWithinRule( + Map<String, Object> params, SchemaField defaultField) { + IntervalsSource source = parseNestedRule(params, "source", "within", defaultField); + int positions = getInt(params, "positions", -1, "within"); + if (positions < 0) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "Rule 'within' requires a non-negative integer 'positions'"); + } + IntervalsSource reference = parseNestedRule(params, "reference", "within", defaultField); + return Intervals.within(source, positions, reference); + } + + private IntervalsSource parseAtLeastRule( + Map<String, Object> params, SchemaField defaultField) { + int minShouldMatch = getInt(params, "min_should_match", -1, "at_least"); + if (minShouldMatch < 0) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "Rule 'at_least' requires a non-negative integer 'min_should_match'"); + } + List<IntervalsSource> intervals = parseIntervalsArray(params, defaultField, "at_least"); + return Intervals.atLeast(minShouldMatch, intervals.toArray(IntervalsSource[]::new)); + } + + private IntervalsSource parseNoIntervalsRule(Map<String, Object> params) { + String reason = getOptionalString(params, "reason", "no_intervals"); + return Intervals.noIntervals(reason == null ? "no_intervals rule" : reason); + } + + private IntervalsSource parseNestedRule( + Map<String, Object> params, String key, String ruleName, SchemaField defaultField) { + Object nested = params.get(key); + if (nested == null) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "Rule '" + ruleName + "' requires '" + key + "' parameter"); + } + return parseRuleObject( + asStringObjectMap(nested, "'" + key + "' in rule '" + ruleName + "'"), defaultField); + } + + private List<IntervalsSource> parseIntervalsArray( + Map<String, Object> params, SchemaField defaultField, String ruleName) { + Object intervalsObj = params.get("intervals"); + if (!(intervalsObj instanceof List<?>)) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "Rule '" + ruleName + "' requires an 'intervals' array"); + } + List<?> rawIntervals = (List<?>) intervalsObj; + if (rawIntervals.isEmpty()) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "Rule '" + ruleName + "' requires at least one interval rule"); + } + List<IntervalsSource> parsed = new ArrayList<>(rawIntervals.size()); + for (Object intervalObj : rawIntervals) { + parsed.add( + parseRuleObject( + asStringObjectMap(intervalObj, "intervals array element"), defaultField)); + } + return parsed; + } + + private IntervalsSource applyFilter( + IntervalsSource source, Object filterObj, SchemaField defaultField) { + if (filterObj == null) { + return source; + } + Map<String, Object> filterMap = asStringObjectMap(filterObj, "filter"); + if (filterMap.size() != 1) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "Filter must contain exactly one operator, got " + filterMap.keySet()); + } + + Map.Entry<String, Object> entry = filterMap.entrySet().iterator().next(); + String op = entry.getKey(); + if ("script".equals(op)) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, "Filter operator 'script' is not supported"); + } + IntervalsSource other = + parseRuleObject( + asStringObjectMap(entry.getValue(), "filter '" + op + "'"), defaultField); + return switch (op) { + case "after" -> Intervals.after(source, other); + case "before" -> Intervals.before(source, other); + case "contained_by" -> Intervals.containedBy(source, other); + case "containing" -> Intervals.containing(source, other); + case "not_contained_by" -> Intervals.notContainedBy(source, other); + case "not_containing" -> Intervals.notContaining(source, other); + case "not_overlapping" -> Intervals.nonOverlapping(source, other); + case "overlapping" -> Intervals.overlapping(source, other); + default -> + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, "Unsupported filter operator: " + op); + }; + } + + /** + * Resolves the field referenced by an optional {@code use_field} rule parameter, falling back + * to the query's default field when absent. Throws BAD_REQUEST if {@code useField} names a + * field that doesn't exist in the schema. + */ + private SchemaField resolveField(String useField, SchemaField defaultField) { + return useField == null ? defaultField : req.getSchema().getField(useField); + } + + private Analyzer resolveAnalyzer( + Map<String, Object> params, SchemaField field, String ruleName) { + String analyzerName = getOptionalString(params, "analyzer", ruleName); + if (analyzerName == null) { + return field.getType().getQueryAnalyzer(); + } + return resolveFieldType(analyzerName, ruleName).getQueryAnalyzer(); + } + + /** + * Resolves the analyzer to use for normalizing prefix/wildcard/fuzzy term text. Unlike {@link + * #resolveAnalyzer}, this uses the field type's multi-term analyzer rather than its regular + * query analyzer, since the term text here is a single already-tokenized value, not free text + * to be tokenized. + */ + private Analyzer resolveMultiTermAnalyzer( + Map<String, Object> params, SchemaField field, String ruleName) { + String analyzerName = getOptionalString(params, "analyzer", ruleName); + FieldType fieldType = + analyzerName == null ? field.getType() : resolveFieldType(analyzerName, ruleName); + if (fieldType instanceof TextField textField) { + return textField.getMultiTermAnalyzer(); + } + return null; + } + + private FieldType resolveFieldType(String analyzerName, String ruleName) { + FieldType fieldType = req.getSchema().getFieldTypeByName(analyzerName); + if (fieldType == null) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "Unknown analyzer '" + + analyzerName + + "' for rule '" + + ruleName + + "'. In Solr this value must match a field type name."); + } + return fieldType; + } + + private String normalizeMultiTerm(String field, String term, Analyzer analyzer) { + if (analyzer == null) { + return term; + } + BytesRef analyzed = TextField.analyzeMultiTerm(field, term, analyzer); + return analyzed == null ? term : analyzed.utf8ToString(); + } + + private int resolveFuzziness(String fuzziness, String term) { + if (fuzziness == null || "AUTO".equals(fuzziness)) { + return resolveAutoFuzziness(term, 3, 6); + } + if (fuzziness.startsWith("AUTO:")) { + String thresholds = fuzziness.substring("AUTO:".length()); + String[] parts = thresholds.split(","); + if (parts.length != 2) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "Invalid fuzziness value: " + fuzziness + ". Expected AUTO:<low>,<high>"); + } + int low; + int high; + try { + low = Integer.parseInt(parts[0].trim()); + high = Integer.parseInt(parts[1].trim()); + } catch (NumberFormatException e) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "Invalid fuzziness value: " + fuzziness + ". Expected AUTO:<low>,<high>", + e); + } + return resolveAutoFuzziness(term, low, high); + } + try { + int edits = Integer.parseInt(fuzziness); + if (edits < 0 || edits > 2) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "fuzziness must be between 0 and 2, got " + edits); + } + return edits; + } catch (NumberFormatException e) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, "Invalid fuzziness value: " + fuzziness, e); + } + } + + private int resolveAutoFuzziness(String term, int low, int high) { + int length = term.codePointCount(0, term.length()); + if (length < low) { + return 0; + } + if (length < high) { + return 1; + } + return 2; + } + + private Map<String, Object> asStringObjectMap(Object obj, String context) { + if (!(obj instanceof Map<?, ?> mapObj)) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "Expected object for " + context + ", got " + describeType(obj)); + } + List<String> badKeys = new ArrayList<>(); + for (Object key : mapObj.keySet()) { + if (!(key instanceof String)) { + badKeys.add(String.valueOf(key)); + } + } + if (!badKeys.isEmpty()) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "Expected string keys for " + context + ", got keys " + badKeys); + } + @SuppressWarnings("unchecked") + Map<String, Object> casted = (Map<String, Object>) mapObj; + return casted; + } + + private String requireString(Map<String, Object> map, String key, String context) { + Object val = map.get(key); + if (!(val instanceof String)) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "Rule '" + + context + + "' requires string parameter '" + + key + + "', got " + + describeType(val)); + } + return (String) val; + } + + private String getOptionalString(Map<String, Object> map, String key, String context) { + Object val = map.get(key); + if (val == null) { + return null; + } + if (!(val instanceof String)) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "Rule '" + + context + + "' expects string parameter '" + + key + + "', got " + + describeType(val)); + } + return (String) val; + } + + private boolean getBoolean( + Map<String, Object> map, String key, boolean defaultValue, String context) { + Object val = map.get(key); + if (val == null) { + return defaultValue; + } + if (val instanceof Boolean b) { + return b; + } + if (val instanceof String s) { + return Boolean.parseBoolean(s); + } + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "Rule '" + + context + + "' expects boolean parameter '" + + key + + "', got " + + describeType(val)); + } + + private int getInt(Map<String, Object> map, String key, int defaultValue, String context) { + Object val = map.get(key); + if (val == null) { + return defaultValue; + } + if (val instanceof Number n) { + return n.intValue(); + } + if (val instanceof String s) { + try { + return Integer.parseInt(s); + } catch (NumberFormatException e) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "Rule '" + context + "' expects integer parameter '" + key + "', got " + s, + e); + } + } + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "Rule '" + + context + + "' expects integer parameter '" + + key + + "', got " + + describeType(val)); + } + + private String describeType(Object value) { + return value == null ? "null" : value.getClass().getName(); + } + }; + } +} diff --git a/solr/core/src/java/org/apache/solr/search/QParserPlugin.java b/solr/core/src/java/org/apache/solr/search/QParserPlugin.java index 4504a8086e7..e21385592ea 100644 --- a/solr/core/src/java/org/apache/solr/search/QParserPlugin.java +++ b/solr/core/src/java/org/apache/solr/search/QParserPlugin.java @@ -92,6 +92,7 @@ public abstract class QParserPlugin implements NamedListInitializedPlugin { map.put(VectorSimilarityQParserPlugin.NAME, new VectorSimilarityQParserPlugin()); map.put(FuzzyQParserPlugin.NAME, new FuzzyQParserPlugin()); map.put(NumericRangeQParserPlugin.NAME, new NumericRangeQParserPlugin()); + map.put(IntervalsQParserPlugin.NAME, new IntervalsQParserPlugin()); standardPlugins = Collections.unmodifiableMap(map); } diff --git a/solr/core/src/test/org/apache/solr/search/DistributedQParserTest.java b/solr/core/src/test/org/apache/solr/search/DistributedQParserTest.java new file mode 100644 index 00000000000..1b46186a076 --- /dev/null +++ b/solr/core/src/test/org/apache/solr/search/DistributedQParserTest.java @@ -0,0 +1,224 @@ +/* + * 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; + +import java.util.Locale; +import java.util.Map; +import org.apache.solr.client.solrj.request.CollectionAdminRequest; +import org.apache.solr.client.solrj.request.QueryRequest; +import org.apache.solr.client.solrj.request.UpdateRequest; +import org.apache.solr.client.solrj.request.json.DirectJsonQueryRequest; +import org.apache.solr.client.solrj.response.QueryResponse; +import org.apache.solr.cloud.SolrCloudTestCase; +import org.apache.solr.common.SolrException; +import org.apache.solr.common.params.MapSolrParams; +import org.junit.BeforeClass; +import org.junit.Test; + +/** + * Distributed search tests for the standard query parsers: {@code lucene}, {@code dismax}, {@code + * edismax}, and {@code intervals}. + */ +public class DistributedQParserTest extends SolrCloudTestCase { + + private static final String COLLECTION = "distributed-qparser"; + + @BeforeClass + public static void setupCluster() throws Exception { + configureCluster(2).addConfig("conf", configset("cloud-dynamic")).configure(); + + CollectionAdminRequest.createCollection(COLLECTION, "conf", 2, 1) + .process(cluster.getSolrClient()); + cluster.waitForActiveCollection(COLLECTION, 2, 2); + + new UpdateRequest() + .add(sdoc("id", "1", "subject", "quick brown fox")) + .add(sdoc("id", "2", "subject", "lazy brown dog")) + .add(sdoc("id", "3", "subject", "quick red dog")) + .add(sdoc("id", "4", "subject", "slow green cat")) + .commit(cluster.getSolrClient(), COLLECTION); + } + + @Test + public void testLuceneQParser() throws Exception { + QueryResponse response = + new QueryRequest(params("q", "subject:quick", "defType", "lucene", "fl", "id")) + .process(cluster.getSolrClient(), COLLECTION); + assertEquals(2, response.getResults().getNumFound()); + + response = + new QueryRequest(params("q", "subject:brown", "defType", "lucene", "fl", "id")) + .process(cluster.getSolrClient(), COLLECTION); + assertEquals(2, response.getResults().getNumFound()); + } + + @Test + public void testDismaxQParser() throws Exception { + QueryResponse response = + new QueryRequest(params("q", "quick", "defType", "dismax", "qf", "subject", "fl", "id")) + .process(cluster.getSolrClient(), COLLECTION); + assertEquals(2, response.getResults().getNumFound()); + + response = + new QueryRequest(params("q", "brown dog", "defType", "dismax", "qf", "subject", "fl", "id")) + .process(cluster.getSolrClient(), COLLECTION); + assertEquals(3, response.getResults().getNumFound()); + } + + @Test + public void testEdismaxQParser() throws Exception { + QueryResponse response = + new QueryRequest(params("q", "quick", "defType", "edismax", "qf", "subject", "fl", "id")) + .process(cluster.getSolrClient(), COLLECTION); + assertEquals(2, response.getResults().getNumFound()); + + response = + new QueryRequest( + params("q", "brown dog", "defType", "edismax", "qf", "subject", "fl", "id")) + .process(cluster.getSolrClient(), COLLECTION); + assertEquals(3, response.getResults().getNumFound()); + } + + @Test + public void testIntervalsQParser() throws Exception { + // match rule: "quick" appears in docs 1 ("quick brown fox") and 3 ("quick red dog") + QueryResponse response = + new DirectJsonQueryRequest( + "{" + + "'query': {intervals: {use_field: subject, 'match': {'query': 'quick'}}}," + + "'fields': 'id'" + + "}") + .process(cluster.getSolrClient(), COLLECTION); + assertEquals(2, response.getResults().getNumFound()); + + // a distinct match rule: "lazy" appears only in doc 2 ("lazy brown dog") — confirm the + // result differs from the "quick" query above + QueryResponse lazyResponse = + new DirectJsonQueryRequest( + "{" + + "'query': {intervals: {use_field: subject, 'match': {'query': 'lazy'}}}," + + "'fields': 'id'" + + "}") + .process(cluster.getSolrClient(), COLLECTION); + assertEquals(1, lazyResponse.getResults().getNumFound()); + assertNotEquals(response.getResults().getNumFound(), lazyResponse.getResults().getNumFound()); + + // all_of ordered: "quick" then "fox" — only doc 1 ("quick brown fox") matches + response = + new DirectJsonQueryRequest( + "{" + + "'query': {intervals: {use_field: subject, " + + "'all_of': {'ordered': true," + + "'intervals': [{'match': {'query': 'quick'}},{'match': {'query': 'fox'}}]}}},," + + "'fields': 'id'" + + "}") + .process(cluster.getSolrClient(), COLLECTION); + assertEquals(1, response.getResults().getNumFound()); + + // union of two top-level interval queries: "quick" (docs 1, 3) or "lazy" (doc 2) — three + // docs match + response = + new DirectJsonQueryRequest( + "{" + + "'query': {'bool': {'should': [" + + "{intervals: {use_field: subject, 'match': {'query': 'quick'}}}," + + "{intervals: {use_field: subject, 'match': {'query': 'lazy'}}}]}}," + + "'fields': 'id'" + + "}") + .process(cluster.getSolrClient(), COLLECTION); + assertEquals(3, response.getResults().getNumFound()); + + // intersection of two top-level interval queries: "quick" (docs 1, 3) and "brown" + // (docs 1, 2) — only doc 1 has both terms + String op = random().nextBoolean() ? "must" : "should"; + response = + new DirectJsonQueryRequest( + "{" + + "'query': {'bool': {'" + + op + + "': [" + + " {intervals: {use_field: subject, 'match': {'query': 'quick'}}}," + + " {intervals: { 'match': {'query': 'brown'}}}" + + "]}}," + + "'fields': 'id'," + + "params:{df: subject}" + + "}") + .process(cluster.getSolrClient(), COLLECTION); + assertEquals(op.equals("must") ? 1 : 3, response.getResults().getNumFound()); + } + + @Test + public void testIntervalsJsonQParser() throws Exception { + // Pure-JSON style: the interval rule is embedded directly under 'intervals' + // match rule: "quick" appears in docs 1 ("quick brown fox") and 3 ("quick red dog") + QueryResponse response = + new DirectJsonQueryRequest( + "{query:{intervals:{use_field:subject,match:{query:quick}}},fields:id}") + .process(cluster.getSolrClient(), COLLECTION); + assertEquals(2, response.getResults().getNumFound()); + + // all_of ordered: "quick" then "fox" — only doc 1 ("quick brown fox") matches + response = + new DirectJsonQueryRequest( + "{query:{intervals:{use_field:subject,all_of:{ordered:true," + + "intervals:[{match:{query:quick}},{match:{query:fox}}]}}},fields:id}") + .process(cluster.getSolrClient(), COLLECTION); + assertEquals(1, response.getResults().getNumFound()); + + // json.params + response = + new DirectJsonQueryRequest( + "{query:{intervals:{match:{query:quick}}},params:{df:subject},fields:id}") + .process(cluster.getSolrClient(), COLLECTION); + assertEquals(2, response.getResults().getNumFound()); + // classic df + response = + new DirectJsonQueryRequest( + "{query:{intervals:{match:{query:quick}}},fields:id}", + new MapSolrParams(Map.of("df", "subject"))) + .process(cluster.getSolrClient(), COLLECTION); + assertEquals(2, response.getResults().getNumFound()); + // precedence + response = + new DirectJsonQueryRequest( + "{query:{intervals:{match:{query:quick},use_field:subject}},fields:id}", + new MapSolrParams(Map.of("df", "OBject"))) + .process(cluster.getSolrClient(), COLLECTION); + assertEquals(2, response.getResults().getNumFound()); + // sad, passing `null` causes fall back to df, cope with it + response = + new DirectJsonQueryRequest( + "{query:{intervals:{match:{query:quick},use_field:null}},fields:id}", + new MapSolrParams(Map.of("df", "subject"))) + .process(cluster.getSolrClient(), COLLECTION); + assertEquals(2, response.getResults().getNumFound()); + } + + public void testIntervalsJsonQParserException() throws Exception { + SolrException exception = + expectThrows( + SolrException.class, + () -> + new DirectJsonQueryRequest( + String.format( + Locale.ROOT, + "{query:{intervals:{use_field:%s,match:{query:quick}}},fields:id}", + random().nextBoolean() ? "{}" : "\"\"")) + .process(cluster.getSolrClient(), COLLECTION)); + System.out.println(exception); + } +} diff --git a/solr/core/src/test/org/apache/solr/search/QueryEqualityTest.java b/solr/core/src/test/org/apache/solr/search/QueryEqualityTest.java index b8895fe7b18..9b94f2a2542 100644 --- a/solr/core/src/test/org/apache/solr/search/QueryEqualityTest.java +++ b/solr/core/src/test/org/apache/solr/search/QueryEqualityTest.java @@ -175,6 +175,16 @@ public class QueryEqualityTest extends SolrTestCaseJ4 { } } + public void testQueryIntervals() throws Exception { + try (SolrQueryRequest req = req("myField", "foo_s")) { + assertQueryEquals( + IntervalsQParserPlugin.NAME, + req, + "{!intervals df=$myField}{'term':{'value':'asdf'}}", + "{!intervals df=foo_s}{'term':{'value':'asdf'}}"); + } + } + public void testQueryBoost() throws Exception { SolrQueryRequest req = req("df", "foo_s", "myBoost", "sum(3,foo_i)"); try { diff --git a/solr/core/src/test/org/apache/solr/search/TestIntervalsQParserPlugin.java b/solr/core/src/test/org/apache/solr/search/TestIntervalsQParserPlugin.java new file mode 100644 index 00000000000..f63d44921b6 --- /dev/null +++ b/solr/core/src/test/org/apache/solr/search/TestIntervalsQParserPlugin.java @@ -0,0 +1,492 @@ +/* + * 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; + +import org.apache.solr.SolrTestCaseJ4; +import org.apache.solr.common.SolrException; +import org.junit.BeforeClass; +import org.junit.Test; + +/** Tests for {@link IntervalsQParserPlugin}. */ +public class TestIntervalsQParserPlugin extends SolrTestCaseJ4 { + + @BeforeClass + public static void beforeClass() throws Exception { + initCore("solrconfig.xml", "schema11.xml"); + } + + @Test + public void testIntervalsMissingQueryThrows() throws Exception { + assertU(adoc("id", "1", "v_t", "hello world")); + assertU(commit()); + + // Without a rule in the intervals json the parser must explain that a rule key is required + assertQEx( + "intervals qparser without a rule should throw BAD_REQUEST", + "Each rule object must contain exactly one rule key", + req("json", "{query:{intervals:{use_field:v_t}}, fields:id}"), + SolrException.ErrorCode.BAD_REQUEST); + } + + @Test + public void testIntervalsMissingJsonBodyThrows() throws Exception { + assertU(adoc("id", "2", "v_t", "hello world")); + assertU(commit()); + + // With a $name reference but no JSON request body at all + assertQEx( + "intervals qparser without a JSON body should throw BAD_REQUEST", + "Expected syntax '{!intervals}<JSON>'", + req("q", "{!intervals df=v_t}"), + SolrException.ErrorCode.BAD_REQUEST); + } + + @Test + public void testIntervalsMatchRuleMatchesDocument() throws Exception { + assertU(adoc("id", "10", "v_t", "foo bar")); + assertU(adoc("id", "11", "v_t", "baz qux")); + assertU(commit()); + + // field specified via use_field in the intervals JSON body + assertJJQ( + "{ query:{intervals:{use_field:v_t, match:{query:foo}}}," + "fields:id}", + "/response=={'numFound':1,'start':0,'numFoundExact':true,'docs':[{'id':'10'}]}"); + } + + @Test + public void testIntervalsAllOfAnyOfNamedQuery() throws Exception { + assertU(adoc("id", "30", "title_t", "alpha beta gamma delta")); + assertU(adoc("id", "31", "title_t", "alpha beta epsilon delta")); + assertU(adoc("id", "32", "title_t", "alpha zeta gamma delta")); + assertU(commit()); + + assertJJQ( + "{ query:{intervals:{use_field:title_t," + + "all_of:{ordered:true,intervals:[" + + "{match:{query:'alpha beta', max_gaps:0, ordered:true}}," + + "{any_of:{intervals:[" + + "{match:{query:'gamma delta', max_gaps:0, ordered:true}}," + + "{match:{query:'epsilon delta', max_gaps:0, ordered:true}}" + + "]}}" + + "]}}}," + + "fields:id}", + "/response=={'numFound':2,'start':0,'numFoundExact':true, \"docs\":[\n" + + " {\n" + + " \"id\":\"30\"\n" + + " },\n" + + " {\n" + + " \"id\":\"31\"\n" + + " }\n" + + " ]}"); + } + + @Test + public void testIntervalsNoMatchingRule() throws Exception { + assertU(adoc("id", "20", "v_t", "hello world")); + assertU(commit()); + + // Match rule text not present in any document; field via use_field + assertJJQ( + "{ query:{intervals:{use_field:v_t, match:{query:zzznomatch}}}," + "fields:id}", + "/response=={'numFound':0,'start':0,'numFoundExact':true, \"docs\":[ ]}"); + } + + @Test + public void testIntervalsTermRule() throws Exception { + assertU(adoc("id", "40", "v_ws", "trm_apple trm_banana")); + assertU(adoc("id", "41", "v_ws", "trm_banana trm_cherry")); + assertU(commit()); + + assertJJQ( + "{ query:{intervals:{use_field:v_ws, term:{value:trm_apple}}}," + "fields:id}", + "/response=={'numFound':1,'start':0,'numFoundExact':true,'docs':[{'id':'40'}]}"); + } + + @Test + public void testIntervalsPhraseRuleWithTerms() throws Exception { + assertU(adoc("id", "50", "v_ws", "phrA_quick phrA_brown phrA_fox")); + assertU(adoc("id", "51", "v_ws", "phrA_quick phrA_fox phrA_brown")); + assertU(commit()); + + assertJJQ( + "{ query:{intervals:{use_field:v_ws," + + "phrase:{terms:[phrA_quick,phrA_brown,phrA_fox]}}}," + + "fields:id}", + "/response=={'numFound':1,'start':0,'numFoundExact':true,'docs':[{'id':'50'}]}"); + } + + @Test + public void testIntervalsPhraseRuleWithIntervals() throws Exception { + assertU(adoc("id", "52", "v_ws", "phrB_quick phrB_brown phrB_fox")); + assertU(adoc("id", "53", "v_ws", "phrB_quick phrB_fox phrB_brown")); + assertU(commit()); + + assertJJQ( + "{ query:{intervals:{use_field:v_ws," + + "phrase:{intervals:" + + "[{term:{value:phrB_quick}},{term:{value:phrB_brown}},{term:{value:phrB_fox}}]}}}," + + "fields:id}", + "/response=={'numFound':1,'start':0,'numFoundExact':true,'docs':[{'id':'52'}]}"); + + assertQEx( + "phrase rule should reject specifying both 'terms' and 'intervals' with BAD_REQUEST", + "cannot specify both 'terms' and 'intervals'", + req( + "json", + "{query:{intervals:{use_field:v_ws," + + "phrase:{terms:[phrB_quick,phrB_brown],intervals:" + + "[{term:{value:phrB_quick}},{term:{value:phrB_brown}}]}}}," + + "fields:id}"), + SolrException.ErrorCode.BAD_REQUEST); + } + + @Test + public void testIntervalsRegexpRule() throws Exception { + assertU(adoc("id", "60", "v_ws", "rx_cat")); + assertU(adoc("id", "61", "v_ws", "rx_car")); + assertU(adoc("id", "62", "v_ws", "rx_dog")); + assertU(commit()); + + assertJJQ( + "{ query:{intervals:{use_field:v_ws, regexp:{pattern:'rx_ca.*'}}}," + "fields:id}", + "/response=={'numFound':2,'start':0,'numFoundExact':true,\"docs\":[\n" + + " {\n" + + " \"id\":\"60\"\n" + + " },\n" + + " {\n" + + " \"id\":\"61\"\n" + + " }\n" + + " ]}"); + + assertQEx( + "regexp rule should reject a negative max_expansions with BAD_REQUEST", + "max_expansions", + req( + "json", + "{query:{intervals:{use_field:v_ws, regexp:{pattern:'rx_ca.*',max_expansions:-1}}}," + + "fields:id}"), + SolrException.ErrorCode.BAD_REQUEST); + } + + @Test + public void testIntervalsRangeRule() throws Exception { + assertU(adoc("id", "70", "v_ws", "rng_aaaa")); + assertU(adoc("id", "71", "v_ws", "rng_bbbb")); + assertU(adoc("id", "72", "v_ws", "rng_cccc")); + assertU(adoc("id", "73", "v_ws", "rng_dddd")); + assertU(commit()); + + assertJJQ( + "{ query:{intervals:{use_field:v_ws," + + "range:{lower_term:rng_bbbb,upper_term:rng_cccc," + + "include_lower:true,include_upper:true}}}," + + "fields:id}", + "/response=={'numFound':2,'start':0,'numFoundExact':true,'docs':[{'id':'71'},{'id':'72'}]}"); + + assertQEx( + "range rule should reject a negative max_expansions with BAD_REQUEST", + "max_expansions", + req( + "json", + "{query:{intervals:{use_field:v_ws," + + "range:{lower_term:rng_bbbb,upper_term:rng_cccc," + + "include_lower:true,include_upper:true,max_expansions:-1}}}," + + "fields:id}"), + SolrException.ErrorCode.BAD_REQUEST); + } + + @Test + public void testIntervalsFuzzyRule() throws Exception { + assertU(adoc("id", "80", "v_ws", "fzz_cat")); + assertU(adoc("id", "81", "v_ws", "fzz_car")); + assertU(adoc("id", "82", "v_ws", "fzz_dog")); + assertU(commit()); + + assertJJQ( + "{ query:{intervals:{use_field:v_ws, fuzzy:{term:fzz_cat,fuzziness:'1'}}}," + "fields:id}", + "/response=={'numFound':2,'start':0,'numFoundExact':true,'docs':[{'id':'80'},{'id':'81'}]}"); + + assertQEx( + "fuzzy rule should reject a negative prefix_length with BAD_REQUEST", + "prefix_length", + req( + "json", + "{query:{intervals:{use_field:v_ws, fuzzy:{term:fzz_cat,prefix_length:-1}}}," + + "fields:id}"), + SolrException.ErrorCode.BAD_REQUEST); + } + + @Test + public void testIntervalsMaxWidthRule() throws Exception { + assertU(adoc("id", "80", "v_ws", "mwd_alpha mwd_beta mwd_gamma")); + assertU(adoc("id", "81", "v_ws", "mwd_alpha mwd_zeta mwd_gamma")); + assertU(commit()); + + // ordered phrase "mwd_alpha mwd_beta" has width 2 (positions 0..1); doc 81 has no mwd_beta + assertJJQ( + "{ query:{intervals:{use_field:v_ws," + + "max_width:{width:2,source:" + + "{all_of:{ordered:true,intervals:[{term:{value:mwd_alpha}},{term:{value:mwd_beta}}]}}}}}," + + "fields:id}", + "/response=={'numFound':1,'start':0,'numFoundExact':true,'docs':[{'id':'80'}]}"); + } + + @Test + public void testIntervalsExtendRule() throws Exception { + assertU(adoc("id", "90", "v_ws", "ext_one ext_two ext_three ext_four ext_five")); + assertU(adoc("id", "91", "v_ws", "ext_one ext_five")); + assertU(commit()); + + // extend ext_three by before=2 and after=2; doc 90 has ext_three, doc 91 does not + assertJJQ( + "{ query:{intervals:{use_field:v_ws," + + "extend:{source:{term:{value:ext_three}},before:2,after:2}}}," + + "fields:id}", + "/response=={'numFound':1,'start':0,'numFoundExact':true,'docs':[{'id':'90'}]}"); + } + + @Test + public void testIntervalsUnorderedNoOverlapsRule() throws Exception { + assertU(adoc("id", "100", "v_ws", "uno_foo uno_bar")); + assertU(adoc("id", "101", "v_ws", "uno_bar uno_foo")); + assertU(adoc("id", "102", "v_ws", "uno_baz uno_qux")); + assertU(commit()); + + assertJJQ( + "{ query:{intervals:{use_field:v_ws," + + "unordered_no_overlaps:{intervals:" + + "[{term:{value:uno_foo}},{term:{value:uno_bar}}]}}}," + + "fields:id}", + "/response=={'numFound':2,'start':0,'numFoundExact':true,\n" + + " \"docs\":[\n" + + " {\n" + + " \"id\":\"100\"\n" + + " },\n" + + " {\n" + + " \"id\":\"101\"\n" + + " }\n" + + " ]}"); + } + + @Test + public void testIntervalsWithinRule() throws Exception { + assertU(adoc("id", "110", "v_ws", "wth_alpha wth_beta wth_gamma")); + assertU(adoc("id", "111", "v_ws", "wth_alpha wth_zeta wth_eps wth_gamma")); + assertU(commit()); + + // "wth_alpha" within 1 position of "wth_beta": doc 110 matches (adjacent), doc 111 does not + assertJJQ( + "{ query:{intervals:{use_field:v_ws," + + "within:{source:{term:{value:wth_alpha}}," + + "positions:1,reference:{term:{value:wth_beta}}}}}," + + "fields:id}", + "/response=={'numFound':1,'start':0,'numFoundExact':true,'docs':[{'id':'110'}]}"); + } + + @Test + public void testIntervalsNotWithinRule() throws Exception { + assertU(adoc("id", "120", "v_ws", "nwt_alpha nwt_zeta nwt_eps nwt_gamma")); + assertU(adoc("id", "121", "v_ws", "nwt_alpha nwt_beta nwt_gamma")); + assertU(commit()); + + // "nwt_alpha" NOT within 1 position of "nwt_beta": doc 121 has them adjacent (excluded), + // doc 120 has no nwt_beta so nwt_alpha qualifies + assertJJQ( + "{ query:{intervals:{use_field:v_ws," + + "not_within:{source:{term:{value:nwt_alpha}}," + + "positions:1,reference:{term:{value:nwt_beta}}}}}," + + "fields:id}", + "/response=={'numFound':1,'start':0,'numFoundExact':true,'docs':[{'id':'120'}]}"); + } + + @Test + public void testIntervalsAtLeastRule() throws Exception { + assertU(adoc("id", "130", "v_ws", "atl_alpha atl_beta atl_gamma")); + assertU(adoc("id", "131", "v_ws", "atl_alpha atl_gamma")); + assertU(adoc("id", "132", "v_ws", "atl_delta atl_epsilon")); + assertU(commit()); + + // at_least 2 of [atl_alpha, atl_beta, atl_gamma]: doc 130 has all 3, doc 131 has 2, doc 132 has + // none + assertJJQ( + "{ query:{intervals:{use_field:v_ws," + + "at_least:{min_should_match:2,intervals:" + + "[{term:{value:atl_alpha}},{term:{value:atl_beta}},{term:{value:atl_gamma}}]}}}," + + "fields:id}", + "/response=={'numFound':2,'start':0,'numFoundExact':true,\n" + + " \"docs\":[\n" + + " {\n" + + " \"id\":\"130\"\n" + + " },\n" + + " {\n" + + " \"id\":\"131\"\n" + + " }\n" + + " ]}"); + } + + @Test + public void testIntervalsNoIntervalsRule() throws Exception { + assertU(adoc("id", "140", "v_ws", "nio_anything")); + assertU(commit()); + + assertJJQ( + "{ query:{intervals:{use_field:v_ws, no_intervals:{reason:testing}}}," + "fields:id}", + "/response=={'numFound':0,'start':0,'numFoundExact':true, \"docs\":[ ]}"); + } + + @Test + public void testIntervalsDfFallbackFromQueryParam() throws Exception { + assertU(adoc("id", "150", "v_ws", "dfp_alpha dfp_beta")); + assertU(adoc("id", "151", "v_ws", "dfp_gamma dfp_delta")); + assertU(commit()); + + // df supplied as a regular query param (not a local param) should be used as the field when + // use_field is absent in the JSON body + assertJJQ( + "{ query:{intervals:{term:{value:dfp_alpha}}}," + "params:{df:v_ws}," + "fields:id}", + "/response=={'numFound':1,'start':0,'numFoundExact':true,'docs':[{'id':'150'}]}"); + } + + @Test + public void testIntervalsLegacyFieldInJsonQueryThrows() throws Exception { + assertU(adoc("id", "160", "v_ws", "bkc_alpha bkc_beta")); + assertU(commit()); + + // Old {field: rule_object} format is no longer supported: even with a valid use_field, the + // field name is mistaken for an (unsupported) rule name since rule objects must be + // {rule_name: {...}}, not {field_name: {...}}. + assertQEx( + "legacy {field: rule} format should throw BAD_REQUEST for an unrecognized rule name", + "Unsupported intervals rule: v_ws", + req( + "json", + "{query:{intervals:{use_field:v_ws, v_ws:{term:{value:bkc_alpha}}}}," + "fields:id}"), + SolrException.ErrorCode.BAD_REQUEST); + } + + @Test + public void testIntervalsMatchRuleNonexistentDfFieldThrows() throws Exception { + // use_field names a field that doesn't exist in the schema; the match rule resolves its + // analyzer from this field + assertQEx( + "match rule with a nonexistent use_field should throw BAD_REQUEST", + "undefined field", + req( + "json", + "{query:{intervals:{use_field:no_such_field, match:{query:foo}}}," + "fields:id}"), + SolrException.ErrorCode.BAD_REQUEST); + } + + @Test + public void testIntervalsMatchRuleNonexistentUseFieldThrows() throws Exception { + // use_field is valid, but the match query's use_field overrides the resolver field + assertQEx( + "match rule with a nonexistent use_field should throw BAD_REQUEST", + "undefined field", + req( + "json", + "{query:{intervals:{use_field:v_t," + + " match:{query:foo,use_field:no_such_field}}}," + + "fields:id}"), + SolrException.ErrorCode.BAD_REQUEST); + } + + @Test + public void testIntervalsPrefixRuleNonexistentDfFieldThrows() throws Exception { + // prefix/wildcard/fuzzy resolve their multi-term analyzer from the field the same way + assertQEx( + "prefix rule with a nonexistent use_field should throw BAD_REQUEST", + "undefined field", + req( + "json", + "{query:{intervals:{use_field:no_such_field, prefix:{prefix:foo}}}," + "fields:id}"), + SolrException.ErrorCode.BAD_REQUEST); + } + + @Test + public void testIntervalsPrefixRuleNonexistentUseFieldThrows() throws Exception { + assertQEx( + "prefix rule with a nonexistent use_field should throw BAD_REQUEST", + "undefined field", + req( + "json", + "{query:{intervals:{use_field:v_ws," + + " prefix:{prefix:foo,use_field:no_such_field}}}," + + "fields:id}"), + SolrException.ErrorCode.BAD_REQUEST); + } + + @Test + public void testIntervalsUnknownAnalyzerFieldTypeThrows() throws Exception { + // an explicit 'analyzer' value that doesn't match any field type name in the schema + assertQEx( + "match rule with an unknown analyzer field type should throw BAD_REQUEST", + "Unknown analyzer", + req( + "json", + "{query:{intervals:{use_field:v_t," + + " match:{query:foo,analyzer:no_such_field_type}}}," + + "fields:id}"), + SolrException.ErrorCode.BAD_REQUEST); + } + + @Test + public void testIntervalsNestedAlternativeOutperformsXmlSpans() throws Exception { + // v_ws (text_ws) is a plain whitespace-tokenized field with no stemming/synonym/word-delimiter + // filters, so the literal terms used in the raw SpanTerm/term rules below match the indexed + // tokens exactly. + assertU(adoc("id", "170", "v_ws", "cmplorem cmpthe cmpdomain cmpis cmpipsum")); + assertU( + adoc("id", "171", "v_ws", "cmplorem cmpthe cmpdomain cmpname cmpsystem cmpis cmpipsum")); + assertU( + adoc("id", "172", "v_ws", "cmplorem cmpthe cmpdomain cmpblame cmpsystem cmpis cmpipsum")); + assertU(commit()); + + assertQ( + "xmlparser SpanNear with nested SpanOr/SpanNear misses one nested match", + req( + "q", + "{!xmlparser df=v_ws}" + + "<SpanNear slop=\"0\" inOrder=\"true\" fieldName=\"v_ws\">" + + "<SpanTerm fieldName=\"v_ws\">cmpthe</SpanTerm>" + + "<SpanOr>" + + "<SpanTerm fieldName=\"v_ws\">cmpdomain</SpanTerm>" + + "<SpanNear slop=\"0\" inOrder=\"true\">" + + "<SpanTerm fieldName=\"v_ws\">cmpdomain</SpanTerm>" + + "<SpanTerm fieldName=\"v_ws\">cmpname</SpanTerm>" + + "<SpanTerm fieldName=\"v_ws\">cmpsystem</SpanTerm>" + + "</SpanNear>" + + "</SpanOr>" + + "<SpanTerm fieldName=\"v_ws\">cmpis</SpanTerm>" + + "</SpanNear>"), + "//result[@numFound='1']"); + + assertJJQ( + "{ query:{intervals:{use_field:v_ws," + + "all_of:{ordered:true,max_gaps:0,intervals:[" + + "{term:{value:cmpthe}}," + + "{any_of:{intervals:[" + + "{term:{value:cmpdomain}}," + + "{phrase:{terms:[cmpdomain,cmpname,cmpsystem]}}" + + "]}}" + + ",{term:{value:cmpis}}" + + "]}}}," + + "fields:id}", + "/response=={'numFound':2,'start':0,'numFoundExact':true," + + "'docs':[{'id':'170'},{'id':'171'}]}"); + } +} diff --git a/solr/solr-ref-guide/modules/query-guide/pages/intervals-query-parser.adoc b/solr/solr-ref-guide/modules/query-guide/pages/intervals-query-parser.adoc new file mode 100644 index 00000000000..2e9efb0e149 --- /dev/null +++ b/solr/solr-ref-guide/modules/query-guide/pages/intervals-query-parser.adoc @@ -0,0 +1,738 @@ += Intervals Query Parser +// 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. + +The Intervals Query Parser builds Lucene interval queries from a xref:json-request-api.adoc[JSON DSL] description. +Interval queries allow you to express positional constraints such as "these terms must appear within N positions of each other" or "this phrase must appear before that one". +See the {lucene-javadocs}/queries/org/apache/lucene/queries/intervals/package-summary.html[Lucene Intervals package documentation] for a detailed description of the underlying interval machinery. + +== Query Format + +Intervals Query Parser may be invoked with the lagacy syntax + +``` +q={!intervals df=text_field}{"phrase":{"terms":["quick","brown","fox"]}}&... +``` + +In the JSON Request API the interval rule may be embedded directly under an `intervals` key in JSON Query DSL. + +[source,json] +---- +{ + "query": { + "intervals": { + "use_field": "another_text_field", + "phrase": { + "terms": ["quick", "brown", "fox"] + } + } + } +} +---- + +The target field is specified via a top-level `use_field` property or via the `df` local or query param as a fallback. + +== Parameters + +`df`:: ++ +[%autowidth,frame=none] +|=== +|Optional |Default: none +|=== ++ +A fallback for the field to run the intervals query against. +May be specified as a local param inside `{! }` or as a regular query param. + +== Interval Rules + +Each rule object contains a key that identifies the rule type, mapped to an object of rule parameters. The root rule may also include a top-level `use_field` property as described above. + +=== `match` + +Analyzes query text and matches documents where the resulting tokens appear according to positional constraints. + +[%autowidth,frame=none] +|=== +|Parameter |Required |Default |Description + +|`query` +|Yes +|— +|The text to analyze and match. + +|`max_gaps` +|No +|`-1` (unlimited) +|Maximum number of gaps (positions) between matched terms. + +|`ordered` +|No +|`false` +|When `true`, terms must appear in the order given. + +|`use_field` +|No +|— +|Analyze using a different field's analyzer and match against that field. + +|`analyzer` +|No +|— +|Name of a field type to use as the analyzer (overrides field default). + +|`filter` +|No +|— +|A <<filter-operators,filter operator>> to apply after matching. +|=== + +Example: + +[source,json] +---- +{ "match": { "query": "apache solr", "max_gaps": 1, "ordered": true } } +---- + +=== `term` + +Matches a single exact term without analysis. + +[%autowidth,frame=none] +|=== +|Parameter |Required |Default |Description + +|`value` +|Yes +|— +|The exact term to match. + +|`use_field` +|No +|— +|Match against this field instead of the query field. +|=== + +Example: + +[source,json] +---- +{ "term": { "value": "solr" } } +---- + +=== `phrase` + +Matches a sequence of terms or interval rules in order with no gaps. + +Accepts either a `terms` array of strings (for a simple phrase) or an `intervals` array of rule objects (to combine other rules into a phrase). + +[%autowidth,frame=none] +|=== +|Parameter |Required |Default |Description + +|`terms` +|One of `terms` / `intervals` required +|— +|Array of exact string terms forming the phrase. + +|`intervals` +|One of `terms` / `intervals` required +|— +|Array of rule objects that must match in sequence. +|=== + +Example using strings: + +[source,json] +---- +{ "phrase": { "terms": ["apache", "solr"] } } +---- + +Example using nested rules: + +[source,json] +---- +{ "phrase": { "intervals": [ { "term": { "value": "apache" } }, { "term": { "value": "solr" } } ] } } +---- + +=== `prefix` + +Matches all terms starting with a given prefix. + +[%autowidth,frame=none] +|=== +|Parameter |Required |Default |Description + +|`prefix` +|Yes +|— +|The prefix string. Normalized using the field's multi-term analyzer. + +|`use_field` +|No +|— +|Operate on this field instead. + +|`analyzer` +|No +|— +|Field type name to use for normalization. +|=== + +Example: + +[source,json] +---- +{ "prefix": { "prefix": "sol" } } +---- + +=== `wildcard` + +Matches terms using a wildcard pattern (`*` and `?`). + +[%autowidth,frame=none] +|=== +|Parameter |Required |Default |Description + +|`pattern` +|Yes +|— +|The wildcard pattern. Normalized using the field's multi-term analyzer. + +|`use_field` +|No +|— +|Operate on this field instead. + +|`analyzer` +|No +|— +|Field type name to use for normalization. +|=== + +Example: + +[source,json] +---- +{ "wildcard": { "pattern": "sol*" } } +---- + +=== `regexp` + +Matches terms using a regular expression. + +[%autowidth,frame=none] +|=== +|Parameter |Required |Default |Description + +|`pattern` +|Yes +|— +|The regular expression pattern. + +|`max_expansions` +|No +|`128` +|Maximum number of terms to expand the regexp to. + +|`use_field` +|No +|— +|Operate on this field instead. +|=== + +Example: + +[source,json] +---- +{ "regexp": { "pattern": "sol.r" } } +---- + +=== `fuzzy` + +Matches terms within a given edit distance of the supplied term. + +[%autowidth,frame=none] +|=== +|Parameter |Required |Default |Description + +|`term` +|Yes +|— +|The term to match fuzzily. Normalized using the field's multi-term analyzer. + +|`fuzziness` +|No +|`AUTO` +|Edit distance: `0`, `1`, `2`, `AUTO`, or `AUTO:<low>,<high>`. + +|`prefix_length` +|No +|`0` +|Number of leading characters that must match exactly. + +|`transpositions` +|No +|`true` +|Whether transpositions count as a single edit. + +|`use_field` +|No +|— +|Operate on this field instead. + +|`analyzer` +|No +|— +|Field type name to use for normalization. +|=== + +Example: + +[source,json] +---- +{ "fuzzy": { "term": "solr", "fuzziness": "AUTO" } } +---- + +=== `range` + +Matches terms within a lexicographic range. + +[%autowidth,frame=none] +|=== +|Parameter |Required |Default |Description + +|`lower_term` +|No +|— +|Lower bound of the range (inclusive by default). + +|`upper_term` +|No +|— +|Upper bound of the range (exclusive by default). + +|`include_lower` +|No +|`true` +|Whether to include the lower bound. + +|`include_upper` +|No +|`false` +|Whether to include the upper bound. + +|`max_expansions` +|No +|`128` +|Maximum number of terms to expand to. +|=== + +Example: + +[source,json] +---- +{ "range": { "lower_term": "aaa", "upper_term": "zzz" } } +---- + +== Combining Rules + +=== `all_of` + +Matches documents where all supplied intervals match in the same field, optionally ordered and within a maximum gap. + +[%autowidth,frame=none] +|=== +|Parameter |Required |Default |Description + +|`intervals` +|Yes +|— +|Array of rule objects that must all match. + +|`ordered` +|No +|`false` +|When `true`, intervals must match in the order listed. + +|`max_gaps` +|No +|`-1` (unlimited) +|Maximum gap between matched intervals. + +|`filter` +|No +|— +|A <<filter-operators,filter operator>> to apply after matching. +|=== + +Example — ordered phrase with a gap: + +[source,json] +---- +{ + "all_of": { + "ordered": true, + "max_gaps": 2, + "intervals": [ + { "match": { "query": "apache solr" } }, + { "term": { "value": "search" } } + ] + } +} +---- + +=== `any_of` + +Matches documents where at least one of the supplied intervals matches. + +[%autowidth,frame=none] +|=== +|Parameter |Required |Default |Description + +|`intervals` +|Yes +|— +|Array of rule objects, any of which may match. + +|`filter` +|No +|— +|A <<filter-operators,filter operator>> to apply after matching. +|=== + +Example: + +[source,json] +---- +{ + "any_of": { + "intervals": [ + { "term": { "value": "apache" } }, + { "term": { "value": "lucene" } } + ] + } +} +---- + +=== `at_least` + +Matches documents where at least `min_should_match` of the supplied intervals match. + +[%autowidth,frame=none] +|=== +|Parameter |Required |Default |Description + +|`min_should_match` +|Yes +|— +|Minimum number of interval rules that must match. + +|`intervals` +|Yes +|— +|Array of rule objects. +|=== + +Example: + +[source,json] +---- +{ + "at_least": { + "min_should_match": 2, + "intervals": [ + { "term": { "value": "apache" } }, + { "term": { "value": "solr" } }, + { "term": { "value": "search" } } + ] + } +} +---- + +== Positional Constraints + +=== `max_width` + +Restricts a source interval to span at most `width` positions. + +[%autowidth,frame=none] +|=== +|Parameter |Required |Default |Description + +|`width` +|Yes +|— +|Maximum allowed span width in positions. + +|`source` +|Yes +|— +|A rule object to constrain. +|=== + +Example: + +[source,json] +---- +{ + "max_width": { + "width": 5, + "source": { "match": { "query": "apache solr" } } + } +} +---- + +=== `extend` + +Extends an interval by adding extra positions before and/or after it. + +[%autowidth,frame=none] +|=== +|Parameter |Required |Default |Description + +|`before` +|No +|`0` +|Number of positions to extend before the interval. + +|`after` +|No +|`0` +|Number of positions to extend after the interval. + +|`source` +|Yes +|— +|A rule object to extend. +|=== + +Example: + +[source,json] +---- +{ + "extend": { + "before": 1, + "after": 1, + "source": { "term": { "value": "solr" } } + } +} +---- + +=== `within` + +Matches documents where a `source` interval occurs within `positions` of a `reference` interval. + +[%autowidth,frame=none] +|=== +|Parameter |Required |Default |Description + +|`positions` +|Yes +|— +|Maximum number of positions separating the two intervals. + +|`source` +|Yes +|— +|The interval that must be near the reference. + +|`reference` +|Yes +|— +|The reference interval. +|=== + +Example: + +[source,json] +---- +{ + "within": { + "positions": 3, + "source": { "term": { "value": "solr" } }, + "reference": { "term": { "value": "apache" } } + } +} +---- + +=== `not_within` + +Matches documents where a `source` interval does *not* occur within `positions` of a `reference` interval. + +[%autowidth,frame=none] +|=== +|Parameter |Required |Default |Description + +|`positions` +|Yes +|— +|Exclusion distance in positions. + +|`source` +|Yes +|— +|The interval that must not be near the reference. + +|`reference` +|Yes +|— +|The reference interval. +|=== + +Example: + +[source,json] +---- +{ + "not_within": { + "positions": 5, + "source": { "term": { "value": "slow" } }, + "reference": { "term": { "value": "solr" } } + } +} +---- + +=== `unordered_no_overlaps` + +Matches documents where two intervals appear in either order without overlapping. +Exactly two intervals must be supplied. + +[%autowidth,frame=none] +|=== +|Parameter |Required |Default |Description + +|`intervals` +|Yes +|— +|Array of exactly two rule objects. +|=== + +Example: + +[source,json] +---- +{ + "unordered_no_overlaps": { + "intervals": [ + { "term": { "value": "apache" } }, + { "term": { "value": "solr" } } + ] + } +} +---- + +=== `no_intervals` + +Always produces no intervals (matches no documents for the given rule). +Useful as a placeholder or for testing. + +[%autowidth,frame=none] +|=== +|Parameter |Required |Default |Description + +|`reason` +|No +|`"no_intervals rule"` +|An optional message explaining why this rule produces no intervals. +|=== + +Example: + +[source,json] +---- +{ "no_intervals": { "reason": "disabled for testing" } } +---- + +== Filter Operators + +The `match`, `all_of`, and `any_of` rules accept an optional `filter` parameter. +A filter restricts matches based on the positional relationship between the source interval and a second (filter) interval. + +The `filter` object contains exactly one of the following operators as its key, mapped to a nested rule object: + +[%autowidth,frame=none] +|=== +|Operator |Description + +|`after` +|Source must appear after the filter interval. + +|`before` +|Source must appear before the filter interval. + +|`contained_by` +|Source must be contained within the filter interval. + +|`containing` +|Source must contain the filter interval. + +|`not_contained_by` +|Source must not be contained within the filter interval. + +|`not_containing` +|Source must not contain the filter interval. + +|`not_overlapping` +|Source must not overlap the filter interval. + +|`overlapping` +|Source must overlap the filter interval. +|=== + +Example — match "solr" only when it appears after "apache": + +[source,json] +---- +{ + "match": { + "query": "solr", + "filter": { + "after": { "term": { "value": "apache" } } + } + } +} +---- + +== Full Example + +Find documents where the `title` field contains the phrase "apache solr" followed within 5 positions by the word "search": + +[source,text] +---- +q={!intervals df=title}$titleQuery +---- + +[source,text] +---- +json={ + "query": { + "intervals": { + "use_field":"title", + "all_of": { + "ordered": true, + "max_gaps": 5, + "intervals": [ + { "match": { "query": "apache solr", "max_gaps": 0, "ordered": true } }, + { "term": { "value": "search" } } + ] + } + } + } +} +---- diff --git a/solr/solr-ref-guide/modules/query-guide/pages/other-parsers.adoc b/solr/solr-ref-guide/modules/query-guide/pages/other-parsers.adoc index 7a8b0bd0918..f1966f0b767 100644 --- a/solr/solr-ref-guide/modules/query-guide/pages/other-parsers.adoc +++ b/solr/solr-ref-guide/modules/query-guide/pages/other-parsers.adoc @@ -717,6 +717,15 @@ Note the name of the cache should be the field name prefixed by "`hash_`". ---- +== Intervals Query Parser + +The `IntervalsQParserPlugin` builds Lucene interval queries from a JSON DSL description. +Interval queries express positional constraints between terms, such as requiring that terms appear within a specified number of positions of each other or in a particular order. + +Invoked with the syntax `{!intervals df=<field>}{"phrase":{"terms":["hash","map"]}}`, + +Details of this query parser are in the section xref:intervals-query-parser.adoc[]. + == Join Query Parser The Join Query Parser allows users to run queries that normalize relationships between documents, similar to SQL-style joins. diff --git a/solr/solr-ref-guide/modules/query-guide/querying-nav.adoc b/solr/solr-ref-guide/modules/query-guide/querying-nav.adoc index 9eb858d4818..dab5a2eeabb 100644 --- a/solr/solr-ref-guide/modules/query-guide/querying-nav.adoc +++ b/solr/solr-ref-guide/modules/query-guide/querying-nav.adoc @@ -34,6 +34,7 @@ ** xref:spatial-search.adoc[] ** xref:dense-vector-search.adoc[] ** xref:other-parsers.adoc[] +*** xref:intervals-query-parser.adoc[] ** xref:sql-query.adoc[] *** xref:jdbc-dbvisualizer.adoc[] *** xref:jdbc-squirrel.adoc[] diff --git a/solr/test-framework/src/java/org/apache/solr/SolrTestCaseJ4.java b/solr/test-framework/src/java/org/apache/solr/SolrTestCaseJ4.java index a608a18b09c..73ce681aad7 100644 --- a/solr/test-framework/src/java/org/apache/solr/SolrTestCaseJ4.java +++ b/solr/test-framework/src/java/org/apache/solr/SolrTestCaseJ4.java @@ -913,6 +913,21 @@ public abstract class SolrTestCaseJ4 extends SolrTestCase { return response; } + /** + * Validates a JSON DSL request matches the given JSON test expressions + * + * @param jsonBody The JSON DSL request body to send to Solr + * @param tests JSON path expression + '==' + expected value + * @see JSONTestUtil#DEFAULT_DELTA + * @see #assertJQ(SolrQueryRequest,double,String...) + * @return The request response as a JSON String if all test patterns pass + */ + public static String assertJJQ(String jsonBody, String... tests) throws Exception { + SolrQueryRequestBase jreq = (SolrQueryRequestBase) req(); + jreq.setContentStreams(List.of(new ContentStreamBase.StringStream(jsonBody))); + return assertJQ(jreq, JSONTestUtil.DEFAULT_DELTA, tests); + } + /** * Validates a query matches some JSON test expressions using the default double delta tolerance. *
