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:
+ *
+ *
+ * - 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:
+ * {...}}}}.
+ *
*/
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);
};