This is an automated email from the ASF dual-hosted git repository.

dsmiley pushed a commit to branch branch_10x
in repository https://gitbox.apache.org/repos/asf/solr.git

commit c40315451ad6faab4956ce0782519742703d3386
Author: Mikhail Khludnev <[email protected]>
AuthorDate: Sat Jul 25 13:02:12 2026 +0300

    SOLR-13764: Introduce IntervalsQParserPlugin (#4582)
    
    Co-authored-by: Sonu Sharma @ercsonusharma <[email protected]>
    (cherry picked from commit 763410f246c5ecdf38474d1d0d573c3aaa46f0a7)
---
 .gitignore                                         |   1 +
 .../SOLR-13764-intervals-query-parser.yml          |  13 +
 .../request/json/JsonConsumerQParserPlugin.java    |  26 +
 .../solr/request/json/JsonQueryConverter.java      |  29 +-
 .../org/apache/solr/request/json/RequestUtil.java  |  13 +-
 .../apache/solr/search/IntervalsQParserPlugin.java | 675 +++++++++++++++++++
 .../java/org/apache/solr/search/QParserPlugin.java |   1 +
 .../apache/solr/search/DistributedQParserTest.java | 224 +++++++
 .../org/apache/solr/search/QueryEqualityTest.java  |  10 +
 .../solr/search/TestIntervalsQParserPlugin.java    | 492 ++++++++++++++
 .../query-guide/pages/intervals-query-parser.adoc  | 738 +++++++++++++++++++++
 .../modules/query-guide/pages/other-parsers.adoc   |   9 +
 .../modules/query-guide/querying-nav.adoc          |   1 +
 .../src/java/org/apache/solr/SolrTestCaseJ4.java   |  15 +
 14 files changed, 2234 insertions(+), 13 deletions(-)

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

Reply via email to