From 3a52030f2f66779fd5077bf202b40d75e0481333 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 30 Jun 2026 21:01:22 +0000 Subject: [PATCH 01/43] Introduce json_queries key in JSON DSL, pass-through to req.getJSON() --- .../java/org/apache/solr/request/json/RequestUtil.java | 4 ++++ .../org/apache/solr/search/json/TestJsonRequest.java | 10 ++++++++++ 2 files changed, 14 insertions(+) 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 39bc03dc5623..2302f102fe56 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 @@ -254,6 +254,10 @@ public static void processParams( SolrException.ErrorCode.BAD_REQUEST, "Expected Map for 'queries', received " + queriesJsonObj); } + } else if ("json_queries".equals(key)) { + // passed through as a parsed object for use by SearchComponent.prepare() at subordinate + // nodes; not processed here + continue; } else if ("params".equals(key) || "facet".equals(key)) { // handled elsewhere continue; diff --git a/solr/core/src/test/org/apache/solr/search/json/TestJsonRequest.java b/solr/core/src/test/org/apache/solr/search/json/TestJsonRequest.java index 628dd73766cf..73653cfc401d 100644 --- a/solr/core/src/test/org/apache/solr/search/json/TestJsonRequest.java +++ b/solr/core/src/test/org/apache/solr/search/json/TestJsonRequest.java @@ -357,6 +357,16 @@ public static void doJsonRequest(Client client, boolean isDistrib) throws Except params("json", "{query:'cat_s:A'}", "json.filter", "'where_s:NY'", "debug", "true"), "debug/json=={query:'cat_s:A', filter:'where_s:NY'}"); + // test json_queries: accepted as a known key and preserved as a parsed object in req.getJSON() + client.testJQ( + params( + "json", + "{query:'cat_s:A', json_queries:{myQuery:{lucene:{query:'where_s:NY'}}}}", + "debug", + "true"), + "response/numFound==2", + "debug/json/json_queries=={myQuery:{lucene:{query:'where_s:NY'}}}"); + // test query dsl client.testJQ(params("json", "{'query':'{!lucene}id:1'}"), "response/numFound==1"); From d13108ed8cb96ef5f62b8a122fda244fe00ee166 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 30 Jun 2026 21:15:57 +0000 Subject: [PATCH 02/43] Introduce IntervalsQParserPlugin ({!intervals json_query=...}), returns MatchNoDocsQuery --- .../solr/search/IntervalsQParserPlugin.java | 49 +++++++++++++++++++ .../org/apache/solr/search/QParserPlugin.java | 1 + .../search/TestIntervalsQParserPlugin.java | 42 ++++++++++++++++ 3 files changed, 92 insertions(+) create mode 100644 solr/core/src/java/org/apache/solr/search/IntervalsQParserPlugin.java create mode 100644 solr/core/src/test/org/apache/solr/search/TestIntervalsQParserPlugin.java 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 000000000000..9116c6b0f724 --- /dev/null +++ b/solr/core/src/java/org/apache/solr/search/IntervalsQParserPlugin.java @@ -0,0 +1,49 @@ +/* + * 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.lucene.search.MatchNoDocsQuery; +import org.apache.lucene.search.Query; +import org.apache.solr.common.params.SolrParams; +import org.apache.solr.request.SolrQueryRequest; + +/** + * A query parser that will eventually build interval queries from a JSON DSL description. Invoked + * with the syntax {@code {!intervals json_query=foobar}}. + * + *

The {@code json_query} local param names an entry in the {@code json_queries} map (passed via + * the JSON DSL) that describes the intervals to match. Processing of that map is not yet + * implemented; this parser currently always returns {@link MatchNoDocsQuery}. + */ +public class IntervalsQParserPlugin extends QParserPlugin { + public static final String NAME = "intervals"; + + /** Local param that names the entry in {@code json_queries} to use. */ + public static final String JSON_QUERY_PARAM = "json_query"; + + @Override + public QParser createParser( + String qstr, SolrParams localParams, SolrParams params, SolrQueryRequest req) { + return new QParser(qstr, localParams, params, req) { + @Override + public Query parse() { + // json_query names the json_queries entry to process – not yet implemented + return new MatchNoDocsQuery(); + } + }; + } +} 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 4504a8086e79..e21385592ea0 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/TestIntervalsQParserPlugin.java b/solr/core/src/test/org/apache/solr/search/TestIntervalsQParserPlugin.java new file mode 100644 index 000000000000..212746757bca --- /dev/null +++ b/solr/core/src/test/org/apache/solr/search/TestIntervalsQParserPlugin.java @@ -0,0 +1,42 @@ +/* + * 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.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 testIntervalsReturnsNoResults() throws Exception { + assertU(adoc("id", "1", "v_t", "hello world")); + assertU(commit()); + + // The parser always returns MatchNoDocsQuery for now, so numFound must be 0 + assertQ( + "intervals qparser should return no docs", + req("q", "{!intervals json_query=myQuery}"), + "//result[@numFound='0']"); + } +} From de1cf821916e66d3acbb5a16716d715c2d174b55 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 30 Jun 2026 21:18:01 +0000 Subject: [PATCH 03/43] Improve TestIntervalsQParserPlugin: add json_queries pass-through test --- .../solr/search/TestIntervalsQParserPlugin.java | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/solr/core/src/test/org/apache/solr/search/TestIntervalsQParserPlugin.java b/solr/core/src/test/org/apache/solr/search/TestIntervalsQParserPlugin.java index 212746757bca..64a32adf99a0 100644 --- a/solr/core/src/test/org/apache/solr/search/TestIntervalsQParserPlugin.java +++ b/solr/core/src/test/org/apache/solr/search/TestIntervalsQParserPlugin.java @@ -39,4 +39,21 @@ public void testIntervalsReturnsNoResults() throws Exception { req("q", "{!intervals json_query=myQuery}"), "//result[@numFound='0']"); } + + @Test + public void testIntervalsWithJsonQueriesPassThrough() throws Exception { + assertU(adoc("id", "2", "v_t", "foo bar")); + assertU(commit()); + + // json_queries is accepted as a top-level JSON DSL key and the json_query param names an entry; + // the parser still returns MatchNoDocsQuery (not yet implemented), so numFound must be 0 + assertQ( + "intervals qparser with json_queries should return no docs", + req( + "q", + "{!intervals json_query=myQuery}", + "json", + "{json_queries:{myQuery:{term:{f:v_t,value:foo}}}}"), + "//result[@numFound='0']"); + } } From 5e892d5fa26917915d0ec0cb1f9503604efee037 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 30 Jun 2026 21:34:11 +0000 Subject: [PATCH 04/43] Implement IntervalsQParserPlugin term interval query from JSON DSL --- .../solr/search/IntervalsQParserPlugin.java | 65 +++++++++++++++++-- .../search/TestIntervalsQParserPlugin.java | 33 +++++++--- 2 files changed, 82 insertions(+), 16 deletions(-) diff --git a/solr/core/src/java/org/apache/solr/search/IntervalsQParserPlugin.java b/solr/core/src/java/org/apache/solr/search/IntervalsQParserPlugin.java index 9116c6b0f724..4f9d6b3689db 100644 --- a/solr/core/src/java/org/apache/solr/search/IntervalsQParserPlugin.java +++ b/solr/core/src/java/org/apache/solr/search/IntervalsQParserPlugin.java @@ -16,18 +16,23 @@ */ package org.apache.solr.search; +import java.util.Map; +import org.apache.lucene.queries.intervals.IntervalQuery; +import org.apache.lucene.queries.intervals.Intervals; import org.apache.lucene.search.MatchNoDocsQuery; import org.apache.lucene.search.Query; +import org.apache.solr.common.SolrException; import org.apache.solr.common.params.SolrParams; import org.apache.solr.request.SolrQueryRequest; /** - * A query parser that will eventually build interval queries from a JSON DSL description. Invoked - * with the syntax {@code {!intervals json_query=foobar}}. + * A query parser that builds interval queries from a JSON DSL description. Invoked with the syntax + * {@code {!intervals json_query=foobar}}. * *

The {@code json_query} local param names an entry in the {@code json_queries} map (passed via - * the JSON DSL) that describes the intervals to match. Processing of that map is not yet - * implemented; this parser currently always returns {@link MatchNoDocsQuery}. + * the JSON DSL) that describes the intervals to match. The simplest supported form is a single + * field-to-term mapping, e.g. {@code {field_name: "term_value"}}, which produces {@code new + * IntervalQuery(field_name, Intervals.term(term_value))}. */ public class IntervalsQParserPlugin extends QParserPlugin { public static final String NAME = "intervals"; @@ -41,8 +46,56 @@ public QParser createParser( return new QParser(qstr, localParams, params, req) { @Override public Query parse() { - // json_query names the json_queries entry to process – not yet implemented - return new MatchNoDocsQuery(); + String jsonQueryName = localParams.get(JSON_QUERY_PARAM); + if (jsonQueryName == null) { + return new MatchNoDocsQuery("No " + JSON_QUERY_PARAM + " parameter specified"); + } + + Map json = req.getJSON(); + if (json == null) { + return new MatchNoDocsQuery("No JSON parameters found"); + } + + Object jsonQueriesObj = json.get("json_queries"); + if (!(jsonQueriesObj instanceof Map)) { + return new MatchNoDocsQuery("No json_queries map found in JSON parameters"); + } + + @SuppressWarnings("unchecked") + Map jsonQueries = (Map) jsonQueriesObj; + Object queryDef = jsonQueries.get(jsonQueryName); + + if (!(queryDef instanceof Map)) { + return new MatchNoDocsQuery( + "Query '" + jsonQueryName + "' not found in json_queries or is not a map"); + } + + @SuppressWarnings("unchecked") + Map queryDefMap = (Map) queryDef; + + if (queryDefMap.size() != 1) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "Expected exactly one {field: term} entry in json_query '" + + jsonQueryName + + "', got " + + queryDefMap.size()); + } + + Map.Entry entry = queryDefMap.entrySet().iterator().next(); + String field = entry.getKey(); + Object termValue = entry.getValue(); + + if (!(termValue instanceof String)) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "Expected a string term value in json_query '" + + jsonQueryName + + "', got " + + (termValue == null ? "null" : termValue.getClass().getName())); + } + + return new IntervalQuery(field, Intervals.term((String) termValue)); } }; } diff --git a/solr/core/src/test/org/apache/solr/search/TestIntervalsQParserPlugin.java b/solr/core/src/test/org/apache/solr/search/TestIntervalsQParserPlugin.java index 64a32adf99a0..1ce3e926a7f4 100644 --- a/solr/core/src/test/org/apache/solr/search/TestIntervalsQParserPlugin.java +++ b/solr/core/src/test/org/apache/solr/search/TestIntervalsQParserPlugin.java @@ -29,31 +29,44 @@ public static void beforeClass() throws Exception { } @Test - public void testIntervalsReturnsNoResults() throws Exception { + public void testIntervalsNoJsonQueryParam() throws Exception { assertU(adoc("id", "1", "v_t", "hello world")); assertU(commit()); - // The parser always returns MatchNoDocsQuery for now, so numFound must be 0 + // Without a json_query param the parser returns MatchNoDocsQuery assertQ( - "intervals qparser should return no docs", - req("q", "{!intervals json_query=myQuery}"), + "intervals qparser without json_query should return no docs", + req("q", "{!intervals}"), "//result[@numFound='0']"); } @Test - public void testIntervalsWithJsonQueriesPassThrough() throws Exception { - assertU(adoc("id", "2", "v_t", "foo bar")); + public void testIntervalsTermMatchesDocument() throws Exception { + assertU(adoc("id", "10", "v_t", "foo bar")); + assertU(adoc("id", "11", "v_t", "baz qux")); assertU(commit()); - // json_queries is accepted as a top-level JSON DSL key and the json_query param names an entry; - // the parser still returns MatchNoDocsQuery (not yet implemented), so numFound must be 0 + // {v_t: "foo"} produces IntervalQuery("v_t", Intervals.term("foo")) assertQ( - "intervals qparser with json_queries should return no docs", + "intervals qparser with {field:term} should match documents containing the term", + req("q", "{!intervals json_query=myQuery}", "json", "{json_queries:{myQuery:{v_t:foo}}}"), + "//result[@numFound='1']", + "//doc/str[@name='id'][.='10']"); + } + + @Test + public void testIntervalsNoMatchingTerm() throws Exception { + assertU(adoc("id", "20", "v_t", "hello world")); + assertU(commit()); + + // Term not present in any document + assertQ( + "intervals qparser with non-matching term should return no docs", req( "q", "{!intervals json_query=myQuery}", "json", - "{json_queries:{myQuery:{term:{f:v_t,value:foo}}}}"), + "{json_queries:{myQuery:{v_t:zzznomatch}}}"), "//result[@numFound='0']"); } } From 643c95e792d160e7806db1713dd5ecc8ecf8ace8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 30 Jun 2026 22:08:21 +0000 Subject: [PATCH 05/43] Implement OpenSearch-style intervals JSON rule parser --- .../solr/search/IntervalsQParserPlugin.java | 385 +++++++++++++++++- .../search/TestIntervalsQParserPlugin.java | 52 ++- 2 files changed, 416 insertions(+), 21 deletions(-) diff --git a/solr/core/src/java/org/apache/solr/search/IntervalsQParserPlugin.java b/solr/core/src/java/org/apache/solr/search/IntervalsQParserPlugin.java index 4f9d6b3689db..eb5d430e3fb6 100644 --- a/solr/core/src/java/org/apache/solr/search/IntervalsQParserPlugin.java +++ b/solr/core/src/java/org/apache/solr/search/IntervalsQParserPlugin.java @@ -16,23 +16,29 @@ */ 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.MatchNoDocsQuery; import org.apache.lucene.search.Query; import org.apache.solr.common.SolrException; import org.apache.solr.common.params.SolrParams; import org.apache.solr.request.SolrQueryRequest; +import org.apache.solr.schema.FieldType; +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_query=foobar}}. * *

The {@code json_query} local param names an entry in the {@code json_queries} map (passed via - * the JSON DSL) that describes the intervals to match. The simplest supported form is a single - * field-to-term mapping, e.g. {@code {field_name: "term_value"}}, which produces {@code new - * IntervalQuery(field_name, Intervals.term(term_value))}. + * the JSON DSL). The named query must be in the form {@code {field_name: {rule_object}}}, for + * example {@code {title: {all_of: {...}}}}. */ public class IntervalsQParserPlugin extends QParserPlugin { public static final String NAME = "intervals"; @@ -70,13 +76,11 @@ public Query parse() { "Query '" + jsonQueryName + "' not found in json_queries or is not a map"); } - @SuppressWarnings("unchecked") - Map queryDefMap = (Map) queryDef; - + Map queryDefMap = asStringObjectMap(queryDef, "json query definition"); if (queryDefMap.size() != 1) { throw new SolrException( SolrException.ErrorCode.BAD_REQUEST, - "Expected exactly one {field: term} entry in json_query '" + "Expected exactly one {field: intervals_rule} entry in json_query '" + jsonQueryName + "', got " + queryDefMap.size()); @@ -84,18 +88,373 @@ public Query parse() { Map.Entry entry = queryDefMap.entrySet().iterator().next(); String field = entry.getKey(); - Object termValue = entry.getValue(); + Map fieldRule = + asStringObjectMap( + entry.getValue(), "intervals query for field '" + field + "' in '" + jsonQueryName + "'"); + IntervalsSource source = parseRuleObject(fieldRule, field); + return new IntervalQuery(field, source); + } - if (!(termValue instanceof String)) { + private IntervalsSource parseRuleObject(Map ruleObject, String topField) { + if (ruleObject.size() != 1) { throw new SolrException( SolrException.ErrorCode.BAD_REQUEST, - "Expected a string term value in json_query '" - + jsonQueryName + "Each rule object must contain exactly one rule key, got " + ruleObject.keySet()); + } + Map.Entry entry = ruleObject.entrySet().iterator().next(); + String ruleName = entry.getKey(); + Map ruleParams = asStringObjectMap(entry.getValue(), "rule '" + ruleName + "'"); + + return switch (ruleName) { + case "match" -> parseMatchRule(ruleParams, topField); + case "prefix" -> parsePrefixRule(ruleParams, topField); + case "wildcard" -> parseWildcardRule(ruleParams, topField); + case "fuzzy" -> parseFuzzyRule(ruleParams, topField); + case "all_of" -> parseAllOfRule(ruleParams, topField); + case "any_of" -> parseAnyOfRule(ruleParams, topField); + default -> + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, "Unsupported intervals rule: " + ruleName); + }; + } + + private IntervalsSource parseMatchRule(Map params, String topField) { + 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"); + String analysisField = useField == null ? topField : useField; + + Analyzer analyzer = resolveAnalyzer(params, analysisField, "match"); + IntervalsSource source; + try { + source = Intervals.analyzedText(queryText, analyzer, analysisField, maxGaps, ordered); + } catch (IOException e) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "Failed to analyze match query text for field '" + analysisField + "'", + e); + } + if (useField != null) { + source = Intervals.fixField(useField, source); + } + return applyFilter(source, params.get("filter"), topField); + } + + private IntervalsSource parsePrefixRule(Map params, String topField) { + String prefix = requireString(params, "prefix", "prefix"); + String useField = getOptionalString(params, "use_field", "prefix"); + String field = useField == null ? topField : useField; + Analyzer analyzer = resolveAnalyzer(params, field, "prefix"); + String normalizedPrefix = normalizeMultiTerm(field, prefix, analyzer); + IntervalsSource source = Intervals.prefix(new org.apache.lucene.util.BytesRef(normalizedPrefix)); + if (useField != null) { + source = Intervals.fixField(useField, source); + } + return source; + } + + private IntervalsSource parseWildcardRule(Map params, String topField) { + String pattern = requireString(params, "pattern", "wildcard"); + String useField = getOptionalString(params, "use_field", "wildcard"); + String field = useField == null ? topField : useField; + Analyzer analyzer = resolveAnalyzer(params, field, "wildcard"); + String normalizedPattern = normalizeMultiTerm(field, pattern, analyzer); + IntervalsSource source = Intervals.wildcard(new org.apache.lucene.util.BytesRef(normalizedPattern)); + if (useField != null) { + source = Intervals.fixField(useField, source); + } + return source; + } + + private IntervalsSource parseFuzzyRule(Map params, String topField) { + String term = requireString(params, "term", "fuzzy"); + String useField = getOptionalString(params, "use_field", "fuzzy"); + String field = useField == null ? topField : useField; + Analyzer analyzer = resolveAnalyzer(params, field, "fuzzy"); + String normalizedTerm = normalizeMultiTerm(field, term, analyzer); + + String fuzziness = getOptionalString(params, "fuzziness", "fuzzy"); + int maxEdits = resolveFuzziness(fuzziness, normalizedTerm); + int prefixLength = getInt(params, "prefix_length", 0, "fuzzy"); + boolean transpositions = getBoolean(params, "transpositions", true, "fuzzy"); + + IntervalsSource source = + Intervals.fuzzyTerm( + normalizedTerm, + maxEdits, + prefixLength, + transpositions, + Intervals.DEFAULT_MAX_EXPANSIONS); + if (useField != null) { + source = Intervals.fixField(useField, source); + } + return source; + } + + private IntervalsSource parseAllOfRule(Map params, String topField) { + List intervals = parseIntervalsArray(params, topField, "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"), topField); + } + + private IntervalsSource parseAnyOfRule(Map params, String topField) { + List intervals = parseIntervalsArray(params, topField, "any_of"); + IntervalsSource source = Intervals.or(intervals); + return applyFilter(source, params.get("filter"), topField); + } + + private List parseIntervalsArray( + Map params, String topField, 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 parsed = new ArrayList<>(rawIntervals.size()); + for (Object intervalObj : rawIntervals) { + parsed.add(parseRuleObject(asStringObjectMap(intervalObj, "intervals array element"), topField)); + } + return parsed; + } + + private IntervalsSource applyFilter( + IntervalsSource source, Object filterObj, String topField) { + if (filterObj == null) { + return source; + } + Map 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 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 + "'"), topField); + 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); + }; + } + + private Analyzer resolveAnalyzer( + Map params, String field, String ruleName) { + String analyzerName = getOptionalString(params, "analyzer", ruleName); + if (analyzerName == null) { + return req.getSchema().getQueryAnalyzer(); + } + 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.getQueryAnalyzer(); + } + + private String normalizeMultiTerm(String field, String term, Analyzer analyzer) { + Analyzer effective = analyzer; + if (effective == null) { + FieldType fieldType = req.getSchema().getFieldTypeNoEx(field); + if (fieldType instanceof TextField textField) { + effective = textField.getMultiTermAnalyzer(); + } + } + if (effective == null) { + return term; + } + org.apache.lucene.util.BytesRef analyzed = TextField.analyzeMultiTerm(field, term, effective); + 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:,"); + } + 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:,", + 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 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 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 casted = (Map) mapObj; + return casted; + } + + private String requireString(Map 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 " - + (termValue == null ? "null" : termValue.getClass().getName())); + + describeType(val)); } + return (String) val; + } + + private String getOptionalString(Map 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 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 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)); + } - return new IntervalQuery(field, Intervals.term((String) termValue)); + private String describeType(Object value) { + return value == null ? "null" : value.getClass().getName(); } }; } diff --git a/solr/core/src/test/org/apache/solr/search/TestIntervalsQParserPlugin.java b/solr/core/src/test/org/apache/solr/search/TestIntervalsQParserPlugin.java index 1ce3e926a7f4..efb574ec47c1 100644 --- a/solr/core/src/test/org/apache/solr/search/TestIntervalsQParserPlugin.java +++ b/solr/core/src/test/org/apache/solr/search/TestIntervalsQParserPlugin.java @@ -41,32 +41,68 @@ public void testIntervalsNoJsonQueryParam() throws Exception { } @Test - public void testIntervalsTermMatchesDocument() throws Exception { + public void testIntervalsMatchRuleMatchesDocument() throws Exception { assertU(adoc("id", "10", "v_t", "foo bar")); assertU(adoc("id", "11", "v_t", "baz qux")); assertU(commit()); - // {v_t: "foo"} produces IntervalQuery("v_t", Intervals.term("foo")) + // {v_t:{match:{query:"foo"}}} produces an IntervalQuery on v_t assertQ( - "intervals qparser with {field:term} should match documents containing the term", - req("q", "{!intervals json_query=myQuery}", "json", "{json_queries:{myQuery:{v_t:foo}}}"), + "intervals qparser with match rule should match documents containing the term", + req( + "q", + "{!intervals json_query=myQuery}", + "json", + "{json_queries:{myQuery:{v_t:{match:{query:foo}}}}}"), "//result[@numFound='1']", "//doc/str[@name='id'][.='10']"); } @Test - public void testIntervalsNoMatchingTerm() throws Exception { + 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()); + + assertQ( + "intervals qparser should support field -> all_of with nested any_of from a named json_query", + req( + "q", + "{!intervals json_query=second_query}", + "json", + "{json_queries:{" + + "second_query:{" + + "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}}" + + "]}}" + + "]" + + "}" + + "}" + + "}" + + "}}"), + "//result[@numFound='2']"); + } + + @Test + public void testIntervalsNoMatchingRule() throws Exception { assertU(adoc("id", "20", "v_t", "hello world")); assertU(commit()); - // Term not present in any document + // Match rule text not present in any document assertQ( - "intervals qparser with non-matching term should return no docs", + "intervals qparser with non-matching rule should return no docs", req( "q", "{!intervals json_query=myQuery}", "json", - "{json_queries:{myQuery:{v_t:zzznomatch}}}"), + "{json_queries:{myQuery:{v_t:{match:{query:zzznomatch}}}}}"), "//result[@numFound='0']"); } } From a402594930efb7fe81e1f72dfb208242d13c7bb2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 30 Jun 2026 22:11:50 +0000 Subject: [PATCH 06/43] Fix intervals DSL parser warnings and finalize validation --- .../solr/search/IntervalsQParserPlugin.java | 50 +++++++++++-------- 1 file changed, 28 insertions(+), 22 deletions(-) diff --git a/solr/core/src/java/org/apache/solr/search/IntervalsQParserPlugin.java b/solr/core/src/java/org/apache/solr/search/IntervalsQParserPlugin.java index eb5d430e3fb6..ee3c0901064e 100644 --- a/solr/core/src/java/org/apache/solr/search/IntervalsQParserPlugin.java +++ b/solr/core/src/java/org/apache/solr/search/IntervalsQParserPlugin.java @@ -26,6 +26,7 @@ import org.apache.lucene.queries.intervals.IntervalsSource; import org.apache.lucene.search.MatchNoDocsQuery; import org.apache.lucene.search.Query; +import org.apache.lucene.util.BytesRef; import org.apache.solr.common.SolrException; import org.apache.solr.common.params.SolrParams; import org.apache.solr.request.SolrQueryRequest; @@ -90,7 +91,8 @@ public Query parse() { String field = entry.getKey(); Map fieldRule = asStringObjectMap( - entry.getValue(), "intervals query for field '" + field + "' in '" + jsonQueryName + "'"); + entry.getValue(), + "intervals query for field '" + field + "' in '" + jsonQueryName + "'"); IntervalsSource source = parseRuleObject(fieldRule, field); return new IntervalQuery(field, source); } @@ -103,7 +105,8 @@ private IntervalsSource parseRuleObject(Map ruleObject, String t } Map.Entry entry = ruleObject.entrySet().iterator().next(); String ruleName = entry.getKey(); - Map ruleParams = asStringObjectMap(entry.getValue(), "rule '" + ruleName + "'"); + Map ruleParams = + asStringObjectMap(entry.getValue(), "rule '" + ruleName + "'"); return switch (ruleName) { case "match" -> parseMatchRule(ruleParams, topField); @@ -112,9 +115,8 @@ private IntervalsSource parseRuleObject(Map ruleObject, String t case "fuzzy" -> parseFuzzyRule(ruleParams, topField); case "all_of" -> parseAllOfRule(ruleParams, topField); case "any_of" -> parseAnyOfRule(ruleParams, topField); - default -> - throw new SolrException( - SolrException.ErrorCode.BAD_REQUEST, "Unsupported intervals rule: " + ruleName); + default -> throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, "Unsupported intervals rule: " + ruleName); }; } @@ -147,7 +149,7 @@ private IntervalsSource parsePrefixRule(Map params, String topFi String field = useField == null ? topField : useField; Analyzer analyzer = resolveAnalyzer(params, field, "prefix"); String normalizedPrefix = normalizeMultiTerm(field, prefix, analyzer); - IntervalsSource source = Intervals.prefix(new org.apache.lucene.util.BytesRef(normalizedPrefix)); + IntervalsSource source = Intervals.prefix(new BytesRef(normalizedPrefix)); if (useField != null) { source = Intervals.fixField(useField, source); } @@ -160,7 +162,7 @@ private IntervalsSource parseWildcardRule(Map params, String top String field = useField == null ? topField : useField; Analyzer analyzer = resolveAnalyzer(params, field, "wildcard"); String normalizedPattern = normalizeMultiTerm(field, pattern, analyzer); - IntervalsSource source = Intervals.wildcard(new org.apache.lucene.util.BytesRef(normalizedPattern)); + IntervalsSource source = Intervals.wildcard(new BytesRef(normalizedPattern)); if (useField != null) { source = Intervals.fixField(useField, source); } @@ -229,7 +231,8 @@ private List parseIntervalsArray( } List parsed = new ArrayList<>(rawIntervals.size()); for (Object intervalObj : rawIntervals) { - parsed.add(parseRuleObject(asStringObjectMap(intervalObj, "intervals array element"), topField)); + parsed.add( + parseRuleObject(asStringObjectMap(intervalObj, "intervals array element"), topField)); } return parsed; } @@ -263,14 +266,12 @@ private IntervalsSource applyFilter( 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); + default -> throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, "Unsupported filter operator: " + op); }; } - private Analyzer resolveAnalyzer( - Map params, String field, String ruleName) { + private Analyzer resolveAnalyzer(Map params, String field, String ruleName) { String analyzerName = getOptionalString(params, "analyzer", ruleName); if (analyzerName == null) { return req.getSchema().getQueryAnalyzer(); @@ -299,7 +300,7 @@ private String normalizeMultiTerm(String field, String term, Analyzer analyzer) if (effective == null) { return term; } - org.apache.lucene.util.BytesRef analyzed = TextField.analyzeMultiTerm(field, term, effective); + BytesRef analyzed = TextField.analyzeMultiTerm(field, term, effective); return analyzed == null ? term : analyzed.utf8ToString(); } @@ -422,7 +423,12 @@ private boolean getBoolean( } throw new SolrException( SolrException.ErrorCode.BAD_REQUEST, - "Rule '" + context + "' expects boolean parameter '" + key + "', got " + describeType(val)); + "Rule '" + + context + + "' expects boolean parameter '" + + key + + "', got " + + describeType(val)); } private int getInt(Map map, String key, int defaultValue, String context) { @@ -439,18 +445,18 @@ private int getInt(Map map, String key, int defaultValue, String } catch (NumberFormatException e) { throw new SolrException( SolrException.ErrorCode.BAD_REQUEST, - "Rule '" - + context - + "' expects integer parameter '" - + key - + "', got " - + s, + "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)); + "Rule '" + + context + + "' expects integer parameter '" + + key + + "', got " + + describeType(val)); } private String describeType(Object value) { From e0841a3169317ebb7ce888b4ce8df1576d064c1e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 30 Jun 2026 22:13:59 +0000 Subject: [PATCH 07/43] Clarify fuzzy expansion constant in intervals parser --- .../java/org/apache/solr/search/IntervalsQParserPlugin.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/solr/core/src/java/org/apache/solr/search/IntervalsQParserPlugin.java b/solr/core/src/java/org/apache/solr/search/IntervalsQParserPlugin.java index ee3c0901064e..1247e4f80a77 100644 --- a/solr/core/src/java/org/apache/solr/search/IntervalsQParserPlugin.java +++ b/solr/core/src/java/org/apache/solr/search/IntervalsQParserPlugin.java @@ -43,6 +43,7 @@ */ public class IntervalsQParserPlugin extends QParserPlugin { public static final String NAME = "intervals"; + private static final int DEFAULT_FUZZY_MAX_EXPANSIONS = Intervals.DEFAULT_MAX_EXPANSIONS; /** Local param that names the entry in {@code json_queries} to use. */ public static final String JSON_QUERY_PARAM = "json_query"; @@ -187,7 +188,7 @@ private IntervalsSource parseFuzzyRule(Map params, String topFie maxEdits, prefixLength, transpositions, - Intervals.DEFAULT_MAX_EXPANSIONS); + DEFAULT_FUZZY_MAX_EXPANSIONS); if (useField != null) { source = Intervals.fixField(useField, source); } From c76d2874de7b9d5a76ca413bf9427c288a8b0cc7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Jul 2026 07:24:04 +0000 Subject: [PATCH 08/43] Add interval rule support: term, phrase, regexp, range, max_width, extend, unordered_no_overlaps, not_within, within, at_least, no_intervals --- .../solr/search/IntervalsQParserPlugin.java | 160 +++++++++++++ .../search/TestIntervalsQParserPlugin.java | 219 ++++++++++++++++++ 2 files changed, 379 insertions(+) diff --git a/solr/core/src/java/org/apache/solr/search/IntervalsQParserPlugin.java b/solr/core/src/java/org/apache/solr/search/IntervalsQParserPlugin.java index 1247e4f80a77..bcd00caf5e7b 100644 --- a/solr/core/src/java/org/apache/solr/search/IntervalsQParserPlugin.java +++ b/solr/core/src/java/org/apache/solr/search/IntervalsQParserPlugin.java @@ -116,6 +116,17 @@ private IntervalsSource parseRuleObject(Map ruleObject, String t case "fuzzy" -> parseFuzzyRule(ruleParams, topField); case "all_of" -> parseAllOfRule(ruleParams, topField); case "any_of" -> parseAnyOfRule(ruleParams, topField); + case "term" -> parseTermRule(ruleParams, topField); + case "phrase" -> parsePhraseRule(ruleParams, topField); + case "regexp" -> parseRegexpRule(ruleParams, topField); + case "range" -> parseRangeRule(ruleParams, topField); + case "max_width" -> parseMaxWidthRule(ruleParams, topField); + case "extend" -> parseExtendRule(ruleParams, topField); + case "unordered_no_overlaps" -> parseUnorderedNoOverlapsRule(ruleParams, topField); + case "not_within" -> parseNotWithinRule(ruleParams, topField); + case "within" -> parseWithinRule(ruleParams, topField); + case "at_least" -> parseAtLeastRule(ruleParams, topField); + case "no_intervals" -> parseNoIntervalsRule(ruleParams); default -> throw new SolrException( SolrException.ErrorCode.BAD_REQUEST, "Unsupported intervals rule: " + ruleName); }; @@ -216,6 +227,155 @@ private IntervalsSource parseAnyOfRule(Map params, String topFie return applyFilter(source, params.get("filter"), topField); } + private IntervalsSource parseTermRule(Map params, String topField) { + String value = requireString(params, "value", "term"); + String useField = getOptionalString(params, "use_field", "term"); + IntervalsSource source = Intervals.term(value); + if (useField != null) { + source = Intervals.fixField(useField, source); + } + return source; + } + + private IntervalsSource parsePhraseRule(Map params, String topField) { + Object termsObj = params.get("terms"); + Object intervalsObj = params.get("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 intervals = parseIntervalsArray(params, topField, "phrase"); + return Intervals.phrase(intervals.toArray(IntervalsSource[]::new)); + } + } + + private IntervalsSource parseRegexpRule(Map params, String topField) { + String pattern = requireString(params, "pattern", "regexp"); + String useField = getOptionalString(params, "use_field", "regexp"); + int maxExpansions = + getInt(params, "max_expansions", Intervals.DEFAULT_MAX_EXPANSIONS, "regexp"); + IntervalsSource source = Intervals.regexp(new BytesRef(pattern), maxExpansions); + if (useField != null) { + source = Intervals.fixField(useField, source); + } + return source; + } + + private IntervalsSource parseRangeRule(Map params, String topField) { + 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"); + 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 params, String topField) { + 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", topField); + return Intervals.maxwidth(width, source); + } + + private IntervalsSource parseExtendRule(Map params, String topField) { + int before = getInt(params, "before", 0, "extend"); + int after = getInt(params, "after", 0, "extend"); + IntervalsSource source = parseNestedRule(params, "source", "extend", topField); + return Intervals.extend(source, before, after); + } + + private IntervalsSource parseUnorderedNoOverlapsRule( + Map params, String topField) { + List intervals = + parseIntervalsArray(params, topField, "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 params, String topField) { + IntervalsSource source = parseNestedRule(params, "source", "not_within", topField); + 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", topField); + return Intervals.notWithin(source, positions, reference); + } + + private IntervalsSource parseWithinRule(Map params, String topField) { + IntervalsSource source = parseNestedRule(params, "source", "within", topField); + 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", topField); + return Intervals.within(source, positions, reference); + } + + private IntervalsSource parseAtLeastRule(Map params, String topField) { + 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 intervals = parseIntervalsArray(params, topField, "at_least"); + return Intervals.atLeast(minShouldMatch, intervals.toArray(IntervalsSource[]::new)); + } + + private IntervalsSource parseNoIntervalsRule(Map params) { + String reason = getOptionalString(params, "reason", "no_intervals"); + return Intervals.noIntervals(reason == null ? "no_intervals rule" : reason); + } + + private IntervalsSource parseNestedRule( + Map params, String key, String ruleName, String topField) { + 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 + "'"), topField); + } + private List parseIntervalsArray( Map params, String topField, String ruleName) { Object intervalsObj = params.get("intervals"); diff --git a/solr/core/src/test/org/apache/solr/search/TestIntervalsQParserPlugin.java b/solr/core/src/test/org/apache/solr/search/TestIntervalsQParserPlugin.java index efb574ec47c1..af563e52bb63 100644 --- a/solr/core/src/test/org/apache/solr/search/TestIntervalsQParserPlugin.java +++ b/solr/core/src/test/org/apache/solr/search/TestIntervalsQParserPlugin.java @@ -105,4 +105,223 @@ public void testIntervalsNoMatchingRule() throws Exception { "{json_queries:{myQuery:{v_t:{match:{query:zzznomatch}}}}}"), "//result[@numFound='0']"); } + + @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()); + + assertQ( + "term rule should match documents containing the exact term", + req( + "q", + "{!intervals json_query=q1}", + "json", + "{json_queries:{q1:{v_ws:{term:{value:trm_apple}}}}}"), + "//result[@numFound='1']", + "//doc/str[@name='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()); + + assertQ( + "phrase rule with terms array should match documents with exact phrase", + req( + "q", + "{!intervals json_query=q1}", + "json", + "{json_queries:{q1:{v_ws:{phrase:{terms:[phrA_quick,phrA_brown,phrA_fox]}}}}}"), + "//result[@numFound='1']", + "//doc/str[@name='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()); + + assertQ( + "phrase rule with intervals array should match documents with the phrase in order", + req( + "q", + "{!intervals json_query=q1}", + "json", + "{json_queries:{q1:{v_ws:{phrase:{intervals:" + + "[{term:{value:phrB_quick}},{term:{value:phrB_brown}},{term:{value:phrB_fox}}]}}}}}"), + "//result[@numFound='1']", + "//doc/str[@name='id'][.='52']"); + } + + @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()); + + assertQ( + "regexp rule should match documents with terms matching the pattern", + req( + "q", + "{!intervals json_query=q1}", + "json", + "{json_queries:{q1:{v_ws:{regexp:{pattern:'rx_ca.*'}}}}}"), + "//result[@numFound='2']"); + } + + @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()); + + assertQ( + "range rule should match documents with terms in the given range", + req( + "q", + "{!intervals json_query=q1}", + "json", + "{json_queries:{q1:{v_ws:{range:{lower_term:rng_bbbb,upper_term:rng_cccc," + + "include_lower:true,include_upper:true}}}}}"), + "//result[@numFound='2']", + "//doc/str[@name='id'][.='71']", + "//doc/str[@name='id'][.='72']"); + } + + @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 + assertQ( + "max_width rule should filter intervals by maximum width", + req( + "q", + "{!intervals json_query=q1}", + "json", + "{json_queries:{q1:{v_ws:{max_width:{width:2,source:" + + "{all_of:{ordered:true,intervals:[{term:{value:mwd_alpha}},{term:{value:mwd_beta}}]}}}}}}}"), + "//result[@numFound='1']", + "//doc/str[@name='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 + assertQ( + "extend rule should extend intervals by specified positions", + req( + "q", + "{!intervals json_query=q1}", + "json", + "{json_queries:{q1:{v_ws:{extend:{source:{term:{value:ext_three}},before:2,after:2}}}}}"), + "//result[@numFound='1']", + "//doc/str[@name='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()); + + assertQ( + "unordered_no_overlaps rule should match documents containing both terms without overlap", + req( + "q", + "{!intervals json_query=q1}", + "json", + "{json_queries:{q1:{v_ws:{unordered_no_overlaps:{intervals:" + + "[{term:{value:uno_foo}},{term:{value:uno_bar}}]}}}}}"), + "//result[@numFound='2']"); + } + + @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 + assertQ( + "within rule should match documents where source appears within N positions of reference", + req( + "q", + "{!intervals json_query=q1}", + "json", + "{json_queries:{q1:{v_ws:{within:{source:{term:{value:wth_alpha}}," + + "positions:1,reference:{term:{value:wth_beta}}}}}}}"), + "//result[@numFound='1']", + "//doc/str[@name='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 + assertQ( + "not_within rule should match documents where source is not within N positions of reference", + req( + "q", + "{!intervals json_query=q1}", + "json", + "{json_queries:{q1:{v_ws:{not_within:{source:{term:{value:nwt_alpha}}," + + "positions:1,reference:{term:{value:nwt_beta}}}}}}}"), + "//result[@numFound='1']", + "//doc/str[@name='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 + assertQ( + "at_least rule should match documents containing at least N of the given sources", + req( + "q", + "{!intervals json_query=q1}", + "json", + "{json_queries:{q1:{v_ws:{at_least:{min_should_match:2,intervals:" + + "[{term:{value:atl_alpha}},{term:{value:atl_beta}},{term:{value:atl_gamma}}]}}}}}"), + "//result[@numFound='2']"); + } + + @Test + public void testIntervalsNoIntervalsRule() throws Exception { + assertU(adoc("id", "140", "v_ws", "nio_anything")); + assertU(commit()); + + assertQ( + "no_intervals rule should match no documents", + req( + "q", + "{!intervals json_query=q1}", + "json", + "{json_queries:{q1:{v_ws:{no_intervals:{reason:testing}}}}}"), + "//result[@numFound='0']"); + } } From 1d9f1e860002f971a41cc4cb514b313b580c8e94 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Jul 2026 07:45:55 +0000 Subject: [PATCH 09/43] Add Intervals Query Parser documentation page and references --- .../pages/intervals-query-parser.adoc | 736 ++++++++++++++++++ .../query-guide/pages/other-parsers.adoc | 9 + .../modules/query-guide/querying-nav.adoc | 1 + 3 files changed, 746 insertions(+) create mode 100644 solr/solr-ref-guide/modules/query-guide/pages/intervals-query-parser.adoc 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 000000000000..ed377ac07c51 --- /dev/null +++ b/solr/solr-ref-guide/modules/query-guide/pages/intervals-query-parser.adoc @@ -0,0 +1,736 @@ += 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 (`IntervalsQParserPlugin`) builds Lucene interval queries from a 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". + +Invoked with the syntax `{!intervals json_query=}`. + +== Basic Usage + +The parser reads query definitions from the `json_queries` map inside the JSON request body. +Each named entry must have the form `{: {}}`. + +[source,text] +---- +q={!intervals json_query=myQuery} +---- + +[source,json] +---- +{ + "json_queries": { + "myQuery": { + "title": { + "match": { "query": "apache solr" } + } + } + } +} +---- + +== Parameters + +`json_query`:: ++ +[%autowidth,frame=none] +|=== +|Required |Default: none +|=== ++ +Names an entry in the `json_queries` map that defines the interval query to execute. + +== Interval Rules + +Each rule object contains exactly one key that identifies the rule type, mapped to an object of rule parameters. + +=== `match` + +Analyses 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 analyse 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 +|— +|Analyse 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:,`. + +|`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 json_query=titleQuery} +---- + +[source,json] +---- +{ + "json_queries": { + "titleQuery": { + "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 7a8b0bd09183..8664147edb1a 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 json_query=}`, where `json_query` names an entry in the `json_queries` map supplied in the JSON request body. + +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 d83ab083a839..d334fd6e868b 100644 --- a/solr/solr-ref-guide/modules/query-guide/querying-nav.adoc +++ b/solr/solr-ref-guide/modules/query-guide/querying-nav.adoc @@ -30,6 +30,7 @@ *** xref:json-combined-query-dsl.adoc[] ** xref:searching-nested-documents.adoc[] ** xref:block-join-query-parser.adoc[] +** xref:intervals-query-parser.adoc[] ** xref:join-query-parser.adoc[] ** xref:spatial-search.adoc[] ** xref:dense-vector-search.adoc[] From 363eb9e6d079958c2079c03b2448b9b06d5154e6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Jul 2026 07:46:35 +0000 Subject: [PATCH 10/43] Fix spelling: analyse -> analyze in intervals-query-parser.adoc --- .../modules/query-guide/pages/intervals-query-parser.adoc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index ed377ac07c51..ca64f0e8a4e5 100644 --- 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 @@ -61,7 +61,7 @@ Each rule object contains exactly one key that identifies the rule type, mapped === `match` -Analyses query text and matches documents where the resulting tokens appear according to positional constraints. +Analyzes query text and matches documents where the resulting tokens appear according to positional constraints. [%autowidth,frame=none] |=== @@ -85,7 +85,7 @@ Analyses query text and matches documents where the resulting tokens appear acco |`use_field` |No |— -|Analyse using a different field's analyzer and match against that field. +|Analyze using a different field's analyzer and match against that field. |`analyzer` |No From e2047c36b0700a0f76b9f512975a621d5526f57f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Jul 2026 07:55:00 +0000 Subject: [PATCH 11/43] Add SOLR-13764 changelog fragment --- .../SOLR-13764-intervals-query-parser-reference-guide.yml | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 changelog/unreleased/SOLR-13764-intervals-query-parser-reference-guide.yml diff --git a/changelog/unreleased/SOLR-13764-intervals-query-parser-reference-guide.yml b/changelog/unreleased/SOLR-13764-intervals-query-parser-reference-guide.yml new file mode 100644 index 000000000000..e20671635515 --- /dev/null +++ b/changelog/unreleased/SOLR-13764-intervals-query-parser-reference-guide.yml @@ -0,0 +1,8 @@ +# See https://github.com/apache/solr/blob/main/dev-docs/changelog.adoc +title: Add reference guide page for the Intervals Query Parser +type: other +authors: + - name: mkhludnev +links: + - name: SOLR-13764 + url: https://issues.apache.org/jira/browse/SOLR-13764 From 655b511398df45f098867b7dd94f82a5084a1670 Mon Sep 17 00:00:00 2001 From: Mikhail Khludnev Date: Wed, 1 Jul 2026 11:07:32 +0300 Subject: [PATCH 12/43] fix changelog --- .../SOLR-13764-intervals-query-parser-reference-guide.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/changelog/unreleased/SOLR-13764-intervals-query-parser-reference-guide.yml b/changelog/unreleased/SOLR-13764-intervals-query-parser-reference-guide.yml index e20671635515..9bd928ab5c35 100644 --- a/changelog/unreleased/SOLR-13764-intervals-query-parser-reference-guide.yml +++ b/changelog/unreleased/SOLR-13764-intervals-query-parser-reference-guide.yml @@ -1,8 +1,10 @@ # See https://github.com/apache/solr/blob/main/dev-docs/changelog.adoc -title: Add reference guide page for the Intervals Query Parser +title: Add Intervals Query Parser type: other authors: - - name: mkhludnev + - name: Mikhail Khludnev + nick: mkhludnev + utl: https://home.apache.org/phonebook.html?uid=mkhl links: - name: SOLR-13764 url: https://issues.apache.org/jira/browse/SOLR-13764 From 53c87308abe8fa8ab6cf5ffbdb284eee60aea128 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Jul 2026 08:35:25 +0000 Subject: [PATCH 13/43] Support df local/query param as field for IntervalsQParser; update tests --- .../solr/search/IntervalsQParserPlugin.java | 86 ++++++++++--- .../search/TestIntervalsQParserPlugin.java | 118 ++++++++++++------ 2 files changed, 147 insertions(+), 57 deletions(-) diff --git a/solr/core/src/java/org/apache/solr/search/IntervalsQParserPlugin.java b/solr/core/src/java/org/apache/solr/search/IntervalsQParserPlugin.java index bcd00caf5e7b..a4e7d965b6a3 100644 --- a/solr/core/src/java/org/apache/solr/search/IntervalsQParserPlugin.java +++ b/solr/core/src/java/org/apache/solr/search/IntervalsQParserPlugin.java @@ -20,6 +20,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.Set; import org.apache.lucene.analysis.Analyzer; import org.apache.lucene.queries.intervals.IntervalQuery; import org.apache.lucene.queries.intervals.Intervals; @@ -28,6 +29,7 @@ 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.request.SolrQueryRequest; import org.apache.solr.schema.FieldType; @@ -35,16 +37,48 @@ /** * A query parser that builds interval queries from a JSON DSL description. Invoked with the syntax - * {@code {!intervals json_query=foobar}}. + * {@code {!intervals json_query=foobar df=title}}. * *

The {@code json_query} local param names an entry in the {@code json_queries} map (passed via - * the JSON DSL). The named query must be in the form {@code {field_name: {rule_object}}}, for - * example {@code {title: {all_of: {...}}}}. + * the JSON DSL). The format of the named query is detected automatically: + * + *

*/ public class IntervalsQParserPlugin extends QParserPlugin { public static final String NAME = "intervals"; private static final int DEFAULT_FUZZY_MAX_EXPANSIONS = Intervals.DEFAULT_MAX_EXPANSIONS; + /** + * The set of known interval rule names. Used to detect whether a json_query entry is in the new + * field-via-{@code df} format (top-level key is a rule name) or the legacy {@code {field_name: + * rule_object}} format. + */ + private static final Set RULE_NAMES = + Set.of( + "match", + "prefix", + "wildcard", + "fuzzy", + "all_of", + "any_of", + "term", + "phrase", + "regexp", + "range", + "max_width", + "extend", + "unordered_no_overlaps", + "not_within", + "within", + "at_least", + "no_intervals"); + /** Local param that names the entry in {@code json_queries} to use. */ public static final String JSON_QUERY_PARAM = "json_query"; @@ -79,21 +113,41 @@ public Query parse() { } Map queryDefMap = asStringObjectMap(queryDef, "json query definition"); - if (queryDefMap.size() != 1) { - throw new SolrException( - SolrException.ErrorCode.BAD_REQUEST, - "Expected exactly one {field: intervals_rule} entry in json_query '" - + jsonQueryName - + "', got " - + queryDefMap.size()); + + String field; + Map fieldRule; + if (queryDefMap.size() == 1 + && RULE_NAMES.contains(queryDefMap.entrySet().iterator().next().getKey())) { + // New format: the top-level key is a rule name, so the json_query value is the rule + // object directly. The target field must be supplied via the df local param or the df + // query param. + field = getParam(CommonParams.DF); + if (field == null || field.isEmpty()) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "json_query '" + + jsonQueryName + + "' is in field-free format but no 'df' parameter was provided"); + } + fieldRule = queryDefMap; + } else { + // Legacy format: json_query value is {field_name: rule_object}. + if (queryDefMap.size() != 1) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "Expected exactly one {field: intervals_rule} entry in json_query '" + + jsonQueryName + + "', got " + + queryDefMap.size()); + } + Map.Entry entry = queryDefMap.entrySet().iterator().next(); + field = entry.getKey(); + fieldRule = + asStringObjectMap( + entry.getValue(), + "intervals query for field '" + field + "' in '" + jsonQueryName + "'"); } - Map.Entry entry = queryDefMap.entrySet().iterator().next(); - String field = entry.getKey(); - Map fieldRule = - asStringObjectMap( - entry.getValue(), - "intervals query for field '" + field + "' in '" + jsonQueryName + "'"); IntervalsSource source = parseRuleObject(fieldRule, field); return new IntervalQuery(field, source); } diff --git a/solr/core/src/test/org/apache/solr/search/TestIntervalsQParserPlugin.java b/solr/core/src/test/org/apache/solr/search/TestIntervalsQParserPlugin.java index af563e52bb63..063e929dbddf 100644 --- a/solr/core/src/test/org/apache/solr/search/TestIntervalsQParserPlugin.java +++ b/solr/core/src/test/org/apache/solr/search/TestIntervalsQParserPlugin.java @@ -46,14 +46,14 @@ public void testIntervalsMatchRuleMatchesDocument() throws Exception { assertU(adoc("id", "11", "v_t", "baz qux")); assertU(commit()); - // {v_t:{match:{query:"foo"}}} produces an IntervalQuery on v_t + // field specified via df local param; json_query is the rule object directly assertQ( "intervals qparser with match rule should match documents containing the term", req( "q", - "{!intervals json_query=myQuery}", + "{!intervals json_query=myQuery df=v_t}", "json", - "{json_queries:{myQuery:{v_t:{match:{query:foo}}}}}"), + "{json_queries:{myQuery:{match:{query:foo}}}}"), "//result[@numFound='1']", "//doc/str[@name='id'][.='10']"); } @@ -66,14 +66,13 @@ public void testIntervalsAllOfAnyOfNamedQuery() throws Exception { assertU(commit()); assertQ( - "intervals qparser should support field -> all_of with nested any_of from a named json_query", + "intervals qparser should support all_of with nested any_of via df local param", req( "q", - "{!intervals json_query=second_query}", + "{!intervals json_query=second_query df=title_t}", "json", "{json_queries:{" + "second_query:{" - + "title_t:{" + "all_of:{" + "ordered:true," + "intervals:[" @@ -85,7 +84,6 @@ public void testIntervalsAllOfAnyOfNamedQuery() throws Exception { + "]" + "}" + "}" - + "}" + "}}"), "//result[@numFound='2']"); } @@ -95,14 +93,14 @@ public void testIntervalsNoMatchingRule() throws Exception { assertU(adoc("id", "20", "v_t", "hello world")); assertU(commit()); - // Match rule text not present in any document + // Match rule text not present in any document; field via df local param assertQ( "intervals qparser with non-matching rule should return no docs", req( "q", - "{!intervals json_query=myQuery}", + "{!intervals json_query=myQuery df=v_t}", "json", - "{json_queries:{myQuery:{v_t:{match:{query:zzznomatch}}}}}"), + "{json_queries:{myQuery:{match:{query:zzznomatch}}}}"), "//result[@numFound='0']"); } @@ -116,9 +114,9 @@ public void testIntervalsTermRule() throws Exception { "term rule should match documents containing the exact term", req( "q", - "{!intervals json_query=q1}", + "{!intervals json_query=q1 df=v_ws}", "json", - "{json_queries:{q1:{v_ws:{term:{value:trm_apple}}}}}"), + "{json_queries:{q1:{term:{value:trm_apple}}}}"), "//result[@numFound='1']", "//doc/str[@name='id'][.='40']"); } @@ -133,9 +131,9 @@ public void testIntervalsPhraseRuleWithTerms() throws Exception { "phrase rule with terms array should match documents with exact phrase", req( "q", - "{!intervals json_query=q1}", + "{!intervals json_query=q1 df=v_ws}", "json", - "{json_queries:{q1:{v_ws:{phrase:{terms:[phrA_quick,phrA_brown,phrA_fox]}}}}}"), + "{json_queries:{q1:{phrase:{terms:[phrA_quick,phrA_brown,phrA_fox]}}}}"), "//result[@numFound='1']", "//doc/str[@name='id'][.='50']"); } @@ -150,10 +148,10 @@ public void testIntervalsPhraseRuleWithIntervals() throws Exception { "phrase rule with intervals array should match documents with the phrase in order", req( "q", - "{!intervals json_query=q1}", + "{!intervals json_query=q1 df=v_ws}", "json", - "{json_queries:{q1:{v_ws:{phrase:{intervals:" - + "[{term:{value:phrB_quick}},{term:{value:phrB_brown}},{term:{value:phrB_fox}}]}}}}}"), + "{json_queries:{q1:{phrase:{intervals:" + + "[{term:{value:phrB_quick}},{term:{value:phrB_brown}},{term:{value:phrB_fox}}]}}}}"), "//result[@numFound='1']", "//doc/str[@name='id'][.='52']"); } @@ -169,9 +167,9 @@ public void testIntervalsRegexpRule() throws Exception { "regexp rule should match documents with terms matching the pattern", req( "q", - "{!intervals json_query=q1}", + "{!intervals json_query=q1 df=v_ws}", "json", - "{json_queries:{q1:{v_ws:{regexp:{pattern:'rx_ca.*'}}}}}"), + "{json_queries:{q1:{regexp:{pattern:'rx_ca.*'}}}}"), "//result[@numFound='2']"); } @@ -187,10 +185,10 @@ public void testIntervalsRangeRule() throws Exception { "range rule should match documents with terms in the given range", req( "q", - "{!intervals json_query=q1}", + "{!intervals json_query=q1 df=v_ws}", "json", - "{json_queries:{q1:{v_ws:{range:{lower_term:rng_bbbb,upper_term:rng_cccc," - + "include_lower:true,include_upper:true}}}}}"), + "{json_queries:{q1:{range:{lower_term:rng_bbbb,upper_term:rng_cccc," + + "include_lower:true,include_upper:true}}}}"), "//result[@numFound='2']", "//doc/str[@name='id'][.='71']", "//doc/str[@name='id'][.='72']"); @@ -207,10 +205,10 @@ public void testIntervalsMaxWidthRule() throws Exception { "max_width rule should filter intervals by maximum width", req( "q", - "{!intervals json_query=q1}", + "{!intervals json_query=q1 df=v_ws}", "json", - "{json_queries:{q1:{v_ws:{max_width:{width:2,source:" - + "{all_of:{ordered:true,intervals:[{term:{value:mwd_alpha}},{term:{value:mwd_beta}}]}}}}}}}"), + "{json_queries:{q1:{max_width:{width:2,source:" + + "{all_of:{ordered:true,intervals:[{term:{value:mwd_alpha}},{term:{value:mwd_beta}}]}}}}}}"), "//result[@numFound='1']", "//doc/str[@name='id'][.='80']"); } @@ -226,9 +224,9 @@ public void testIntervalsExtendRule() throws Exception { "extend rule should extend intervals by specified positions", req( "q", - "{!intervals json_query=q1}", + "{!intervals json_query=q1 df=v_ws}", "json", - "{json_queries:{q1:{v_ws:{extend:{source:{term:{value:ext_three}},before:2,after:2}}}}}"), + "{json_queries:{q1:{extend:{source:{term:{value:ext_three}},before:2,after:2}}}}"), "//result[@numFound='1']", "//doc/str[@name='id'][.='90']"); } @@ -244,10 +242,10 @@ public void testIntervalsUnorderedNoOverlapsRule() throws Exception { "unordered_no_overlaps rule should match documents containing both terms without overlap", req( "q", - "{!intervals json_query=q1}", + "{!intervals json_query=q1 df=v_ws}", "json", - "{json_queries:{q1:{v_ws:{unordered_no_overlaps:{intervals:" - + "[{term:{value:uno_foo}},{term:{value:uno_bar}}]}}}}}"), + "{json_queries:{q1:{unordered_no_overlaps:{intervals:" + + "[{term:{value:uno_foo}},{term:{value:uno_bar}}]}}}}"), "//result[@numFound='2']"); } @@ -262,10 +260,10 @@ public void testIntervalsWithinRule() throws Exception { "within rule should match documents where source appears within N positions of reference", req( "q", - "{!intervals json_query=q1}", + "{!intervals json_query=q1 df=v_ws}", "json", - "{json_queries:{q1:{v_ws:{within:{source:{term:{value:wth_alpha}}," - + "positions:1,reference:{term:{value:wth_beta}}}}}}}"), + "{json_queries:{q1:{within:{source:{term:{value:wth_alpha}}," + + "positions:1,reference:{term:{value:wth_beta}}}}}}"), "//result[@numFound='1']", "//doc/str[@name='id'][.='110']"); } @@ -282,10 +280,10 @@ public void testIntervalsNotWithinRule() throws Exception { "not_within rule should match documents where source is not within N positions of reference", req( "q", - "{!intervals json_query=q1}", + "{!intervals json_query=q1 df=v_ws}", "json", - "{json_queries:{q1:{v_ws:{not_within:{source:{term:{value:nwt_alpha}}," - + "positions:1,reference:{term:{value:nwt_beta}}}}}}}"), + "{json_queries:{q1:{not_within:{source:{term:{value:nwt_alpha}}," + + "positions:1,reference:{term:{value:nwt_beta}}}}}}"), "//result[@numFound='1']", "//doc/str[@name='id'][.='120']"); } @@ -303,10 +301,10 @@ public void testIntervalsAtLeastRule() throws Exception { "at_least rule should match documents containing at least N of the given sources", req( "q", - "{!intervals json_query=q1}", + "{!intervals json_query=q1 df=v_ws}", "json", - "{json_queries:{q1:{v_ws:{at_least:{min_should_match:2,intervals:" - + "[{term:{value:atl_alpha}},{term:{value:atl_beta}},{term:{value:atl_gamma}}]}}}}}"), + "{json_queries:{q1:{at_least:{min_should_match:2,intervals:" + + "[{term:{value:atl_alpha}},{term:{value:atl_beta}},{term:{value:atl_gamma}}]}}}}"), "//result[@numFound='2']"); } @@ -319,9 +317,47 @@ public void testIntervalsNoIntervalsRule() throws Exception { "no_intervals rule should match no documents", req( "q", - "{!intervals json_query=q1}", + "{!intervals json_query=q1 df=v_ws}", "json", - "{json_queries:{q1:{v_ws:{no_intervals:{reason:testing}}}}}"), + "{json_queries:{q1:{no_intervals:{reason:testing}}}}"), "//result[@numFound='0']"); } + + @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 + assertQ( + "df query param (not local param) should be used as the field when df is absent in local params", + req( + "q", + "{!intervals json_query=q1}", + "df", + "v_ws", + "json", + "{json_queries:{q1:{term:{value:dfp_alpha}}}}"), + "//result[@numFound='1']", + "//doc/str[@name='id'][.='150']"); + } + + @Test + public void testIntervalsBackwardCompatFieldInJsonQuery() throws Exception { + assertU(adoc("id", "160", "v_ws", "bkc_alpha bkc_beta")); + assertU(adoc("id", "161", "v_ws", "bkc_gamma bkc_delta")); + assertU(commit()); + + // Old format {field: rule_object} still works when df is absent + assertQ( + "backward-compatible {field: rule} format should still work when df is absent", + req( + "q", + "{!intervals json_query=q1}", + "json", + "{json_queries:{q1:{v_ws:{term:{value:bkc_alpha}}}}}"), + "//result[@numFound='1']", + "//doc/str[@name='id'][.='160']"); + } } From d42e02cf4ee7ea8a102a21a724b09ee8e9458593 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Jul 2026 08:36:42 +0000 Subject: [PATCH 14/43] Update intervals-query-parser.adoc to document both df and legacy query formats --- .../pages/intervals-query-parser.adoc | 46 +++++++++++++++++-- 1 file changed, 42 insertions(+), 4 deletions(-) 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 index ca64f0e8a4e5..f970c80a939e 100644 --- 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 @@ -19,12 +19,37 @@ The Intervals Query Parser (`IntervalsQParserPlugin`) builds Lucene interval queries from a 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". -Invoked with the syntax `{!intervals json_query=}`. +Invoked with the syntax `{!intervals json_query= df=}`. -== Basic Usage +== Query Formats -The parser reads query definitions from the `json_queries` map inside the JSON request body. -Each named entry must have the form `{: {}}`. +The parser supports two formats for entries in the `json_queries` map. + +=== Field via `df` (preferred) + +Specify the target field using the `df` local param (or the `df` query param as a fallback). +The named entry in `json_queries` is then the rule object directly, with no field wrapper. + +[source,text] +---- +q={!intervals json_query=myQuery df=title} +---- + +[source,json] +---- +{ + "json_queries": { + "myQuery": { + "match": { "query": "apache solr" } + } + } +} +---- + +=== Field embedded in the query (legacy) + +For backward compatibility, the named entry may wrap the rule object in a single-key map whose key is the field name. +In this case the `df` parameter is not required. [source,text] ---- @@ -44,6 +69,8 @@ q={!intervals json_query=myQuery} } ---- +The parser detects the format automatically: if the top-level key of the named entry is a known rule name (e.g., `match`, `all_of`, `term`), the `df` format is used; otherwise the field is taken from the top-level key. + == Parameters `json_query`:: @@ -55,6 +82,17 @@ q={!intervals json_query=myQuery} + Names an entry in the `json_queries` map that defines the interval query to execute. +`df`:: ++ +[%autowidth,frame=none] +|=== +|Optional |Default: none +|=== ++ +The field to run the interval query against. +Required when the named `json_queries` entry uses the field-free format where the top-level key is a rule name. +May be specified as a local param inside `{! }` or as a regular query param. + == Interval Rules Each rule object contains exactly one key that identifies the rule type, mapped to an object of rule parameters. From 81b3464157ecef518fcdb92f477d73b9135768f4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Jul 2026 08:45:24 +0000 Subject: [PATCH 15/43] Remove legacy format support from IntervalsQParserPlugin --- .../solr/search/IntervalsQParserPlugin.java | 77 +++---------------- .../search/TestIntervalsQParserPlugin.java | 20 ++--- .../pages/intervals-query-parser.adoc | 28 +------ 3 files changed, 18 insertions(+), 107 deletions(-) diff --git a/solr/core/src/java/org/apache/solr/search/IntervalsQParserPlugin.java b/solr/core/src/java/org/apache/solr/search/IntervalsQParserPlugin.java index a4e7d965b6a3..bfdf8b7c7c58 100644 --- a/solr/core/src/java/org/apache/solr/search/IntervalsQParserPlugin.java +++ b/solr/core/src/java/org/apache/solr/search/IntervalsQParserPlugin.java @@ -20,7 +20,6 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; -import java.util.Set; import org.apache.lucene.analysis.Analyzer; import org.apache.lucene.queries.intervals.IntervalQuery; import org.apache.lucene.queries.intervals.Intervals; @@ -40,45 +39,14 @@ * {@code {!intervals json_query=foobar df=title}}. * *

The {@code json_query} local param names an entry in the {@code json_queries} map (passed via - * the JSON DSL). The format of the named query is detected automatically: - * - *

    - *
  • New format: the top-level key is a rule name (e.g., {@code match}, {@code all_of}). - * The target field is read from the {@code df} local param, falling back to the {@code df} - * query param. Example: {@code {all_of: {...}}} with {@code df=title}. - *
  • Legacy format: the top-level key is a field name. Example: {@code {title: {all_of: - * {...}}}}. - *
+ * the JSON DSL). The top-level key of the named query must be a rule name (e.g., {@code match}, + * {@code all_of}). The target field is read from the {@code df} local param, falling back to the + * {@code df} query param. Example: {@code {all_of: {...}}} with {@code df=title}. */ public class IntervalsQParserPlugin extends QParserPlugin { public static final String NAME = "intervals"; private static final int DEFAULT_FUZZY_MAX_EXPANSIONS = Intervals.DEFAULT_MAX_EXPANSIONS; - /** - * The set of known interval rule names. Used to detect whether a json_query entry is in the new - * field-via-{@code df} format (top-level key is a rule name) or the legacy {@code {field_name: - * rule_object}} format. - */ - private static final Set RULE_NAMES = - Set.of( - "match", - "prefix", - "wildcard", - "fuzzy", - "all_of", - "any_of", - "term", - "phrase", - "regexp", - "range", - "max_width", - "extend", - "unordered_no_overlaps", - "not_within", - "within", - "at_least", - "no_intervals"); - /** Local param that names the entry in {@code json_queries} to use. */ public static final String JSON_QUERY_PARAM = "json_query"; @@ -114,41 +82,14 @@ public Query parse() { Map queryDefMap = asStringObjectMap(queryDef, "json query definition"); - String field; - Map fieldRule; - if (queryDefMap.size() == 1 - && RULE_NAMES.contains(queryDefMap.entrySet().iterator().next().getKey())) { - // New format: the top-level key is a rule name, so the json_query value is the rule - // object directly. The target field must be supplied via the df local param or the df - // query param. - field = getParam(CommonParams.DF); - if (field == null || field.isEmpty()) { - throw new SolrException( - SolrException.ErrorCode.BAD_REQUEST, - "json_query '" - + jsonQueryName - + "' is in field-free format but no 'df' parameter was provided"); - } - fieldRule = queryDefMap; - } else { - // Legacy format: json_query value is {field_name: rule_object}. - if (queryDefMap.size() != 1) { - throw new SolrException( - SolrException.ErrorCode.BAD_REQUEST, - "Expected exactly one {field: intervals_rule} entry in json_query '" - + jsonQueryName - + "', got " - + queryDefMap.size()); - } - Map.Entry entry = queryDefMap.entrySet().iterator().next(); - field = entry.getKey(); - fieldRule = - asStringObjectMap( - entry.getValue(), - "intervals query for field '" + field + "' in '" + jsonQueryName + "'"); + String field = getParam(CommonParams.DF); + if (field == null || field.isEmpty()) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "json_query '" + jsonQueryName + "' requires a 'df' parameter to specify the field"); } - IntervalsSource source = parseRuleObject(fieldRule, field); + IntervalsSource source = parseRuleObject(queryDefMap, field); return new IntervalQuery(field, source); } diff --git a/solr/core/src/test/org/apache/solr/search/TestIntervalsQParserPlugin.java b/solr/core/src/test/org/apache/solr/search/TestIntervalsQParserPlugin.java index 063e929dbddf..6f904bc9542e 100644 --- a/solr/core/src/test/org/apache/solr/search/TestIntervalsQParserPlugin.java +++ b/solr/core/src/test/org/apache/solr/search/TestIntervalsQParserPlugin.java @@ -17,6 +17,7 @@ package org.apache.solr.search; import org.apache.solr.SolrTestCaseJ4; +import org.apache.solr.common.SolrException; import org.junit.BeforeClass; import org.junit.Test; @@ -344,20 +345,15 @@ public void testIntervalsDfFallbackFromQueryParam() throws Exception { } @Test - public void testIntervalsBackwardCompatFieldInJsonQuery() throws Exception { + public void testIntervalsLegacyFieldInJsonQueryThrows() throws Exception { assertU(adoc("id", "160", "v_ws", "bkc_alpha bkc_beta")); - assertU(adoc("id", "161", "v_ws", "bkc_gamma bkc_delta")); assertU(commit()); - // Old format {field: rule_object} still works when df is absent - assertQ( - "backward-compatible {field: rule} format should still work when df is absent", - req( - "q", - "{!intervals json_query=q1}", - "json", - "{json_queries:{q1:{v_ws:{term:{value:bkc_alpha}}}}}"), - "//result[@numFound='1']", - "//doc/str[@name='id'][.='160']"); + // Old {field: rule_object} format is no longer supported; missing df should throw + assertQEx( + "legacy {field: rule} format without df should throw BAD_REQUEST", + "requires a 'df' parameter", + req("q", "{!intervals json_query=q1}", "json", "{json_queries:{q1:{v_ws:{term:{value:bkc_alpha}}}}}"), + SolrException.ErrorCode.BAD_REQUEST); } } 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 index f970c80a939e..2f46dd1878c8 100644 --- 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 @@ -46,31 +46,6 @@ q={!intervals json_query=myQuery df=title} } ---- -=== Field embedded in the query (legacy) - -For backward compatibility, the named entry may wrap the rule object in a single-key map whose key is the field name. -In this case the `df` parameter is not required. - -[source,text] ----- -q={!intervals json_query=myQuery} ----- - -[source,json] ----- -{ - "json_queries": { - "myQuery": { - "title": { - "match": { "query": "apache solr" } - } - } - } -} ----- - -The parser detects the format automatically: if the top-level key of the named entry is a known rule name (e.g., `match`, `all_of`, `term`), the `df` format is used; otherwise the field is taken from the top-level key. - == Parameters `json_query`:: @@ -90,8 +65,7 @@ Names an entry in the `json_queries` map that defines the interval query to exec |=== + The field to run the interval query against. -Required when the named `json_queries` entry uses the field-free format where the top-level key is a rule name. -May be specified as a local param inside `{! }` or as a regular query param. +Required; may be specified as a local param inside `{! }` or as a regular query param. == Interval Rules From 92ec988fa689e7a513f92a553fba22888ff4e046 Mon Sep 17 00:00:00 2001 From: Mikhail Khludnev Date: Wed, 1 Jul 2026 11:48:37 +0300 Subject: [PATCH 16/43] Rename SOLR-13764-intervals-query-parser-reference-guide.yml to SOLR-13764-intervals-query-parser.yml --- ...-reference-guide.yml => SOLR-13764-intervals-query-parser.yml} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog/unreleased/{SOLR-13764-intervals-query-parser-reference-guide.yml => SOLR-13764-intervals-query-parser.yml} (100%) diff --git a/changelog/unreleased/SOLR-13764-intervals-query-parser-reference-guide.yml b/changelog/unreleased/SOLR-13764-intervals-query-parser.yml similarity index 100% rename from changelog/unreleased/SOLR-13764-intervals-query-parser-reference-guide.yml rename to changelog/unreleased/SOLR-13764-intervals-query-parser.yml From 73e50a9ce0c83b128808c6e626380d2f66dc22dd Mon Sep 17 00:00:00 2001 From: Mikhail Khludnev Date: Wed, 1 Jul 2026 11:52:27 +0300 Subject: [PATCH 17/43] Change type to 'added' for intervals query parser --- changelog/unreleased/SOLR-13764-intervals-query-parser.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog/unreleased/SOLR-13764-intervals-query-parser.yml b/changelog/unreleased/SOLR-13764-intervals-query-parser.yml index 9bd928ab5c35..158d41b6e7b5 100644 --- a/changelog/unreleased/SOLR-13764-intervals-query-parser.yml +++ b/changelog/unreleased/SOLR-13764-intervals-query-parser.yml @@ -1,6 +1,6 @@ # See https://github.com/apache/solr/blob/main/dev-docs/changelog.adoc title: Add Intervals Query Parser -type: other +type: added authors: - name: Mikhail Khludnev nick: mkhludnev From a45e9f4638719ea3f5d7ca38c4e2fdcb9229ce87 Mon Sep 17 00:00:00 2001 From: Mikhail Khludnev Date: Wed, 1 Jul 2026 12:02:21 +0300 Subject: [PATCH 18/43] Revise intervals query parser documentation Updated the section title and removed outdated content regarding query formats. --- .../modules/query-guide/pages/intervals-query-parser.adoc | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) 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 index 2f46dd1878c8..0c3f4bb79f5b 100644 --- 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 @@ -21,11 +21,7 @@ Interval queries allow you to express positional constraints such as "these term Invoked with the syntax `{!intervals json_query= df=}`. -== Query Formats - -The parser supports two formats for entries in the `json_queries` map. - -=== Field via `df` (preferred) +== Query Format Specify the target field using the `df` local param (or the `df` query param as a fallback). The named entry in `json_queries` is then the rule object directly, with no field wrapper. From b9b984604044a6c78a55fef01c3386712a811ea2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Jul 2026 09:05:18 +0000 Subject: [PATCH 19/43] Add Lucene intervals package link to intervals-query-parser.adoc --- .../modules/query-guide/pages/intervals-query-parser.adoc | 1 + 1 file changed, 1 insertion(+) 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 index 0c3f4bb79f5b..b6ce9cfbfd92 100644 --- 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 @@ -18,6 +18,7 @@ The Intervals Query Parser (`IntervalsQParserPlugin`) builds Lucene interval queries from a 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 https://lucene.apache.org/core/10_4_0/queries/org/apache/lucene/queries/intervals/package-summary.html[Lucene Intervals package documentation] for a detailed description of the underlying interval machinery. Invoked with the syntax `{!intervals json_query= df=}`. From 4a7e58f4ad329330fedcfb7f84d9a73e2f247cdd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Jul 2026 09:34:12 +0000 Subject: [PATCH 20/43] Fix spotless formatting in TestIntervalsQParserPlugin --- .../org/apache/solr/search/TestIntervalsQParserPlugin.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/solr/core/src/test/org/apache/solr/search/TestIntervalsQParserPlugin.java b/solr/core/src/test/org/apache/solr/search/TestIntervalsQParserPlugin.java index 6f904bc9542e..c90c57ae6659 100644 --- a/solr/core/src/test/org/apache/solr/search/TestIntervalsQParserPlugin.java +++ b/solr/core/src/test/org/apache/solr/search/TestIntervalsQParserPlugin.java @@ -353,7 +353,11 @@ public void testIntervalsLegacyFieldInJsonQueryThrows() throws Exception { assertQEx( "legacy {field: rule} format without df should throw BAD_REQUEST", "requires a 'df' parameter", - req("q", "{!intervals json_query=q1}", "json", "{json_queries:{q1:{v_ws:{term:{value:bkc_alpha}}}}}"), + req( + "q", + "{!intervals json_query=q1}", + "json", + "{json_queries:{q1:{v_ws:{term:{value:bkc_alpha}}}}}"), SolrException.ErrorCode.BAD_REQUEST); } } From 168ff13fe677bca5e241e6d3e9444379cfbcaece Mon Sep 17 00:00:00 2001 From: Mikhail Khludnev Date: Wed, 1 Jul 2026 12:36:34 +0300 Subject: [PATCH 21/43] Fix the guide --- .../modules/query-guide/pages/intervals-query-parser.adoc | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) 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 index b6ce9cfbfd92..ba538eba6d97 100644 --- 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 @@ -34,7 +34,7 @@ q={!intervals json_query=myQuery df=title} [source,json] ---- -{ +json={ "json_queries": { "myQuery": { "match": { "query": "apache solr" } @@ -721,15 +721,14 @@ Find documents where the `title` field contains the phrase "apache solr" followe [source,text] ---- -q={!intervals json_query=titleQuery} +q={!intervals json_query=titleQuery df=title} ---- [source,json] ---- -{ +json={ "json_queries": { "titleQuery": { - "title": { "all_of": { "ordered": true, "max_gaps": 5, @@ -738,7 +737,6 @@ q={!intervals json_query=titleQuery} { "term": { "value": "search" } } ] } - } } } } From d644ea9180d0b34a5f69d59a6c6d6c53ec0125ab Mon Sep 17 00:00:00 2001 From: Mikhail Khludnev Date: Wed, 1 Jul 2026 12:44:24 +0300 Subject: [PATCH 22/43] Change source format from json to text in intervals query --- .../modules/query-guide/pages/intervals-query-parser.adoc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index ba538eba6d97..f313237c086a 100644 --- 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 @@ -32,7 +32,7 @@ The named entry in `json_queries` is then the rule object directly, with no fiel q={!intervals json_query=myQuery df=title} ---- -[source,json] +[source,text] ---- json={ "json_queries": { @@ -724,7 +724,7 @@ Find documents where the `title` field contains the phrase "apache solr" followe q={!intervals json_query=titleQuery df=title} ---- -[source,json] +[source,text] ---- json={ "json_queries": { From a9a1007461cabc913e0608630931cc498251e004 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Jul 2026 09:57:42 +0000 Subject: [PATCH 23/43] Add intervals vs xmlparser nested query comparison test --- .../search/TestIntervalsQParserPlugin.java | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/solr/core/src/test/org/apache/solr/search/TestIntervalsQParserPlugin.java b/solr/core/src/test/org/apache/solr/search/TestIntervalsQParserPlugin.java index c90c57ae6659..d85855115d3a 100644 --- a/solr/core/src/test/org/apache/solr/search/TestIntervalsQParserPlugin.java +++ b/solr/core/src/test/org/apache/solr/search/TestIntervalsQParserPlugin.java @@ -360,4 +360,51 @@ public void testIntervalsLegacyFieldInJsonQueryThrows() throws Exception { "{json_queries:{q1:{v_ws:{term:{value:bkc_alpha}}}}}"), SolrException.ErrorCode.BAD_REQUEST); } + + @Test + public void testIntervalsNestedAlternativeOutperformsXmlSpans() throws Exception { + assertU(adoc("id", "170", "v_t", "cmplorem cmpthe cmpdomain cmpis cmpipsum")); + assertU( + adoc("id", "171", "v_t", "cmplorem cmpthe cmpdomain cmpname cmpsystem cmpis cmpipsum")); + assertU( + adoc("id", "172", "v_t", "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_t}" + + "" + + "cmpthe" + + "" + + "cmpdomain" + + "" + + "cmpdomain" + + "cmpname" + + "cmpsystem" + + "" + + "" + + "cmpis" + + ""), + "//result[@numFound='1']"); + + assertQ( + "intervals handles the same nested alternative and finds both valid matches", + req( + "q", + "{!intervals json_query=cmpq df=v_t}", + "json", + "{json_queries:{cmpq:{all_of:{ordered:true,intervals:[" + + "{term:{value:cmpthe}}," + + "{any_of:{intervals:[" + + "{term:{value:cmpdomain}}," + + "{phrase:{terms:[cmpdomain,cmpname,cmpsystem]}}" + + "]}}" + + ",{term:{value:cmpis}}" + + "]}}}}"), + "//result[@numFound='2']", + "//doc/str[@name='id'][.='170']", + "//doc/str[@name='id'][.='171']"); + } } From 6d7d239fdbd92bd7154e9fcd0d10e8a7b4d8478f Mon Sep 17 00:00:00 2001 From: Mikhail Khludnev Date: Wed, 1 Jul 2026 13:02:50 +0300 Subject: [PATCH 24/43] Clarify JSON DSL reference in intervals query parser Updated the description to reference JSON DSL documentation. --- .../modules/query-guide/pages/intervals-query-parser.adoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index f313237c086a..ac5d6b6aedae 100644 --- 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 @@ -16,7 +16,7 @@ // specific language governing permissions and limitations // under the License. -The Intervals Query Parser (`IntervalsQParserPlugin`) builds Lucene interval queries from a JSON DSL description. +The Intervals Query Parser (`IntervalsQParserPlugin`) 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 https://lucene.apache.org/core/10_4_0/queries/org/apache/lucene/queries/intervals/package-summary.html[Lucene Intervals package documentation] for a detailed description of the underlying interval machinery. From 5755f591d2ed450a19ce924a9cbd361f265b598b Mon Sep 17 00:00:00 2001 From: Mikhail Khludnev Date: Wed, 1 Jul 2026 13:03:47 +0300 Subject: [PATCH 25/43] Fix typo in author URL field --- changelog/unreleased/SOLR-13764-intervals-query-parser.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog/unreleased/SOLR-13764-intervals-query-parser.yml b/changelog/unreleased/SOLR-13764-intervals-query-parser.yml index 158d41b6e7b5..edc9477856fa 100644 --- a/changelog/unreleased/SOLR-13764-intervals-query-parser.yml +++ b/changelog/unreleased/SOLR-13764-intervals-query-parser.yml @@ -4,7 +4,7 @@ type: added authors: - name: Mikhail Khludnev nick: mkhludnev - utl: https://home.apache.org/phonebook.html?uid=mkhl + url: https://home.apache.org/phonebook.html?uid=mkhl links: - name: SOLR-13764 url: https://issues.apache.org/jira/browse/SOLR-13764 From 5f29a8f9142bd7154a6216f80094a18f81f4189e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Jul 2026 10:22:40 +0000 Subject: [PATCH 26/43] Fix tidy and checkSiteLinks CI failures --- .../org/apache/solr/search/TestIntervalsQParserPlugin.java | 3 +-- .../modules/query-guide/pages/intervals-query-parser.adoc | 6 +++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/solr/core/src/test/org/apache/solr/search/TestIntervalsQParserPlugin.java b/solr/core/src/test/org/apache/solr/search/TestIntervalsQParserPlugin.java index d85855115d3a..27d8d11753b2 100644 --- a/solr/core/src/test/org/apache/solr/search/TestIntervalsQParserPlugin.java +++ b/solr/core/src/test/org/apache/solr/search/TestIntervalsQParserPlugin.java @@ -364,8 +364,7 @@ public void testIntervalsLegacyFieldInJsonQueryThrows() throws Exception { @Test public void testIntervalsNestedAlternativeOutperformsXmlSpans() throws Exception { assertU(adoc("id", "170", "v_t", "cmplorem cmpthe cmpdomain cmpis cmpipsum")); - assertU( - adoc("id", "171", "v_t", "cmplorem cmpthe cmpdomain cmpname cmpsystem cmpis cmpipsum")); + assertU(adoc("id", "171", "v_t", "cmplorem cmpthe cmpdomain cmpname cmpsystem cmpis cmpipsum")); assertU( adoc("id", "172", "v_t", "cmplorem cmpthe cmpdomain cmpblame cmpsystem cmpis cmpipsum")); assertU(commit()); 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 index ac5d6b6aedae..1f78f1a7c370 100644 --- 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 @@ -104,7 +104,7 @@ Analyzes query text and matches documents where the resulting tokens appear acco |`filter` |No |— -|A <<_filter_operators,filter operator>> to apply after matching. +|A <> to apply after matching. |=== Example: @@ -383,7 +383,7 @@ Matches documents where all supplied intervals match in the same field, optional |`filter` |No |— -|A <<_filter_operators,filter operator>> to apply after matching. +|A <> to apply after matching. |=== Example — ordered phrase with a gap: @@ -418,7 +418,7 @@ Matches documents where at least one of the supplied intervals matches. |`filter` |No |— -|A <<_filter_operators,filter operator>> to apply after matching. +|A <> to apply after matching. |=== Example: From 003d263706a052fad4d283a59ba5d4b558457f87 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Jul 2026 10:38:20 +0000 Subject: [PATCH 27/43] Add DistributedQParserTest: cloud-based distributed search test for lucene, dismax, edismax parsers --- .../solr/search/DistributedQParserTest.java | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 solr/core/src/test/org/apache/solr/search/DistributedQParserTest.java 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 000000000000..0cb014927d3a --- /dev/null +++ b/solr/core/src/test/org/apache/solr/search/DistributedQParserTest.java @@ -0,0 +1,93 @@ +/* + * 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.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.response.QueryResponse; +import org.apache.solr.cloud.SolrCloudTestCase; +import org.junit.BeforeClass; +import org.junit.Test; + +/** + * Distributed search tests for the standard query parsers: {@code lucene}, {@code dismax}, and + * {@code edismax}. + */ +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()); + } +} From f8652decb588ca047a64a3ae448a8a08ea7ae35c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Jul 2026 10:44:57 +0000 Subject: [PATCH 28/43] Add testIntervalsQParser to DistributedQParserTest --- .../solr/search/DistributedQParserTest.java | 34 +++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/solr/core/src/test/org/apache/solr/search/DistributedQParserTest.java b/solr/core/src/test/org/apache/solr/search/DistributedQParserTest.java index 0cb014927d3a..ea5710e31c1c 100644 --- a/solr/core/src/test/org/apache/solr/search/DistributedQParserTest.java +++ b/solr/core/src/test/org/apache/solr/search/DistributedQParserTest.java @@ -25,8 +25,8 @@ import org.junit.Test; /** - * Distributed search tests for the standard query parsers: {@code lucene}, {@code dismax}, and - * {@code edismax}. + * Distributed search tests for the standard query parsers: {@code lucene}, {@code dismax}, {@code + * edismax}, and {@code intervals}. */ public class DistributedQParserTest extends SolrCloudTestCase { @@ -90,4 +90,34 @@ public void testEdismaxQParser() throws Exception { .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 QueryRequest( + params( + "q", + "{!intervals json_query=q1 df=subject}", + "json", + "{json_queries:{q1:{match:{query:quick}}}}", + "fl", + "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 QueryRequest( + params( + "q", + "{!intervals json_query=q1 df=subject}", + "json", + "{json_queries:{q1:{all_of:{ordered:true," + + "intervals:[{match:{query:quick}},{match:{query:fox}}]}}}}", + "fl", + "id")) + .process(cluster.getSolrClient(), COLLECTION); + assertEquals(1, response.getResults().getNumFound()); + } } From 88e4c3baacb0340416a4db5a141a7daecd1ab2b8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Jul 2026 11:14:30 +0000 Subject: [PATCH 29/43] fix: reformat DistributedQParserTest.java per spotless rules --- .../apache/solr/search/DistributedQParserTest.java | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/solr/core/src/test/org/apache/solr/search/DistributedQParserTest.java b/solr/core/src/test/org/apache/solr/search/DistributedQParserTest.java index ea5710e31c1c..7a9c36fa9611 100644 --- a/solr/core/src/test/org/apache/solr/search/DistributedQParserTest.java +++ b/solr/core/src/test/org/apache/solr/search/DistributedQParserTest.java @@ -64,14 +64,12 @@ public void testLuceneQParser() throws Exception { @Test public void testDismaxQParser() throws Exception { QueryResponse response = - new QueryRequest( - params("q", "quick", "defType", "dismax", "qf", "subject", "fl", "id")) + 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")) + new QueryRequest(params("q", "brown dog", "defType", "dismax", "qf", "subject", "fl", "id")) .process(cluster.getSolrClient(), COLLECTION); assertEquals(3, response.getResults().getNumFound()); } @@ -79,14 +77,12 @@ public void testDismaxQParser() throws Exception { @Test public void testEdismaxQParser() throws Exception { QueryResponse response = - new QueryRequest( - params("q", "quick", "defType", "edismax", "qf", "subject", "fl", "id")) + 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")) + new QueryRequest(params("q", "brown dog", "defType", "edismax", "qf", "subject", "fl", "id")) .process(cluster.getSolrClient(), COLLECTION); assertEquals(3, response.getResults().getNumFound()); } From 223440d61fe6efce31e63058e76e59a84438af0d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Jul 2026 11:40:22 +0000 Subject: [PATCH 30/43] Fix Spotless formatting violation in DistributedQParserTest.java --- .../test/org/apache/solr/search/DistributedQParserTest.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/solr/core/src/test/org/apache/solr/search/DistributedQParserTest.java b/solr/core/src/test/org/apache/solr/search/DistributedQParserTest.java index 7a9c36fa9611..181df37375ca 100644 --- a/solr/core/src/test/org/apache/solr/search/DistributedQParserTest.java +++ b/solr/core/src/test/org/apache/solr/search/DistributedQParserTest.java @@ -82,7 +82,8 @@ public void testEdismaxQParser() throws Exception { assertEquals(2, response.getResults().getNumFound()); response = - new QueryRequest(params("q", "brown dog", "defType", "edismax", "qf", "subject", "fl", "id")) + new QueryRequest( + params("q", "brown dog", "defType", "edismax", "qf", "subject", "fl", "id")) .process(cluster.getSolrClient(), COLLECTION); assertEquals(3, response.getResults().getNumFound()); } From 11ccd1e3556a9eb63157264e8e1a5f4e4c19b7e2 Mon Sep 17 00:00:00 2001 From: Mikhail Khludnev Date: Wed, 1 Jul 2026 16:14:23 +0300 Subject: [PATCH 31/43] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../java/org/apache/solr/search/IntervalsQParserPlugin.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/solr/core/src/java/org/apache/solr/search/IntervalsQParserPlugin.java b/solr/core/src/java/org/apache/solr/search/IntervalsQParserPlugin.java index bfdf8b7c7c58..c4b1eff9b1a9 100644 --- a/solr/core/src/java/org/apache/solr/search/IntervalsQParserPlugin.java +++ b/solr/core/src/java/org/apache/solr/search/IntervalsQParserPlugin.java @@ -154,8 +154,8 @@ private IntervalsSource parsePrefixRule(Map params, String topFi String prefix = requireString(params, "prefix", "prefix"); String useField = getOptionalString(params, "use_field", "prefix"); String field = useField == null ? topField : useField; - Analyzer analyzer = resolveAnalyzer(params, field, "prefix"); - String normalizedPrefix = normalizeMultiTerm(field, prefix, analyzer); +Analyzer analyzer = params.get("analyzer") == null ? null : resolveAnalyzer(params, field, "prefix"); +String normalizedPrefix = normalizeMultiTerm(field, prefix, analyzer); IntervalsSource source = Intervals.prefix(new BytesRef(normalizedPrefix)); if (useField != null) { source = Intervals.fixField(useField, source); From ce87c95bc771e7a0c140c10712d07cbe05b44fe4 Mon Sep 17 00:00:00 2001 From: Mikhail Khludnev Date: Wed, 1 Jul 2026 16:32:22 +0300 Subject: [PATCH 32/43] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../apache/solr/search/IntervalsQParserPlugin.java | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/solr/core/src/java/org/apache/solr/search/IntervalsQParserPlugin.java b/solr/core/src/java/org/apache/solr/search/IntervalsQParserPlugin.java index c4b1eff9b1a9..ea6ebb465e0a 100644 --- a/solr/core/src/java/org/apache/solr/search/IntervalsQParserPlugin.java +++ b/solr/core/src/java/org/apache/solr/search/IntervalsQParserPlugin.java @@ -154,8 +154,9 @@ private IntervalsSource parsePrefixRule(Map params, String topFi String prefix = requireString(params, "prefix", "prefix"); String useField = getOptionalString(params, "use_field", "prefix"); String field = useField == null ? topField : useField; -Analyzer analyzer = params.get("analyzer") == null ? null : resolveAnalyzer(params, field, "prefix"); -String normalizedPrefix = normalizeMultiTerm(field, prefix, analyzer); + Analyzer analyzer = + params.get("analyzer") == null ? null : resolveAnalyzer(params, field, "prefix"); + String normalizedPrefix = normalizeMultiTerm(field, prefix, analyzer); IntervalsSource source = Intervals.prefix(new BytesRef(normalizedPrefix)); if (useField != null) { source = Intervals.fixField(useField, source); @@ -167,7 +168,8 @@ private IntervalsSource parseWildcardRule(Map params, String top String pattern = requireString(params, "pattern", "wildcard"); String useField = getOptionalString(params, "use_field", "wildcard"); String field = useField == null ? topField : useField; - Analyzer analyzer = resolveAnalyzer(params, field, "wildcard"); + Analyzer analyzer = + params.get("analyzer") == null ? null : resolveAnalyzer(params, field, "wildcard"); String normalizedPattern = normalizeMultiTerm(field, pattern, analyzer); IntervalsSource source = Intervals.wildcard(new BytesRef(normalizedPattern)); if (useField != null) { @@ -180,7 +182,8 @@ private IntervalsSource parseFuzzyRule(Map params, String topFie String term = requireString(params, "term", "fuzzy"); String useField = getOptionalString(params, "use_field", "fuzzy"); String field = useField == null ? topField : useField; - Analyzer analyzer = resolveAnalyzer(params, field, "fuzzy"); + Analyzer analyzer = + params.get("analyzer") == null ? null : resolveAnalyzer(params, field, "fuzzy"); String normalizedTerm = normalizeMultiTerm(field, term, analyzer); String fuzziness = getOptionalString(params, "fuzziness", "fuzzy"); @@ -430,7 +433,7 @@ private IntervalsSource applyFilter( private Analyzer resolveAnalyzer(Map params, String field, String ruleName) { String analyzerName = getOptionalString(params, "analyzer", ruleName); if (analyzerName == null) { - return req.getSchema().getQueryAnalyzer(); + return req.getSchema().getFieldTypeNoEx(field).getQueryAnalyzer(); } FieldType fieldType = req.getSchema().getFieldTypeByName(analyzerName); if (fieldType == null) { From 6f2f3ce4af25bdeab4d3abdfaf2c5dd9e2ee9b12 Mon Sep 17 00:00:00 2001 From: Mikhail Khludnev Date: Wed, 1 Jul 2026 18:00:52 +0300 Subject: [PATCH 33/43] multiterm analyzer and QueryEqualityTest --- .../solr/search/IntervalsQParserPlugin.java | 45 ++++++++++++------- .../apache/solr/search/QueryEqualityTest.java | 11 +++++ .../pages/intervals-query-parser.adoc | 2 +- 3 files changed, 41 insertions(+), 17 deletions(-) diff --git a/solr/core/src/java/org/apache/solr/search/IntervalsQParserPlugin.java b/solr/core/src/java/org/apache/solr/search/IntervalsQParserPlugin.java index ea6ebb465e0a..1f5921e0c2a1 100644 --- a/solr/core/src/java/org/apache/solr/search/IntervalsQParserPlugin.java +++ b/solr/core/src/java/org/apache/solr/search/IntervalsQParserPlugin.java @@ -154,8 +154,7 @@ private IntervalsSource parsePrefixRule(Map params, String topFi String prefix = requireString(params, "prefix", "prefix"); String useField = getOptionalString(params, "use_field", "prefix"); String field = useField == null ? topField : useField; - Analyzer analyzer = - params.get("analyzer") == null ? null : resolveAnalyzer(params, field, "prefix"); + Analyzer analyzer = resolveMultiTermAnalyzer(params, field, "prefix"); String normalizedPrefix = normalizeMultiTerm(field, prefix, analyzer); IntervalsSource source = Intervals.prefix(new BytesRef(normalizedPrefix)); if (useField != null) { @@ -168,8 +167,7 @@ private IntervalsSource parseWildcardRule(Map params, String top String pattern = requireString(params, "pattern", "wildcard"); String useField = getOptionalString(params, "use_field", "wildcard"); String field = useField == null ? topField : useField; - Analyzer analyzer = - params.get("analyzer") == null ? null : resolveAnalyzer(params, field, "wildcard"); + Analyzer analyzer = resolveMultiTermAnalyzer(params, field, "wildcard"); String normalizedPattern = normalizeMultiTerm(field, pattern, analyzer); IntervalsSource source = Intervals.wildcard(new BytesRef(normalizedPattern)); if (useField != null) { @@ -182,8 +180,7 @@ private IntervalsSource parseFuzzyRule(Map params, String topFie String term = requireString(params, "term", "fuzzy"); String useField = getOptionalString(params, "use_field", "fuzzy"); String field = useField == null ? topField : useField; - Analyzer analyzer = - params.get("analyzer") == null ? null : resolveAnalyzer(params, field, "fuzzy"); + Analyzer analyzer = resolveMultiTermAnalyzer(params, field, "fuzzy"); String normalizedTerm = normalizeMultiTerm(field, term, analyzer); String fuzziness = getOptionalString(params, "fuzziness", "fuzzy"); @@ -435,6 +432,29 @@ private Analyzer resolveAnalyzer(Map params, String field, Strin if (analyzerName == null) { return req.getSchema().getFieldTypeNoEx(field).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 params, String field, String ruleName) { + String analyzerName = getOptionalString(params, "analyzer", ruleName); + FieldType fieldType = + analyzerName == null + ? req.getSchema().getFieldTypeNoEx(field) + : 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( @@ -445,21 +465,14 @@ private Analyzer resolveAnalyzer(Map params, String field, Strin + ruleName + "'. In Solr this value must match a field type name."); } - return fieldType.getQueryAnalyzer(); + return fieldType; } private String normalizeMultiTerm(String field, String term, Analyzer analyzer) { - Analyzer effective = analyzer; - if (effective == null) { - FieldType fieldType = req.getSchema().getFieldTypeNoEx(field); - if (fieldType instanceof TextField textField) { - effective = textField.getMultiTermAnalyzer(); - } - } - if (effective == null) { + if (analyzer == null) { return term; } - BytesRef analyzed = TextField.analyzeMultiTerm(field, term, effective); + BytesRef analyzed = TextField.analyzeMultiTerm(field, term, analyzer); return analyzed == null ? term : analyzed.utf8ToString(); } 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 ecad83f39de0..941424569208 100644 --- a/solr/core/src/test/org/apache/solr/search/QueryEqualityTest.java +++ b/solr/core/src/test/org/apache/solr/search/QueryEqualityTest.java @@ -174,6 +174,17 @@ public void testQueryFuzzy() throws Exception { } } + public void testQueryIntervals() throws Exception { + try (SolrQueryRequest req = req("myField", "foo_s")) { + req.setJSON(Map.of("json_queries", Map.of("q1", Map.of("term", Map.of("value", "asdf"))))); + assertQueryEquals( + IntervalsQParserPlugin.NAME, + req, + "{!intervals json_query=q1 df=$myField}", + "{!intervals json_query=q1 df=foo_s}"); + } + } + public void testQueryBoost() throws Exception { SolrQueryRequest req = req("df", "foo_s", "myBoost", "sum(3,foo_i)"); try { 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 index 1f78f1a7c370..c3fdc742b8ca 100644 --- 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 @@ -58,7 +58,7 @@ Names an entry in the `json_queries` map that defines the interval query to exec + [%autowidth,frame=none] |=== -|Optional |Default: none +|Required |Default: none |=== + The field to run the interval query against. From a61b90f71c46013aa614eddbf684875ad99823b5 Mon Sep 17 00:00:00 2001 From: Mikhail Khludnev Date: Wed, 1 Jul 2026 18:37:22 +0300 Subject: [PATCH 34/43] fix tests --- .../search/TestIntervalsQParserPlugin.java | 43 +++++++++++-------- 1 file changed, 26 insertions(+), 17 deletions(-) diff --git a/solr/core/src/test/org/apache/solr/search/TestIntervalsQParserPlugin.java b/solr/core/src/test/org/apache/solr/search/TestIntervalsQParserPlugin.java index 27d8d11753b2..1eb9d0c4bf84 100644 --- a/solr/core/src/test/org/apache/solr/search/TestIntervalsQParserPlugin.java +++ b/solr/core/src/test/org/apache/solr/search/TestIntervalsQParserPlugin.java @@ -349,13 +349,15 @@ 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; missing df should throw + // Old {field: rule_object} format is no longer supported: even with a valid df, 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 without df should throw BAD_REQUEST", - "requires a 'df' parameter", + "legacy {field: rule} format should throw BAD_REQUEST for an unrecognized rule name", + "Unsupported intervals rule: v_ws", req( "q", - "{!intervals json_query=q1}", + "{!intervals json_query=q1 df=v_ws}", "json", "{json_queries:{q1:{v_ws:{term:{value:bkc_alpha}}}}}"), SolrException.ErrorCode.BAD_REQUEST); @@ -363,28 +365,32 @@ public void testIntervalsLegacyFieldInJsonQueryThrows() throws Exception { @Test public void testIntervalsNestedAlternativeOutperformsXmlSpans() throws Exception { - assertU(adoc("id", "170", "v_t", "cmplorem cmpthe cmpdomain cmpis cmpipsum")); - assertU(adoc("id", "171", "v_t", "cmplorem cmpthe cmpdomain cmpname cmpsystem cmpis cmpipsum")); + // 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_t", "cmplorem cmpthe cmpdomain cmpblame cmpsystem cmpis cmpipsum")); + 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_t}" - + "" - + "cmpthe" + "{!xmlparser df=v_ws}" + + "" + + "cmpthe" + "" - + "cmpdomain" + + "cmpdomain" + "" - + "cmpdomain" - + "cmpname" - + "cmpsystem" + + "cmpdomain" + + "cmpname" + + "cmpsystem" + "" + "" - + "cmpis" + + "cmpis" + ""), "//result[@numFound='1']"); @@ -392,9 +398,12 @@ public void testIntervalsNestedAlternativeOutperformsXmlSpans() throws Exception "intervals handles the same nested alternative and finds both valid matches", req( "q", - "{!intervals json_query=cmpq df=v_t}", + "{!intervals json_query=cmpq df=v_ws}", "json", - "{json_queries:{cmpq:{all_of:{ordered:true,intervals:[" + // max_gaps:0 mirrors the xmlparser query's slop="0": the whole sequence must be + // contiguous, so doc 172 (where cmpblame/cmpsystem separate cmpdomain from cmpis) + // is correctly excluded even though it contains "cmpdomain" in isolation. + "{json_queries:{cmpq:{all_of:{ordered:true,max_gaps:0,intervals:[" + "{term:{value:cmpthe}}," + "{any_of:{intervals:[" + "{term:{value:cmpdomain}}," From 2f3141bc6479f6225f4f08a5f9db2f804bb6c72a Mon Sep 17 00:00:00 2001 From: Mikhail Khludnev Date: Thu, 2 Jul 2026 10:52:47 +0300 Subject: [PATCH 35/43] review --- solr/.vscode/settings.json | 3 ++ .../solr/search/IntervalsQParserPlugin.java | 25 ++++++++-- .../solr/search/DistributedQParserTest.java | 49 ++++++++++++++++++- .../search/TestIntervalsQParserPlugin.java | 21 ++++++++ .../pages/intervals-query-parser.adoc | 4 +- 5 files changed, 96 insertions(+), 6 deletions(-) create mode 100644 solr/.vscode/settings.json diff --git a/solr/.vscode/settings.json b/solr/.vscode/settings.json new file mode 100644 index 000000000000..7b016a89fbaf --- /dev/null +++ b/solr/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "java.compile.nullAnalysis.mode": "automatic" +} \ No newline at end of file diff --git a/solr/core/src/java/org/apache/solr/search/IntervalsQParserPlugin.java b/solr/core/src/java/org/apache/solr/search/IntervalsQParserPlugin.java index 1f5921e0c2a1..c5d00b388996 100644 --- a/solr/core/src/java/org/apache/solr/search/IntervalsQParserPlugin.java +++ b/solr/core/src/java/org/apache/solr/search/IntervalsQParserPlugin.java @@ -50,6 +50,9 @@ public class IntervalsQParserPlugin extends QParserPlugin { /** Local param that names the entry in {@code json_queries} to use. */ public static final String JSON_QUERY_PARAM = "json_query"; + /** Top-level JSON key holding the map of named interval query definitions. */ + private static final String JSON_QUERIES_KEY = "json_queries"; + @Override public QParser createParser( String qstr, SolrParams localParams, SolrParams params, SolrQueryRequest req) { @@ -66,9 +69,9 @@ public Query parse() { return new MatchNoDocsQuery("No JSON parameters found"); } - Object jsonQueriesObj = json.get("json_queries"); + Object jsonQueriesObj = json.get(JSON_QUERIES_KEY); if (!(jsonQueriesObj instanceof Map)) { - return new MatchNoDocsQuery("No json_queries map found in JSON parameters"); + return new MatchNoDocsQuery("No " + JSON_QUERIES_KEY + " map found in JSON parameters"); } @SuppressWarnings("unchecked") @@ -77,7 +80,11 @@ public Query parse() { if (!(queryDef instanceof Map)) { return new MatchNoDocsQuery( - "Query '" + jsonQueryName + "' not found in json_queries or is not a map"); + "Query '" + + jsonQueryName + + "' not found in " + + JSON_QUERIES_KEY + + " or is not a map"); } Map queryDefMap = asStringObjectMap(queryDef, "json query definition"); @@ -270,6 +277,12 @@ private IntervalsSource parseRegexpRule(Map params, String topFi 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(useField, source); @@ -284,6 +297,12 @@ private IntervalsSource parseRangeRule(Map params, String topFie 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); diff --git a/solr/core/src/test/org/apache/solr/search/DistributedQParserTest.java b/solr/core/src/test/org/apache/solr/search/DistributedQParserTest.java index 181df37375ca..094876a2d70f 100644 --- a/solr/core/src/test/org/apache/solr/search/DistributedQParserTest.java +++ b/solr/core/src/test/org/apache/solr/search/DistributedQParserTest.java @@ -97,12 +97,30 @@ public void testIntervalsQParser() throws Exception { "q", "{!intervals json_query=q1 df=subject}", "json", - "{json_queries:{q1:{match:{query:quick}}}}", + "{json_queries:{q1:{match:{query:quick}}"+ + (nextBoolean()? ",ignore:{match:{query:lazy}}":"")+ + "}}", "fl", "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 QueryRequest( + params( + "q", + "{!intervals json_query=q1 df=subject}", + "json", + "{json_queries:{q1:{match:{query:lazy}}"+ + (nextBoolean()? ",ignore:{match:{query:quick}}":"")+, + "fl", + "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 QueryRequest( @@ -116,5 +134,34 @@ public void testIntervalsQParser() throws Exception { "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. Note: the leading space before the first "{!" is required, otherwise the + // whole q string is parsed as a single set of local params rather than two clauses. + response = + new QueryRequest( + params( + "q", + " {!intervals json_query=q1 df=subject} {!intervals json_query=q2 df=subject}", + "json", + "{json_queries:{q1:{match:{query:quick}},q2:{match:{query:lazy}}}}", + "fl", + "id")) + .process(cluster.getSolrClient(), COLLECTION); + assertEquals(3, response.getResults().getNumFound()); + + // intersection of two top-level interval queries (using "+" to require both clauses): + // "quick" (docs 1, 3) and "brown" (docs 1, 2) — only doc 1 has both terms + response = + new QueryRequest( + params( + "q", + " +{!intervals json_query=q1 df=subject} +{!intervals json_query=q2 df=subject}", + "json", + "{json_queries:{q1:{match:{query:quick}},q2:{match:{query:brown}}}}", + "fl", + "id")) + .process(cluster.getSolrClient(), COLLECTION); + assertEquals(1, response.getResults().getNumFound()); } } diff --git a/solr/core/src/test/org/apache/solr/search/TestIntervalsQParserPlugin.java b/solr/core/src/test/org/apache/solr/search/TestIntervalsQParserPlugin.java index 1eb9d0c4bf84..aeb611f12a7e 100644 --- a/solr/core/src/test/org/apache/solr/search/TestIntervalsQParserPlugin.java +++ b/solr/core/src/test/org/apache/solr/search/TestIntervalsQParserPlugin.java @@ -172,6 +172,16 @@ public void testIntervalsRegexpRule() throws Exception { "json", "{json_queries:{q1:{regexp:{pattern:'rx_ca.*'}}}}"), "//result[@numFound='2']"); + + assertQEx( + "regexp rule should reject a negative max_expansions with BAD_REQUEST", + "max_expansions", + req( + "q", + "{!intervals json_query=q1 df=v_ws}", + "json", + "{json_queries:{q1:{regexp:{pattern:'rx_ca.*',max_expansions:-1}}}}"), + SolrException.ErrorCode.BAD_REQUEST); } @Test @@ -193,6 +203,17 @@ public void testIntervalsRangeRule() throws Exception { "//result[@numFound='2']", "//doc/str[@name='id'][.='71']", "//doc/str[@name='id'][.='72']"); + + assertQEx( + "range rule should reject a negative max_expansions with BAD_REQUEST", + "max_expansions", + req( + "q", + "{!intervals json_query=q1 df=v_ws}", + "json", + "{json_queries:{q1:{range:{lower_term:rng_bbbb,upper_term:rng_cccc," + + "include_lower:true,include_upper:true,max_expansions:-1}}}}"), + SolrException.ErrorCode.BAD_REQUEST); } @Test 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 index c3fdc742b8ca..c9f8eae5d43d 100644 --- 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 @@ -18,7 +18,7 @@ The Intervals Query Parser (`IntervalsQParserPlugin`) 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 https://lucene.apache.org/core/10_4_0/queries/org/apache/lucene/queries/intervals/package-summary.html[Lucene Intervals package documentation] for a detailed description of the underlying interval machinery. +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. Invoked with the syntax `{!intervals json_query= df=}`. @@ -79,7 +79,7 @@ Analyzes query text and matches documents where the resulting tokens appear acco |`query` |Yes |— -|The text to analyse and match. +|The text to analyze and match. |`max_gaps` |No From da96d7c3f35322b88ce680a5ef7d2e1ce3d2abde Mon Sep 17 00:00:00 2001 From: Mikhail Khludnev Date: Thu, 2 Jul 2026 11:34:21 +0300 Subject: [PATCH 36/43] Delete solr/.vscode/settings.json --- solr/.vscode/settings.json | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 solr/.vscode/settings.json diff --git a/solr/.vscode/settings.json b/solr/.vscode/settings.json deleted file mode 100644 index 7b016a89fbaf..000000000000 --- a/solr/.vscode/settings.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "java.compile.nullAnalysis.mode": "automatic" -} \ No newline at end of file From 50130b5dfc99e806e5a0a0aa0778fc3693e0f840 Mon Sep 17 00:00:00 2001 From: Mikhail Khludnev Date: Thu, 2 Jul 2026 12:37:28 +0300 Subject: [PATCH 37/43] fix test compile --- .../org/apache/solr/search/DistributedQParserTest.java | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/solr/core/src/test/org/apache/solr/search/DistributedQParserTest.java b/solr/core/src/test/org/apache/solr/search/DistributedQParserTest.java index 094876a2d70f..6c0701b4d035 100644 --- a/solr/core/src/test/org/apache/solr/search/DistributedQParserTest.java +++ b/solr/core/src/test/org/apache/solr/search/DistributedQParserTest.java @@ -97,9 +97,8 @@ public void testIntervalsQParser() throws Exception { "q", "{!intervals json_query=q1 df=subject}", "json", - "{json_queries:{q1:{match:{query:quick}}"+ - (nextBoolean()? ",ignore:{match:{query:lazy}}":"")+ - "}}", + "{json_queries:{q1:{match:{query:quick}}" + + (random().nextBoolean() ? ",ignore:{match:{query:lazy}}}}" : "}}"), "fl", "id")) .process(cluster.getSolrClient(), COLLECTION); @@ -113,8 +112,8 @@ public void testIntervalsQParser() throws Exception { "q", "{!intervals json_query=q1 df=subject}", "json", - "{json_queries:{q1:{match:{query:lazy}}"+ - (nextBoolean()? ",ignore:{match:{query:quick}}":"")+, + "{json_queries:{q1:{match:{query:lazy}}" + + (random().nextBoolean() ? ",ignore:{match:{query:quick}}}}" : "}}"), "fl", "id")) .process(cluster.getSolrClient(), COLLECTION); From abde6b27bbbe718567b83b885f276980ed018fab Mon Sep 17 00:00:00 2001 From: Mikhail Khludnev Date: Thu, 2 Jul 2026 14:26:50 +0300 Subject: [PATCH 38/43] check prefix len --- .../solr/search/IntervalsQParserPlugin.java | 5 ++++ .../search/TestIntervalsQParserPlugin.java | 29 +++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/solr/core/src/java/org/apache/solr/search/IntervalsQParserPlugin.java b/solr/core/src/java/org/apache/solr/search/IntervalsQParserPlugin.java index c5d00b388996..b158b1db5a2a 100644 --- a/solr/core/src/java/org/apache/solr/search/IntervalsQParserPlugin.java +++ b/solr/core/src/java/org/apache/solr/search/IntervalsQParserPlugin.java @@ -193,6 +193,11 @@ private IntervalsSource parseFuzzyRule(Map params, String topFie 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 = diff --git a/solr/core/src/test/org/apache/solr/search/TestIntervalsQParserPlugin.java b/solr/core/src/test/org/apache/solr/search/TestIntervalsQParserPlugin.java index aeb611f12a7e..185c28ad616d 100644 --- a/solr/core/src/test/org/apache/solr/search/TestIntervalsQParserPlugin.java +++ b/solr/core/src/test/org/apache/solr/search/TestIntervalsQParserPlugin.java @@ -216,6 +216,35 @@ public void testIntervalsRangeRule() throws Exception { 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()); + + assertQ( + "fuzzy rule should match documents with terms within the edit distance", + req( + "q", + "{!intervals json_query=q1 df=v_ws}", + "json", + "{json_queries:{q1:{fuzzy:{term:fzz_cat,fuzziness:'1'}}}}"), + "//result[@numFound='2']", + "//doc/str[@name='id'][.='80']", + "//doc/str[@name='id'][.='81']"); + + assertQEx( + "fuzzy rule should reject a negative prefix_length with BAD_REQUEST", + "prefix_length", + req( + "q", + "{!intervals json_query=q1 df=v_ws}", + "json", + "{json_queries:{q1:{fuzzy:{term:fzz_cat,prefix_length:-1}}}}"), + SolrException.ErrorCode.BAD_REQUEST); + } + @Test public void testIntervalsMaxWidthRule() throws Exception { assertU(adoc("id", "80", "v_ws", "mwd_alpha mwd_beta mwd_gamma")); From e52214df2cf7a483737b8b3af32017c6936b869b Mon Sep 17 00:00:00 2001 From: Mikhail Khludnev Date: Fri, 3 Jul 2026 00:27:27 +0300 Subject: [PATCH 39/43] change syntax to {!intervals df=title}$foobar extract JSON_QUERIES_KEY constant and update documentation for new syntax --- .../apache/solr/request/json/RequestUtil.java | 8 +- .../solr/search/IntervalsQParserPlugin.java | 53 +++++---- .../solr/search/DistributedQParserTest.java | 10 +- .../apache/solr/search/QueryEqualityTest.java | 4 +- .../search/TestIntervalsQParserPlugin.java | 106 ++++++++++++------ .../pages/intervals-query-parser.adoc | 16 ++- .../query-guide/pages/json-request-api.adoc | 2 + .../query-guide/pages/other-parsers.adoc | 2 +- 8 files changed, 132 insertions(+), 69 deletions(-) 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 2302f102fe56..60f53793db36 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 @@ -38,6 +38,12 @@ import org.noggit.ObjectBuilder; public class RequestUtil { + /** + * Top-level JSON key holding the map of named query definitions, referenced elsewhere (e.g. by + * {@link org.apache.solr.search.IntervalsQParserPlugin}) via a {@code $name} local param value. + */ + public static final String JSON_QUERIES_KEY = "json_queries"; + /** * Set default-ish params on a SolrQueryRequest as well as do standard macro processing and JSON * request parsing. @@ -254,7 +260,7 @@ public static void processParams( SolrException.ErrorCode.BAD_REQUEST, "Expected Map for 'queries', received " + queriesJsonObj); } - } else if ("json_queries".equals(key)) { + } else if (JSON_QUERIES_KEY.equals(key)) { // passed through as a parsed object for use by SearchComponent.prepare() at subordinate // nodes; not processed here continue; diff --git a/solr/core/src/java/org/apache/solr/search/IntervalsQParserPlugin.java b/solr/core/src/java/org/apache/solr/search/IntervalsQParserPlugin.java index b158b1db5a2a..3887f5115827 100644 --- a/solr/core/src/java/org/apache/solr/search/IntervalsQParserPlugin.java +++ b/solr/core/src/java/org/apache/solr/search/IntervalsQParserPlugin.java @@ -24,34 +24,35 @@ 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.MatchNoDocsQuery; 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.request.SolrQueryRequest; +import org.apache.solr.request.json.RequestUtil; import org.apache.solr.schema.FieldType; 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_query=foobar df=title}}. + * {@code {!intervals df=title}$foobar}. * - *

The {@code json_query} local param names an entry in the {@code json_queries} map (passed via - * the JSON DSL). The top-level key of the named query must be a rule name (e.g., {@code match}, - * {@code all_of}). The target field is read from the {@code df} local param, falling back to the - * {@code df} query param. Example: {@code {all_of: {...}}} with {@code df=title}. + *

The {@code $foobar} reference (the query string following the local params) names an entry in + * the {@code json_queries} map (passed via the JSON DSL). The top-level key of the named query must + * be a rule name (e.g., {@code match}, {@code all_of}). The target field is read from the {@code + * df} local param, falling back to the {@code df} query param. Example: {@code {all_of: {...}}} + * with {@code df=title}. */ public class IntervalsQParserPlugin extends QParserPlugin { public static final String NAME = "intervals"; private static final int DEFAULT_FUZZY_MAX_EXPANSIONS = Intervals.DEFAULT_MAX_EXPANSIONS; - /** Local param that names the entry in {@code json_queries} to use. */ - public static final String JSON_QUERY_PARAM = "json_query"; - - /** Top-level JSON key holding the map of named interval query definitions. */ - private static final String JSON_QUERIES_KEY = "json_queries"; + /** Syntax reminder included in exceptions thrown for malformed/missing input. */ + private static final String SYNTAX_HELP = + "Expected syntax '{!intervals df=}$', where refers to an entry " + + "in the 'json_queries' map of the JSON request body, e.g. " + + "q={!intervals df=title}$myQuery with json={\"json_queries\":{\"myQuery\":{...}}}"; @Override public QParser createParser( @@ -59,19 +60,25 @@ public QParser createParser( return new QParser(qstr, localParams, params, req) { @Override public Query parse() { - String jsonQueryName = localParams.get(JSON_QUERY_PARAM); - if (jsonQueryName == null) { - return new MatchNoDocsQuery("No " + JSON_QUERY_PARAM + " parameter specified"); + if (qstr == null || qstr.isEmpty() || qstr.charAt(0) != '$' || qstr.length() < 2) { + throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, SYNTAX_HELP); } + String jsonQueryName = qstr.substring(1); Map json = req.getJSON(); if (json == null) { - return new MatchNoDocsQuery("No JSON parameters found"); + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, "No JSON request body found; " + SYNTAX_HELP); } - Object jsonQueriesObj = json.get(JSON_QUERIES_KEY); + Object jsonQueriesObj = json.get(RequestUtil.JSON_QUERIES_KEY); if (!(jsonQueriesObj instanceof Map)) { - return new MatchNoDocsQuery("No " + JSON_QUERIES_KEY + " map found in JSON parameters"); + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "No '" + + RequestUtil.JSON_QUERIES_KEY + + "' map found in JSON request body; " + + SYNTAX_HELP); } @SuppressWarnings("unchecked") @@ -79,12 +86,14 @@ public Query parse() { Object queryDef = jsonQueries.get(jsonQueryName); if (!(queryDef instanceof Map)) { - return new MatchNoDocsQuery( + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, "Query '" + jsonQueryName - + "' not found in " - + JSON_QUERIES_KEY - + " or is not a map"); + + "' not found in '" + + RequestUtil.JSON_QUERIES_KEY + + "' or is not an object; " + + SYNTAX_HELP); } Map queryDefMap = asStringObjectMap(queryDef, "json query definition"); @@ -93,7 +102,7 @@ public Query parse() { if (field == null || field.isEmpty()) { throw new SolrException( SolrException.ErrorCode.BAD_REQUEST, - "json_query '" + jsonQueryName + "' requires a 'df' parameter to specify the field"); + "Query '" + jsonQueryName + "' requires a 'df' parameter to specify the field"); } IntervalsSource source = parseRuleObject(queryDefMap, field); diff --git a/solr/core/src/test/org/apache/solr/search/DistributedQParserTest.java b/solr/core/src/test/org/apache/solr/search/DistributedQParserTest.java index 6c0701b4d035..a70d8e9aadd9 100644 --- a/solr/core/src/test/org/apache/solr/search/DistributedQParserTest.java +++ b/solr/core/src/test/org/apache/solr/search/DistributedQParserTest.java @@ -95,7 +95,7 @@ public void testIntervalsQParser() throws Exception { new QueryRequest( params( "q", - "{!intervals json_query=q1 df=subject}", + "{!intervals df=subject}$q1", "json", "{json_queries:{q1:{match:{query:quick}}" + (random().nextBoolean() ? ",ignore:{match:{query:lazy}}}}" : "}}"), @@ -110,7 +110,7 @@ public void testIntervalsQParser() throws Exception { new QueryRequest( params( "q", - "{!intervals json_query=q1 df=subject}", + "{!intervals df=subject}$q1", "json", "{json_queries:{q1:{match:{query:lazy}}" + (random().nextBoolean() ? ",ignore:{match:{query:quick}}}}" : "}}"), @@ -125,7 +125,7 @@ public void testIntervalsQParser() throws Exception { new QueryRequest( params( "q", - "{!intervals json_query=q1 df=subject}", + "{!intervals df=subject}$q1", "json", "{json_queries:{q1:{all_of:{ordered:true," + "intervals:[{match:{query:quick}},{match:{query:fox}}]}}}}", @@ -141,7 +141,7 @@ public void testIntervalsQParser() throws Exception { new QueryRequest( params( "q", - " {!intervals json_query=q1 df=subject} {!intervals json_query=q2 df=subject}", + " {!intervals df=subject}$q1 {!intervals df=subject}$q2", "json", "{json_queries:{q1:{match:{query:quick}},q2:{match:{query:lazy}}}}", "fl", @@ -155,7 +155,7 @@ public void testIntervalsQParser() throws Exception { new QueryRequest( params( "q", - " +{!intervals json_query=q1 df=subject} +{!intervals json_query=q2 df=subject}", + " +{!intervals df=subject}$q1 +{!intervals df=subject}$q2", "json", "{json_queries:{q1:{match:{query:quick}},q2:{match:{query:brown}}}}", "fl", 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 941424569208..31a75ecd364a 100644 --- a/solr/core/src/test/org/apache/solr/search/QueryEqualityTest.java +++ b/solr/core/src/test/org/apache/solr/search/QueryEqualityTest.java @@ -180,8 +180,8 @@ public void testQueryIntervals() throws Exception { assertQueryEquals( IntervalsQParserPlugin.NAME, req, - "{!intervals json_query=q1 df=$myField}", - "{!intervals json_query=q1 df=foo_s}"); + "{!intervals df=$myField}$q1", + "{!intervals df=foo_s}$q1"); } } diff --git a/solr/core/src/test/org/apache/solr/search/TestIntervalsQParserPlugin.java b/solr/core/src/test/org/apache/solr/search/TestIntervalsQParserPlugin.java index 185c28ad616d..58a3e211ab3f 100644 --- a/solr/core/src/test/org/apache/solr/search/TestIntervalsQParserPlugin.java +++ b/solr/core/src/test/org/apache/solr/search/TestIntervalsQParserPlugin.java @@ -30,15 +30,59 @@ public static void beforeClass() throws Exception { } @Test - public void testIntervalsNoJsonQueryParam() throws Exception { + public void testIntervalsMissingQueryReferenceThrows() throws Exception { assertU(adoc("id", "1", "v_t", "hello world")); assertU(commit()); - // Without a json_query param the parser returns MatchNoDocsQuery - assertQ( - "intervals qparser without json_query should return no docs", - req("q", "{!intervals}"), - "//result[@numFound='0']"); + // Without a "$name" query string the parser must explain the required syntax + assertQEx( + "intervals qparser without a $name reference should throw BAD_REQUEST", + "Expected syntax", + req("q", "{!intervals df=v_t}"), + 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", + "No JSON request body found", + req("q", "{!intervals df=v_t}$myQuery"), + SolrException.ErrorCode.BAD_REQUEST); + } + + @Test + public void testIntervalsMissingJsonQueriesKeyThrows() throws Exception { + assertU(adoc("id", "3", "v_t", "hello world")); + assertU(commit()); + + // JSON body present, but no top-level "json_queries" map + assertQEx( + "intervals qparser without a json_queries map should throw BAD_REQUEST", + "No 'json_queries' map found", + req("q", "{!intervals df=v_t}$myQuery", "json", "{params:{}}"), + SolrException.ErrorCode.BAD_REQUEST); + } + + @Test + public void testIntervalsMissingNamedQueryThrows() throws Exception { + assertU(adoc("id", "4", "v_t", "hello world")); + assertU(commit()); + + // json_queries map present, but it has no entry named "myQuery" + assertQEx( + "intervals qparser with an unresolved $name reference should throw BAD_REQUEST", + "Query 'myQuery' not found in 'json_queries'", + req( + "q", + "{!intervals df=v_t}$myQuery", + "json", + "{json_queries:{otherQuery:{match:{query:foo}}}}"), + SolrException.ErrorCode.BAD_REQUEST); } @Test @@ -47,12 +91,12 @@ public void testIntervalsMatchRuleMatchesDocument() throws Exception { assertU(adoc("id", "11", "v_t", "baz qux")); assertU(commit()); - // field specified via df local param; json_query is the rule object directly + // field specified via df local param; the named json_queries entry is the rule object directly assertQ( "intervals qparser with match rule should match documents containing the term", req( "q", - "{!intervals json_query=myQuery df=v_t}", + "{!intervals df=v_t}$myQuery", "json", "{json_queries:{myQuery:{match:{query:foo}}}}"), "//result[@numFound='1']", @@ -70,7 +114,7 @@ public void testIntervalsAllOfAnyOfNamedQuery() throws Exception { "intervals qparser should support all_of with nested any_of via df local param", req( "q", - "{!intervals json_query=second_query df=title_t}", + "{!intervals df=title_t}$second_query", "json", "{json_queries:{" + "second_query:{" @@ -99,7 +143,7 @@ public void testIntervalsNoMatchingRule() throws Exception { "intervals qparser with non-matching rule should return no docs", req( "q", - "{!intervals json_query=myQuery df=v_t}", + "{!intervals df=v_t}$myQuery", "json", "{json_queries:{myQuery:{match:{query:zzznomatch}}}}"), "//result[@numFound='0']"); @@ -113,11 +157,7 @@ public void testIntervalsTermRule() throws Exception { assertQ( "term rule should match documents containing the exact term", - req( - "q", - "{!intervals json_query=q1 df=v_ws}", - "json", - "{json_queries:{q1:{term:{value:trm_apple}}}}"), + req("q", "{!intervals df=v_ws}$q1", "json", "{json_queries:{q1:{term:{value:trm_apple}}}}"), "//result[@numFound='1']", "//doc/str[@name='id'][.='40']"); } @@ -132,7 +172,7 @@ public void testIntervalsPhraseRuleWithTerms() throws Exception { "phrase rule with terms array should match documents with exact phrase", req( "q", - "{!intervals json_query=q1 df=v_ws}", + "{!intervals df=v_ws}$q1", "json", "{json_queries:{q1:{phrase:{terms:[phrA_quick,phrA_brown,phrA_fox]}}}}"), "//result[@numFound='1']", @@ -149,7 +189,7 @@ public void testIntervalsPhraseRuleWithIntervals() throws Exception { "phrase rule with intervals array should match documents with the phrase in order", req( "q", - "{!intervals json_query=q1 df=v_ws}", + "{!intervals df=v_ws}$q1", "json", "{json_queries:{q1:{phrase:{intervals:" + "[{term:{value:phrB_quick}},{term:{value:phrB_brown}},{term:{value:phrB_fox}}]}}}}"), @@ -168,7 +208,7 @@ public void testIntervalsRegexpRule() throws Exception { "regexp rule should match documents with terms matching the pattern", req( "q", - "{!intervals json_query=q1 df=v_ws}", + "{!intervals df=v_ws}$q1", "json", "{json_queries:{q1:{regexp:{pattern:'rx_ca.*'}}}}"), "//result[@numFound='2']"); @@ -178,7 +218,7 @@ public void testIntervalsRegexpRule() throws Exception { "max_expansions", req( "q", - "{!intervals json_query=q1 df=v_ws}", + "{!intervals df=v_ws}$q1", "json", "{json_queries:{q1:{regexp:{pattern:'rx_ca.*',max_expansions:-1}}}}"), SolrException.ErrorCode.BAD_REQUEST); @@ -196,7 +236,7 @@ public void testIntervalsRangeRule() throws Exception { "range rule should match documents with terms in the given range", req( "q", - "{!intervals json_query=q1 df=v_ws}", + "{!intervals df=v_ws}$q1", "json", "{json_queries:{q1:{range:{lower_term:rng_bbbb,upper_term:rng_cccc," + "include_lower:true,include_upper:true}}}}"), @@ -209,7 +249,7 @@ public void testIntervalsRangeRule() throws Exception { "max_expansions", req( "q", - "{!intervals json_query=q1 df=v_ws}", + "{!intervals df=v_ws}$q1", "json", "{json_queries:{q1:{range:{lower_term:rng_bbbb,upper_term:rng_cccc," + "include_lower:true,include_upper:true,max_expansions:-1}}}}"), @@ -227,7 +267,7 @@ public void testIntervalsFuzzyRule() throws Exception { "fuzzy rule should match documents with terms within the edit distance", req( "q", - "{!intervals json_query=q1 df=v_ws}", + "{!intervals df=v_ws}$q1", "json", "{json_queries:{q1:{fuzzy:{term:fzz_cat,fuzziness:'1'}}}}"), "//result[@numFound='2']", @@ -239,7 +279,7 @@ public void testIntervalsFuzzyRule() throws Exception { "prefix_length", req( "q", - "{!intervals json_query=q1 df=v_ws}", + "{!intervals df=v_ws}$q1", "json", "{json_queries:{q1:{fuzzy:{term:fzz_cat,prefix_length:-1}}}}"), SolrException.ErrorCode.BAD_REQUEST); @@ -256,7 +296,7 @@ public void testIntervalsMaxWidthRule() throws Exception { "max_width rule should filter intervals by maximum width", req( "q", - "{!intervals json_query=q1 df=v_ws}", + "{!intervals df=v_ws}$q1", "json", "{json_queries:{q1:{max_width:{width:2,source:" + "{all_of:{ordered:true,intervals:[{term:{value:mwd_alpha}},{term:{value:mwd_beta}}]}}}}}}"), @@ -275,7 +315,7 @@ public void testIntervalsExtendRule() throws Exception { "extend rule should extend intervals by specified positions", req( "q", - "{!intervals json_query=q1 df=v_ws}", + "{!intervals df=v_ws}$q1", "json", "{json_queries:{q1:{extend:{source:{term:{value:ext_three}},before:2,after:2}}}}"), "//result[@numFound='1']", @@ -293,7 +333,7 @@ public void testIntervalsUnorderedNoOverlapsRule() throws Exception { "unordered_no_overlaps rule should match documents containing both terms without overlap", req( "q", - "{!intervals json_query=q1 df=v_ws}", + "{!intervals df=v_ws}$q1", "json", "{json_queries:{q1:{unordered_no_overlaps:{intervals:" + "[{term:{value:uno_foo}},{term:{value:uno_bar}}]}}}}"), @@ -311,7 +351,7 @@ public void testIntervalsWithinRule() throws Exception { "within rule should match documents where source appears within N positions of reference", req( "q", - "{!intervals json_query=q1 df=v_ws}", + "{!intervals df=v_ws}$q1", "json", "{json_queries:{q1:{within:{source:{term:{value:wth_alpha}}," + "positions:1,reference:{term:{value:wth_beta}}}}}}"), @@ -331,7 +371,7 @@ public void testIntervalsNotWithinRule() throws Exception { "not_within rule should match documents where source is not within N positions of reference", req( "q", - "{!intervals json_query=q1 df=v_ws}", + "{!intervals df=v_ws}$q1", "json", "{json_queries:{q1:{not_within:{source:{term:{value:nwt_alpha}}," + "positions:1,reference:{term:{value:nwt_beta}}}}}}"), @@ -352,7 +392,7 @@ public void testIntervalsAtLeastRule() throws Exception { "at_least rule should match documents containing at least N of the given sources", req( "q", - "{!intervals json_query=q1 df=v_ws}", + "{!intervals df=v_ws}$q1", "json", "{json_queries:{q1:{at_least:{min_should_match:2,intervals:" + "[{term:{value:atl_alpha}},{term:{value:atl_beta}},{term:{value:atl_gamma}}]}}}}"), @@ -368,7 +408,7 @@ public void testIntervalsNoIntervalsRule() throws Exception { "no_intervals rule should match no documents", req( "q", - "{!intervals json_query=q1 df=v_ws}", + "{!intervals df=v_ws}$q1", "json", "{json_queries:{q1:{no_intervals:{reason:testing}}}}"), "//result[@numFound='0']"); @@ -385,7 +425,7 @@ public void testIntervalsDfFallbackFromQueryParam() throws Exception { "df query param (not local param) should be used as the field when df is absent in local params", req( "q", - "{!intervals json_query=q1}", + "{!intervals}$q1", "df", "v_ws", "json", @@ -407,7 +447,7 @@ public void testIntervalsLegacyFieldInJsonQueryThrows() throws Exception { "Unsupported intervals rule: v_ws", req( "q", - "{!intervals json_query=q1 df=v_ws}", + "{!intervals df=v_ws}$q1", "json", "{json_queries:{q1:{v_ws:{term:{value:bkc_alpha}}}}}"), SolrException.ErrorCode.BAD_REQUEST); @@ -448,7 +488,7 @@ public void testIntervalsNestedAlternativeOutperformsXmlSpans() throws Exception "intervals handles the same nested alternative and finds both valid matches", req( "q", - "{!intervals json_query=cmpq df=v_ws}", + "{!intervals df=v_ws}$cmpq", "json", // max_gaps:0 mirrors the xmlparser query's slop="0": the whole sequence must be // contiguous, so doc 172 (where cmpblame/cmpsystem separate cmpdomain from cmpis) 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 index c9f8eae5d43d..2bd2439de2cc 100644 --- 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 @@ -20,16 +20,21 @@ The Intervals Query Parser (`IntervalsQParserPlugin`) builds Lucene interval que 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. -Invoked with the syntax `{!intervals json_query= df=}`. +Invoked with the syntax `{!intervals df=}$`. == Query Format Specify the target field using the `df` local param (or the `df` query param as a fallback). -The named entry in `json_queries` is then the rule object directly, with no field wrapper. +The query string following the local params, `$`, is *required* and names an entry in the +`json_queries` map of the JSON request body; that entry is then the rule object directly, with no +field wrapper. +If the `$` reference is missing or malformed, or if `json_queries` (or the named entry +within it) is absent from the JSON request body, the query parser throws a `BAD_REQUEST` error +explaining the expected syntax. [source,text] ---- -q={!intervals json_query=myQuery df=title} +q={!intervals df=title}$myQuery ---- [source,text] @@ -45,13 +50,14 @@ json={ == Parameters -`json_query`:: +`$`:: + [%autowidth,frame=none] |=== |Required |Default: none |=== + +The query string following the local params (e.g., `$myQuery`). Names an entry in the `json_queries` map that defines the interval query to execute. `df`:: @@ -721,7 +727,7 @@ Find documents where the `title` field contains the phrase "apache solr" followe [source,text] ---- -q={!intervals json_query=titleQuery df=title} +q={!intervals df=title}$titleQuery ---- [source,text] diff --git a/solr/solr-ref-guide/modules/query-guide/pages/json-request-api.adoc b/solr/solr-ref-guide/modules/query-guide/pages/json-request-api.adoc index 7b56b48f3158..30ac4429cb11 100644 --- a/solr/solr-ref-guide/modules/query-guide/pages/json-request-api.adoc +++ b/solr/solr-ref-guide/modules/query-guide/pages/json-request-api.adoc @@ -254,6 +254,8 @@ curl "http://localhost:8983/solr/techproducts/query?fl=name,price&q=memory&rows= Usage of `queries` key is described in xref:json-query-dsl.adoc#additional-queries[Additional Queries]. +`json_queries` key is used by xref:intervals-query-parser.adoc[Intervals Query Parser]. + === Parameter Substitution / Macro Expansion Of course request templating via parameter substitution works fully with JSON request bodies or parameters as well. 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 8664147edb1a..569639570a62 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 @@ -722,7 +722,7 @@ Note the name of the cache should be the field name prefixed by "`hash_`". 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 json_query=}`, where `json_query` names an entry in the `json_queries` map supplied in the JSON request body. +Invoked with the syntax `{!intervals df=}$`, where `$` is a required reference to an entry in the `json_queries` map supplied in the JSON request body. Details of this query parser are in the section xref:intervals-query-parser.adoc[]. From dde6a643f605bfb2e15b5dd4e0ded90c3da46936 Mon Sep 17 00:00:00 2001 From: Mikhail Khludnev Date: Fri, 3 Jul 2026 01:34:06 +0300 Subject: [PATCH 40/43] strict field check use typed SchemaField --- solr/.gitignore | 1 + .../solr/search/IntervalsQParserPlugin.java | 177 ++++++++++-------- .../search/TestIntervalsQParserPlugin.java | 70 +++++++ .../modules/query-guide/querying-nav.adoc | 2 +- 4 files changed, 173 insertions(+), 77 deletions(-) diff --git a/solr/.gitignore b/solr/.gitignore index 8d643687405e..71c14a078728 100644 --- a/solr/.gitignore +++ b/solr/.gitignore @@ -24,3 +24,4 @@ lib/ test-lib/ +.vscode/ \ No newline at end of file diff --git a/solr/core/src/java/org/apache/solr/search/IntervalsQParserPlugin.java b/solr/core/src/java/org/apache/solr/search/IntervalsQParserPlugin.java index 3887f5115827..f85ab94ab6a4 100644 --- a/solr/core/src/java/org/apache/solr/search/IntervalsQParserPlugin.java +++ b/solr/core/src/java/org/apache/solr/search/IntervalsQParserPlugin.java @@ -32,6 +32,7 @@ import org.apache.solr.request.SolrQueryRequest; import org.apache.solr.request.json.RequestUtil; import org.apache.solr.schema.FieldType; +import org.apache.solr.schema.SchemaField; import org.apache.solr.schema.TextField; /** @@ -104,12 +105,14 @@ public Query parse() { SolrException.ErrorCode.BAD_REQUEST, "Query '" + jsonQueryName + "' requires a 'df' parameter to specify the field"); } + SchemaField defaultField = req.getSchema().getField(field); - IntervalsSource source = parseRuleObject(queryDefMap, field); - return new IntervalQuery(field, source); + IntervalsSource source = parseRuleObject(queryDefMap, defaultField); + return new IntervalQuery(defaultField.getName(), source); } - private IntervalsSource parseRuleObject(Map ruleObject, String topField) { + private IntervalsSource parseRuleObject( + Map ruleObject, SchemaField defaultField) { if (ruleObject.size() != 1) { throw new SolrException( SolrException.ErrorCode.BAD_REQUEST, @@ -121,83 +124,87 @@ private IntervalsSource parseRuleObject(Map ruleObject, String t asStringObjectMap(entry.getValue(), "rule '" + ruleName + "'"); return switch (ruleName) { - case "match" -> parseMatchRule(ruleParams, topField); - case "prefix" -> parsePrefixRule(ruleParams, topField); - case "wildcard" -> parseWildcardRule(ruleParams, topField); - case "fuzzy" -> parseFuzzyRule(ruleParams, topField); - case "all_of" -> parseAllOfRule(ruleParams, topField); - case "any_of" -> parseAnyOfRule(ruleParams, topField); - case "term" -> parseTermRule(ruleParams, topField); - case "phrase" -> parsePhraseRule(ruleParams, topField); - case "regexp" -> parseRegexpRule(ruleParams, topField); - case "range" -> parseRangeRule(ruleParams, topField); - case "max_width" -> parseMaxWidthRule(ruleParams, topField); - case "extend" -> parseExtendRule(ruleParams, topField); - case "unordered_no_overlaps" -> parseUnorderedNoOverlapsRule(ruleParams, topField); - case "not_within" -> parseNotWithinRule(ruleParams, topField); - case "within" -> parseWithinRule(ruleParams, topField); - case "at_least" -> parseAtLeastRule(ruleParams, topField); + 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 params, String topField) { + private IntervalsSource parseMatchRule(Map 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"); - String analysisField = useField == null ? topField : useField; + SchemaField analysisField = resolveField(useField, defaultField); Analyzer analyzer = resolveAnalyzer(params, analysisField, "match"); IntervalsSource source; try { - source = Intervals.analyzedText(queryText, analyzer, analysisField, maxGaps, ordered); + 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 + "'", + "Failed to analyze match query text for field '" + analysisField.getName() + "'", e); } if (useField != null) { - source = Intervals.fixField(useField, source); + source = Intervals.fixField(analysisField.getName(), source); } - return applyFilter(source, params.get("filter"), topField); + return applyFilter(source, params.get("filter"), defaultField); } - private IntervalsSource parsePrefixRule(Map params, String topField) { + private IntervalsSource parsePrefixRule( + Map params, SchemaField defaultField) { String prefix = requireString(params, "prefix", "prefix"); String useField = getOptionalString(params, "use_field", "prefix"); - String field = useField == null ? topField : useField; + SchemaField field = resolveField(useField, defaultField); Analyzer analyzer = resolveMultiTermAnalyzer(params, field, "prefix"); - String normalizedPrefix = normalizeMultiTerm(field, prefix, analyzer); + String normalizedPrefix = normalizeMultiTerm(field.getName(), prefix, analyzer); IntervalsSource source = Intervals.prefix(new BytesRef(normalizedPrefix)); if (useField != null) { - source = Intervals.fixField(useField, source); + source = Intervals.fixField(field.getName(), source); } return source; } - private IntervalsSource parseWildcardRule(Map params, String topField) { + private IntervalsSource parseWildcardRule( + Map params, SchemaField defaultField) { String pattern = requireString(params, "pattern", "wildcard"); String useField = getOptionalString(params, "use_field", "wildcard"); - String field = useField == null ? topField : useField; + SchemaField field = resolveField(useField, defaultField); Analyzer analyzer = resolveMultiTermAnalyzer(params, field, "wildcard"); - String normalizedPattern = normalizeMultiTerm(field, pattern, analyzer); + String normalizedPattern = normalizeMultiTerm(field.getName(), pattern, analyzer); IntervalsSource source = Intervals.wildcard(new BytesRef(normalizedPattern)); if (useField != null) { - source = Intervals.fixField(useField, source); + source = Intervals.fixField(field.getName(), source); } return source; } - private IntervalsSource parseFuzzyRule(Map params, String topField) { + private IntervalsSource parseFuzzyRule(Map params, SchemaField defaultField) { String term = requireString(params, "term", "fuzzy"); String useField = getOptionalString(params, "use_field", "fuzzy"); - String field = useField == null ? topField : useField; + SchemaField field = resolveField(useField, defaultField); Analyzer analyzer = resolveMultiTermAnalyzer(params, field, "fuzzy"); - String normalizedTerm = normalizeMultiTerm(field, term, analyzer); + String normalizedTerm = normalizeMultiTerm(field.getName(), term, analyzer); String fuzziness = getOptionalString(params, "fuzziness", "fuzzy"); int maxEdits = resolveFuzziness(fuzziness, normalizedTerm); @@ -217,13 +224,13 @@ private IntervalsSource parseFuzzyRule(Map params, String topFie transpositions, DEFAULT_FUZZY_MAX_EXPANSIONS); if (useField != null) { - source = Intervals.fixField(useField, source); + source = Intervals.fixField(field.getName(), source); } return source; } - private IntervalsSource parseAllOfRule(Map params, String topField) { - List intervals = parseIntervalsArray(params, topField, "all_of"); + private IntervalsSource parseAllOfRule(Map params, SchemaField defaultField) { + List intervals = parseIntervalsArray(params, defaultField, "all_of"); boolean ordered = getBoolean(params, "ordered", false, "all_of"); int maxGaps = getInt(params, "max_gaps", -1, "all_of"); @@ -234,26 +241,27 @@ private IntervalsSource parseAllOfRule(Map params, String topFie if (maxGaps >= 0) { source = Intervals.maxgaps(maxGaps, source); } - return applyFilter(source, params.get("filter"), topField); + return applyFilter(source, params.get("filter"), defaultField); } - private IntervalsSource parseAnyOfRule(Map params, String topField) { - List intervals = parseIntervalsArray(params, topField, "any_of"); + private IntervalsSource parseAnyOfRule(Map params, SchemaField defaultField) { + List intervals = parseIntervalsArray(params, defaultField, "any_of"); IntervalsSource source = Intervals.or(intervals); - return applyFilter(source, params.get("filter"), topField); + return applyFilter(source, params.get("filter"), defaultField); } - private IntervalsSource parseTermRule(Map params, String topField) { + private IntervalsSource parseTermRule(Map 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(useField, source); + source = Intervals.fixField(resolveField(useField, defaultField).getName(), source); } return source; } - private IntervalsSource parsePhraseRule(Map params, String topField) { + private IntervalsSource parsePhraseRule( + Map params, SchemaField defaultField) { Object termsObj = params.get("terms"); Object intervalsObj = params.get("intervals"); if (termsObj == null && intervalsObj == null) { @@ -281,12 +289,13 @@ private IntervalsSource parsePhraseRule(Map params, String topFi } return Intervals.phrase(terms); } else { - List intervals = parseIntervalsArray(params, topField, "phrase"); + List intervals = parseIntervalsArray(params, defaultField, "phrase"); return Intervals.phrase(intervals.toArray(IntervalsSource[]::new)); } } - private IntervalsSource parseRegexpRule(Map params, String topField) { + private IntervalsSource parseRegexpRule( + Map params, SchemaField defaultField) { String pattern = requireString(params, "pattern", "regexp"); String useField = getOptionalString(params, "use_field", "regexp"); int maxExpansions = @@ -299,12 +308,12 @@ private IntervalsSource parseRegexpRule(Map params, String topFi } IntervalsSource source = Intervals.regexp(new BytesRef(pattern), maxExpansions); if (useField != null) { - source = Intervals.fixField(useField, source); + source = Intervals.fixField(resolveField(useField, defaultField).getName(), source); } return source; } - private IntervalsSource parseRangeRule(Map params, String topField) { + private IntervalsSource parseRangeRule(Map 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"); @@ -322,28 +331,30 @@ private IntervalsSource parseRangeRule(Map params, String topFie return Intervals.range(lowerTerm, upperTerm, includeLower, includeUpper, maxExpansions); } - private IntervalsSource parseMaxWidthRule(Map params, String topField) { + private IntervalsSource parseMaxWidthRule( + Map 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", topField); + IntervalsSource source = parseNestedRule(params, "source", "max_width", defaultField); return Intervals.maxwidth(width, source); } - private IntervalsSource parseExtendRule(Map params, String topField) { + private IntervalsSource parseExtendRule( + Map params, SchemaField defaultField) { int before = getInt(params, "before", 0, "extend"); int after = getInt(params, "after", 0, "extend"); - IntervalsSource source = parseNestedRule(params, "source", "extend", topField); + IntervalsSource source = parseNestedRule(params, "source", "extend", defaultField); return Intervals.extend(source, before, after); } private IntervalsSource parseUnorderedNoOverlapsRule( - Map params, String topField) { + Map params, SchemaField defaultField) { List intervals = - parseIntervalsArray(params, topField, "unordered_no_overlaps"); + parseIntervalsArray(params, defaultField, "unordered_no_overlaps"); if (intervals.size() != 2) { throw new SolrException( SolrException.ErrorCode.BAD_REQUEST, @@ -352,38 +363,42 @@ private IntervalsSource parseUnorderedNoOverlapsRule( return Intervals.unorderedNoOverlaps(intervals.get(0), intervals.get(1)); } - private IntervalsSource parseNotWithinRule(Map params, String topField) { - IntervalsSource source = parseNestedRule(params, "source", "not_within", topField); + private IntervalsSource parseNotWithinRule( + Map 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", topField); + IntervalsSource reference = + parseNestedRule(params, "reference", "not_within", defaultField); return Intervals.notWithin(source, positions, reference); } - private IntervalsSource parseWithinRule(Map params, String topField) { - IntervalsSource source = parseNestedRule(params, "source", "within", topField); + private IntervalsSource parseWithinRule( + Map 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", topField); + IntervalsSource reference = parseNestedRule(params, "reference", "within", defaultField); return Intervals.within(source, positions, reference); } - private IntervalsSource parseAtLeastRule(Map params, String topField) { + private IntervalsSource parseAtLeastRule( + Map 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 intervals = parseIntervalsArray(params, topField, "at_least"); + List intervals = parseIntervalsArray(params, defaultField, "at_least"); return Intervals.atLeast(minShouldMatch, intervals.toArray(IntervalsSource[]::new)); } @@ -393,7 +408,7 @@ private IntervalsSource parseNoIntervalsRule(Map params) { } private IntervalsSource parseNestedRule( - Map params, String key, String ruleName, String topField) { + Map params, String key, String ruleName, SchemaField defaultField) { Object nested = params.get(key); if (nested == null) { throw new SolrException( @@ -401,11 +416,11 @@ private IntervalsSource parseNestedRule( "Rule '" + ruleName + "' requires '" + key + "' parameter"); } return parseRuleObject( - asStringObjectMap(nested, "'" + key + "' in rule '" + ruleName + "'"), topField); + asStringObjectMap(nested, "'" + key + "' in rule '" + ruleName + "'"), defaultField); } private List parseIntervalsArray( - Map params, String topField, String ruleName) { + Map params, SchemaField defaultField, String ruleName) { Object intervalsObj = params.get("intervals"); if (!(intervalsObj instanceof List)) { throw new SolrException( @@ -421,13 +436,14 @@ private List parseIntervalsArray( List parsed = new ArrayList<>(rawIntervals.size()); for (Object intervalObj : rawIntervals) { parsed.add( - parseRuleObject(asStringObjectMap(intervalObj, "intervals array element"), topField)); + parseRuleObject( + asStringObjectMap(intervalObj, "intervals array element"), defaultField)); } return parsed; } private IntervalsSource applyFilter( - IntervalsSource source, Object filterObj, String topField) { + IntervalsSource source, Object filterObj, SchemaField defaultField) { if (filterObj == null) { return source; } @@ -445,7 +461,8 @@ private IntervalsSource applyFilter( SolrException.ErrorCode.BAD_REQUEST, "Filter operator 'script' is not supported"); } IntervalsSource other = - parseRuleObject(asStringObjectMap(entry.getValue(), "filter '" + op + "'"), topField); + parseRuleObject( + asStringObjectMap(entry.getValue(), "filter '" + op + "'"), defaultField); return switch (op) { case "after" -> Intervals.after(source, other); case "before" -> Intervals.before(source, other); @@ -460,10 +477,20 @@ private IntervalsSource applyFilter( }; } - private Analyzer resolveAnalyzer(Map params, String field, String ruleName) { + /** + * 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 params, SchemaField field, String ruleName) { String analyzerName = getOptionalString(params, "analyzer", ruleName); if (analyzerName == null) { - return req.getSchema().getFieldTypeNoEx(field).getQueryAnalyzer(); + return field.getType().getQueryAnalyzer(); } return resolveFieldType(analyzerName, ruleName).getQueryAnalyzer(); } @@ -475,12 +502,10 @@ private Analyzer resolveAnalyzer(Map params, String field, Strin * to be tokenized. */ private Analyzer resolveMultiTermAnalyzer( - Map params, String field, String ruleName) { + Map params, SchemaField field, String ruleName) { String analyzerName = getOptionalString(params, "analyzer", ruleName); FieldType fieldType = - analyzerName == null - ? req.getSchema().getFieldTypeNoEx(field) - : resolveFieldType(analyzerName, ruleName); + analyzerName == null ? field.getType() : resolveFieldType(analyzerName, ruleName); if (fieldType instanceof TextField textField) { return textField.getMultiTermAnalyzer(); } diff --git a/solr/core/src/test/org/apache/solr/search/TestIntervalsQParserPlugin.java b/solr/core/src/test/org/apache/solr/search/TestIntervalsQParserPlugin.java index 58a3e211ab3f..bdf210b9d31b 100644 --- a/solr/core/src/test/org/apache/solr/search/TestIntervalsQParserPlugin.java +++ b/solr/core/src/test/org/apache/solr/search/TestIntervalsQParserPlugin.java @@ -453,6 +453,76 @@ public void testIntervalsLegacyFieldInJsonQueryThrows() throws Exception { SolrException.ErrorCode.BAD_REQUEST); } + @Test + public void testIntervalsMatchRuleNonexistentDfFieldThrows() throws Exception { + // df names a field that doesn't exist in the schema; the match rule resolves its analyzer + // from this field since no analyzer/use_field override is given + assertQEx( + "match rule with a nonexistent df field should throw BAD_REQUEST", + "undefined field", + req( + "q", + "{!intervals df=no_such_field}$q1", + "json", + "{json_queries:{q1:{match:{query:foo}}}}"), + SolrException.ErrorCode.BAD_REQUEST); + } + + @Test + public void testIntervalsMatchRuleNonexistentUseFieldThrows() throws Exception { + // df is valid, but use_field overrides the field used to resolve the analyzer + assertQEx( + "match rule with a nonexistent use_field should throw BAD_REQUEST", + "undefined field", + req( + "q", + "{!intervals df=v_t}$q1", + "json", + "{json_queries:{q1:{match:{query:foo,use_field:no_such_field}}}}"), + 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 df field should throw BAD_REQUEST", + "undefined field", + req( + "q", + "{!intervals df=no_such_field}$q1", + "json", + "{json_queries:{q1:{prefix:{prefix:foo}}}}"), + 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( + "q", + "{!intervals df=v_ws}$q1", + "json", + "{json_queries:{q1:{prefix:{prefix:foo,use_field:no_such_field}}}}"), + 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( + "q", + "{!intervals df=v_t}$q1", + "json", + "{json_queries:{q1:{match:{query:foo,analyzer:no_such_field_type}}}}"), + 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 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 d334fd6e868b..8604457592e3 100644 --- a/solr/solr-ref-guide/modules/query-guide/querying-nav.adoc +++ b/solr/solr-ref-guide/modules/query-guide/querying-nav.adoc @@ -30,12 +30,12 @@ *** xref:json-combined-query-dsl.adoc[] ** xref:searching-nested-documents.adoc[] ** xref:block-join-query-parser.adoc[] -** xref:intervals-query-parser.adoc[] ** xref:join-query-parser.adoc[] ** xref:spatial-search.adoc[] ** xref:dense-vector-search.adoc[] *** xref:text-to-vector.adoc[] ** xref:other-parsers.adoc[] +*** xref:intervals-query-parser.adoc[] ** xref:sql-query.adoc[] *** xref:jdbc-dbvisualizer.adoc[] *** xref:jdbc-squirrel.adoc[] From c1b46353fd339781ccc4a2b5f79b0ca804d95969 Mon Sep 17 00:00:00 2001 From: Mikhail Khludnev Date: Fri, 3 Jul 2026 09:44:39 +0300 Subject: [PATCH 41/43] assertJQ --- .../apache/solr/search/TestIntervalsQParserPlugin.java | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/solr/core/src/test/org/apache/solr/search/TestIntervalsQParserPlugin.java b/solr/core/src/test/org/apache/solr/search/TestIntervalsQParserPlugin.java index bdf210b9d31b..2db09515c221 100644 --- a/solr/core/src/test/org/apache/solr/search/TestIntervalsQParserPlugin.java +++ b/solr/core/src/test/org/apache/solr/search/TestIntervalsQParserPlugin.java @@ -177,6 +177,16 @@ public void testIntervalsPhraseRuleWithTerms() throws Exception { "{json_queries:{q1:{phrase:{terms:[phrA_quick,phrA_brown,phrA_fox]}}}}"), "//result[@numFound='1']", "//doc/str[@name='id'][.='50']"); + // same query expressed via the top-level JSON "query" (rather than the "q" param) still + // resolves the intervals qparser's $q1 reference against json_queries + assertJQ( + req( + "json", + "{query:{intervals:{df:v_ws, query:$q1}}, " + + " json_queries:{q1:{phrase:{terms:[phrA_quick,phrA_brown,phrA_fox]}}}," + + " fields:id" + + "}"), + "/response=={'numFound':1,'start':0,'numFoundExact':true,'docs':[{'id':'50'}]}"); } @Test From c9ccd8ad2cc3d264dec5beb6a7df83d8e911aac3 Mon Sep 17 00:00:00 2001 From: Mikhail Khludnev Date: Fri, 3 Jul 2026 10:44:17 +0300 Subject: [PATCH 42/43] tiding manually --- .../org/apache/solr/search/IntervalsQParserPlugin.java | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/solr/core/src/java/org/apache/solr/search/IntervalsQParserPlugin.java b/solr/core/src/java/org/apache/solr/search/IntervalsQParserPlugin.java index f85ab94ab6a4..5ef82db885ee 100644 --- a/solr/core/src/java/org/apache/solr/search/IntervalsQParserPlugin.java +++ b/solr/core/src/java/org/apache/solr/search/IntervalsQParserPlugin.java @@ -141,8 +141,9 @@ private IntervalsSource parseRuleObject( 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); + default -> + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, "Unsupported intervals rule: " + ruleName); }; } @@ -472,8 +473,9 @@ private IntervalsSource applyFilter( 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); + default -> + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, "Unsupported filter operator: " + op); }; } From bac1035093f8911e2a03e1bf7c521313f071052f Mon Sep 17 00:00:00 2001 From: Mikhail Khludnev Date: Fri, 3 Jul 2026 11:00:49 +0300 Subject: [PATCH 43/43] tidyng spaces. dunno why --- .../java/org/apache/solr/search/IntervalsQParserPlugin.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/solr/core/src/java/org/apache/solr/search/IntervalsQParserPlugin.java b/solr/core/src/java/org/apache/solr/search/IntervalsQParserPlugin.java index 5ef82db885ee..9b37c229e444 100644 --- a/solr/core/src/java/org/apache/solr/search/IntervalsQParserPlugin.java +++ b/solr/core/src/java/org/apache/solr/search/IntervalsQParserPlugin.java @@ -141,7 +141,7 @@ private IntervalsSource parseRuleObject( case "within" -> parseWithinRule(ruleParams, defaultField); case "at_least" -> parseAtLeastRule(ruleParams, defaultField); case "no_intervals" -> parseNoIntervalsRule(ruleParams); - default -> + default -> throw new SolrException( SolrException.ErrorCode.BAD_REQUEST, "Unsupported intervals rule: " + ruleName); }; @@ -473,7 +473,7 @@ private IntervalsSource applyFilter( case "not_containing" -> Intervals.notContaining(source, other); case "not_overlapping" -> Intervals.nonOverlapping(source, other); case "overlapping" -> Intervals.overlapping(source, other); - default -> + default -> throw new SolrException( SolrException.ErrorCode.BAD_REQUEST, "Unsupported filter operator: " + op); };