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

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


The following commit(s) were added to refs/heads/main by this push:
     new 83d560030fd SOLR-18585: intervals script support (#5224)
83d560030fd is described below

commit 83d560030fde8451bb9ec3f4bab9bcd58bdd781e
Author: Mikhail Khludnev <[email protected]>
AuthorDate: Sun Nov 1 02:18:38 2026 +0300

    SOLR-18585: intervals script support (#5224)
    
    Co-authored-by: mkhludnev <[email protected]>
    Co-authored-by: Copilot <[email protected]>
    Co-authored-by: Copilot Autofix powered by AI 
<[email protected]>
    Co-authored-by: Nick Shanin <[email protected]>
---
 changelog/unreleased/intervals-script-filter.yml   |  12 +
 .../apache/solr/search/IntervalsQParserPlugin.java |  38 ++-
 .../solr/search/ScriptFilterIntervalsSource.java   | 334 +++++++++++++++++++++
 .../apache/solr/search/DistributedQParserTest.java |  82 +++++
 .../solr/search/TestIntervalsQParserPlugin.java    |  83 +++++
 .../search/TestScriptFilterIntervalsSource.java    | 111 +++++++
 .../query-guide/pages/intervals-query-parser.adoc  |  53 +++-
 7 files changed, 710 insertions(+), 3 deletions(-)

diff --git a/changelog/unreleased/intervals-script-filter.yml 
b/changelog/unreleased/intervals-script-filter.yml
new file mode 100644
index 00000000000..4daedb3d38f
--- /dev/null
+++ b/changelog/unreleased/intervals-script-filter.yml
@@ -0,0 +1,12 @@
+title: >
+  Add script filter operator to intervals QParser, evaluated by Lucene 
Expressions
+  with interval.start, interval.end and interval.gap variables in scope.
+type: added
+authors:
+  - name: Mikhail Khludnev
+    nick: mkhl
+  - name: Nick Shanin
+    nick: nick-boss-tech
+links:
+  - name: SOLR-18585
+    url: https://issues.apache.org/jira/browse/SOLR-18585
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 02d696d87b3..d5c7ee59872 100644
--- a/solr/core/src/java/org/apache/solr/search/IntervalsQParserPlugin.java
+++ b/solr/core/src/java/org/apache/solr/search/IntervalsQParserPlugin.java
@@ -17,10 +17,13 @@
 package org.apache.solr.search;
 
 import java.io.IOException;
+import java.text.ParseException;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
 import org.apache.lucene.analysis.Analyzer;
+import org.apache.lucene.expressions.Expression;
+import org.apache.lucene.expressions.js.JavascriptCompiler;
 import org.apache.lucene.queries.intervals.IntervalQuery;
 import org.apache.lucene.queries.intervals.Intervals;
 import org.apache.lucene.queries.intervals.IntervalsSource;
@@ -434,8 +437,7 @@ public class IntervalsQParserPlugin extends QParserPlugin 
implements JsonConsume
         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");
+          return parseScriptFilter(source, entry.getValue());
         }
         IntervalsSource other =
             parseRuleObject(
@@ -455,6 +457,38 @@ public class IntervalsQParserPlugin extends QParserPlugin 
implements JsonConsume
         };
       }
 
+      /**
+       * Parses the {@code script} filter operator, which filters intervals by 
evaluating a Lucene
+       * Expressions script over the pseudo-variables {@code interval.start}, 
{@code interval.end}
+       * and {@code interval.gap}. A non-zero result accepts the interval.
+       */
+      private IntervalsSource parseScriptFilter(IntervalsSource source, Object 
scriptObj) {
+        String script =
+            requireString(asStringObjectMap(scriptObj, "filter 'script'"), 
"source", "script");
+        Expression expression;
+        try {
+          expression = JavascriptCompiler.compile(script);
+        } catch (ParseException e) {
+          throw new SolrException(
+              SolrException.ErrorCode.BAD_REQUEST,
+              "Invalid script expression '" + script + "' in filter 'script'",
+              e);
+        }
+        ScriptFilterIntervalsSource.IntervalBindings bindings =
+            new ScriptFilterIntervalsSource.IntervalBindings();
+        for (String variable : expression.variables) {
+          try {
+            bindings.getDoubleValuesSource(variable);
+          } catch (IllegalArgumentException e) {
+            throw new SolrException(
+                SolrException.ErrorCode.BAD_REQUEST,
+                "Unknown variable '" + variable + "' in filter script '" + 
script + "'",
+                e);
+          }
+        }
+        return new ScriptFilterIntervalsSource(source, expression, bindings);
+      }
+
       /**
        * 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
diff --git 
a/solr/core/src/java/org/apache/solr/search/ScriptFilterIntervalsSource.java 
b/solr/core/src/java/org/apache/solr/search/ScriptFilterIntervalsSource.java
new file mode 100644
index 00000000000..1680111ba10
--- /dev/null
+++ b/solr/core/src/java/org/apache/solr/search/ScriptFilterIntervalsSource.java
@@ -0,0 +1,334 @@
+/*
+ * 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 org.apache.lucene.expressions.Bindings;
+import org.apache.lucene.expressions.Expression;
+import org.apache.lucene.index.LeafReaderContext;
+import org.apache.lucene.queries.intervals.FilteredIntervalsSource;
+import org.apache.lucene.queries.intervals.IntervalFilter;
+import org.apache.lucene.queries.intervals.IntervalIterator;
+import org.apache.lucene.queries.intervals.IntervalMatchesIterator;
+import org.apache.lucene.queries.intervals.IntervalsSource;
+import org.apache.lucene.search.DoubleValues;
+import org.apache.lucene.search.DoubleValuesSource;
+import org.apache.lucene.search.IndexSearcher;
+import org.apache.lucene.search.MatchesIterator;
+import org.apache.lucene.search.Query;
+
+/**
+ * An {@link FilteredIntervalsSource} that filters intervals by evaluating a 
Lucene Expressions
+ * script against the current interval.
+ *
+ * <p>The script is bound to the pseudo-variables {@code interval.start}, 
{@code interval.end} and
+ * {@code interval.gap}, and an interval is accepted when the script evaluates 
to a non-zero value.
+ */
+public class ScriptFilterIntervalsSource extends FilteredIntervalsSource {
+
+  /** Bindings that expose the current interval's {@code start}, {@code end} 
and {@code gap}. */
+  public static class IntervalBindings extends Bindings {
+
+    private static final DoubleValuesSource START =
+        new IntervalDoubleValuesSource("start") {
+          @Override
+          protected double value(IntervalValues interval) {
+            return interval.start();
+          }
+        };
+    private static final DoubleValuesSource END =
+        new IntervalDoubleValuesSource("end") {
+          @Override
+          protected double value(IntervalValues interval) {
+            return interval.end();
+          }
+        };
+    private static final DoubleValuesSource GAP =
+        new IntervalDoubleValuesSource("gap") {
+          @Override
+          protected double value(IntervalValues interval) {
+            return interval.gaps();
+          }
+        };
+
+    @Override
+    public DoubleValuesSource getDoubleValuesSource(String name) {
+      return switch (name) {
+        case "interval.start" -> START;
+        case "interval.end" -> END;
+        case "interval.gap" -> GAP;
+        default ->
+            throw new IllegalArgumentException(
+                "Unknown interval variable '"
+                    + name
+                    + "'. Available: interval.start, interval.end, 
interval.gap");
+      };
+    }
+  }
+
+  /** A {@link DoubleValuesSource} that reads a scalar property of the current 
interval. */
+  private abstract static class IntervalDoubleValuesSource extends 
DoubleValuesSource {
+
+    private final String variable;
+
+    IntervalDoubleValuesSource(String variable) {
+      this.variable = variable;
+    }
+
+    @Override
+    public final DoubleValues getValues(LeafReaderContext ctx, DoubleValues 
scores)
+        throws IOException {
+      throw new IOException(
+          "interval variable '" + variable + "' can only be evaluated within 
an interval filter");
+    }
+
+    @Override
+    public final boolean needsScores() {
+      return false;
+    }
+
+    @Override
+    public final boolean isCacheable(LeafReaderContext ctx) {
+      return false;
+    }
+
+    @Override
+    public final DoubleValuesSource rewrite(IndexSearcher searcher) throws 
IOException {
+      return this;
+    }
+
+    @Override
+    public final int hashCode() {
+      return variable.hashCode();
+    }
+
+    @Override
+    public final boolean equals(Object obj) {
+      return obj instanceof IntervalDoubleValuesSource that && 
variable.equals(that.variable);
+    }
+
+    @Override
+    public final String toString() {
+      return "interval." + variable;
+    }
+
+    protected abstract double value(IntervalValues interval);
+  }
+
+  private interface IntervalValues {
+    int start();
+
+    int end();
+
+    int gaps();
+  }
+
+  private static final class IteratorValues implements IntervalValues {
+    private final IntervalIterator iterator;
+
+    IteratorValues(IntervalIterator iterator) {
+      this.iterator = iterator;
+    }
+
+    @Override
+    public int start() {
+      return iterator.start();
+    }
+
+    @Override
+    public int end() {
+      return iterator.end();
+    }
+
+    @Override
+    public int gaps() {
+      return iterator.gaps();
+    }
+  }
+
+  private final Expression expression;
+  private final IntervalBindings bindings;
+
+  /**
+   * Creates a script-filtered intervals source.
+   *
+   * @param in the source to filter
+   * @param expression a compiled expression whose variables are resolved 
through {@code bindings}
+   * @param bindings bindings exposing the interval pseudo-variables
+   */
+  public ScriptFilterIntervalsSource(
+      IntervalsSource in, Expression expression, IntervalBindings bindings) {
+    super("SCRIPT(" + expression.sourceText + ")", in);
+    this.expression = expression;
+    this.bindings = bindings;
+  }
+
+  /** Evaluates the script against the current position of one interval 
iterator. */
+  private final class Evaluator {
+    private final IntervalValues interval;
+    private final DoubleValues[] values = new 
DoubleValues[expression.variables.length];
+
+    Evaluator(IntervalValues interval) {
+      this.interval = interval;
+      for (int i = 0; i < values.length; i++) {
+        DoubleValuesSource source = 
bindings.getDoubleValuesSource(expression.variables[i]);
+        values[i] =
+            new DoubleValues() {
+              @Override
+              public double doubleValue() {
+                return source instanceof IntervalDoubleValuesSource ids
+                    ? ids.value(Evaluator.this.interval)
+                    : Double.NaN;
+              }
+
+              @Override
+              public boolean advanceExact(int doc) {
+                return true;
+              }
+            };
+      }
+    }
+
+    boolean accept() throws IOException {
+      return expression.evaluate(values) != 0;
+    }
+  }
+
+  @Override
+  public IntervalIterator intervals(String field, LeafReaderContext ctx) 
throws IOException {
+    IntervalIterator in = this.in.intervals(field, ctx);
+    if (in == null) {
+      return null;
+    }
+    Evaluator evaluator = new Evaluator(new IteratorValues(in));
+    return new IntervalFilter(in) {
+      @Override
+      protected boolean accept() {
+        try {
+          return evaluator.accept();
+        } catch (IOException e) {
+          throw new RuntimeException(
+              "Failed to evaluate interval script '" + expression.sourceText + 
"'", e);
+        }
+      }
+    };
+  }
+
+  @Override
+  public IntervalMatchesIterator matches(String field, LeafReaderContext ctx, 
int doc)
+      throws IOException {
+    IntervalMatchesIterator source = in.matches(field, ctx, doc);
+    if (source == null) {
+      return null;
+    }
+    Evaluator evaluator =
+        new Evaluator(
+            new IntervalValues() {
+              @Override
+              public int start() {
+                return source.startPosition();
+              }
+
+              @Override
+              public int end() {
+                return source.endPosition();
+              }
+
+              @Override
+              public int gaps() {
+                return source.gaps();
+              }
+            });
+    boolean found = false;
+    while (source.next()) {
+      if (evaluator.accept()) {
+        found = true;
+        break;
+      }
+    }
+    if (!found) {
+      return null;
+    }
+    return new IntervalMatchesIterator() {
+      private boolean first = true;
+
+      @Override
+      public boolean next() throws IOException {
+        if (first) {
+          first = false;
+          return true;
+        }
+        while (source.next()) {
+          if (evaluator.accept()) {
+            return true;
+          }
+        }
+        return false;
+      }
+
+      @Override
+      public int startPosition() {
+        return source.startPosition();
+      }
+
+      @Override
+      public int endPosition() {
+        return source.endPosition();
+      }
+
+      @Override
+      public int startOffset() throws IOException {
+        return source.startOffset();
+      }
+
+      @Override
+      public int endOffset() throws IOException {
+        return source.endOffset();
+      }
+
+      @Override
+      public int gaps() {
+        return source.gaps();
+      }
+
+      @Override
+      public int width() {
+        return source.width();
+      }
+
+      @Override
+      public MatchesIterator getSubMatches() throws IOException {
+        return source.getSubMatches();
+      }
+
+      @Override
+      public Query getQuery() {
+        return source.getQuery();
+      }
+    };
+  }
+
+  @Override
+  protected boolean accept(IntervalIterator it) {
+    try {
+      return new Evaluator(new IteratorValues(it)).accept();
+    } catch (IOException e) {
+      throw new RuntimeException(
+          "Failed to evaluate interval script '" + expression.sourceText + 
"'", e);
+    }
+  }
+}
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 1b46186a076..44ca000e305 100644
--- a/solr/core/src/test/org/apache/solr/search/DistributedQParserTest.java
+++ b/solr/core/src/test/org/apache/solr/search/DistributedQParserTest.java
@@ -208,6 +208,88 @@ public class DistributedQParserTest extends 
SolrCloudTestCase {
     assertEquals(2, response.getResults().getNumFound());
   }
 
+  @Test
+  public void testIntervalsScriptFilterDistributed() throws Exception {
+    // Dedicated collection so the extra docs don't perturb the assertions of 
the other tests,
+    // which share the "distributed-qparser" collection.
+    String scriptCollection = "distributed-qparser-script";
+    CollectionAdminRequest.createCollection(scriptCollection, "conf", 2, 1)
+        .process(cluster.getSolrClient());
+    cluster.waitForActiveCollection(scriptCollection, 2, 2);
+
+    new UpdateRequest()
+        // "quick brown" ordered is [0..1] in docs 1 and 6, [1..2] in doc 8
+        .add(sdoc("id", "1", "subject", "quick brown fox"))
+        .add(sdoc("id", "6", "subject", "quick brown dog"))
+        .add(sdoc("id", "8", "subject", "fox quick brown"))
+        // "quick fox" ordered is [1..2] (gap 0) in doc 5 and [0..2] (gap 1) 
in docs 1 and 7
+        .add(sdoc("id", "5", "subject", "brown quick fox"))
+        .add(sdoc("id", "7", "subject", "quick lazy fox"))
+        .commit(cluster.getSolrClient(), scriptCollection);
+
+    // filter on interval.start: "quick brown" matches docs 1, 6, 8; keep only 
start == 0
+    QueryResponse response =
+        new DirectJsonQueryRequest(
+                "{"
+                    + "'query': {intervals: {use_field: subject, 'match': "
+                    + "{'query': 'quick brown', 'ordered': true, "
+                    + "'filter': {'script': {'source': 'interval.start == 
0'}}}}},"
+                    + "'fields': 'id'"
+                    + "}")
+            .process(cluster.getSolrClient(), scriptCollection);
+    assertEquals(2, response.getResults().getNumFound());
+
+    // filter on interval.end: only doc 8 has "quick brown" ending at position 
2
+    response =
+        new DirectJsonQueryRequest(
+                "{"
+                    + "'query': {intervals: {use_field: subject, 'match': "
+                    + "{'query': 'quick brown', 'ordered': true, "
+                    + "'filter': {'script': {'source': 'interval.end == 
2'}}}}},"
+                    + "'fields': 'id'"
+                    + "}")
+            .process(cluster.getSolrClient(), scriptCollection);
+    assertEquals(1, response.getResults().getNumFound());
+
+    // filter on interval.gap: "quick fox" matches docs 1, 5, 7; only doc 5 is 
adjacent (gap 0)
+    response =
+        new DirectJsonQueryRequest(
+                "{"
+                    + "'query': {intervals: {use_field: subject, 'match': "
+                    + "{'query': 'quick fox', 'ordered': true, "
+                    + "'filter': {'script': {'source': 'interval.gap == 
0'}}}}},"
+                    + "'fields': 'id'"
+                    + "}")
+            .process(cluster.getSolrClient(), scriptCollection);
+    assertEquals(1, response.getResults().getNumFound());
+
+    // combined expression over all three variables: "quick brown" at exactly 
[0..1] is docs 1 and 6
+    response =
+        new DirectJsonQueryRequest(
+                "{"
+                    + "'query': {intervals: {use_field: subject, 'match': "
+                    + "{'query': 'quick brown', 'ordered': true, "
+                    + "'filter': {'script': {'source': "
+                    + "'interval.start == 0 && interval.end == 1 && 
interval.gap == 0'}}}}},"
+                    + "'fields': 'id'"
+                    + "}")
+            .process(cluster.getSolrClient(), scriptCollection);
+    assertEquals(2, response.getResults().getNumFound());
+
+    // a script referencing an unknown interval variable fails cleanly on the 
shards
+    expectThrows(
+        SolrException.class,
+        () ->
+            new DirectJsonQueryRequest(
+                    "{"
+                        + "'query': {intervals: {use_field: subject, 'match': "
+                        + "{'query': 'quick brown', 'ordered': true, "
+                        + "'filter': {'script': {'source': 'interval.start + 
missing'}}}}},"
+                        + "'fields': 'id'"
+                        + "}")
+                .process(cluster.getSolrClient(), scriptCollection));
+  }
+
   public void testIntervalsJsonQParserException() throws Exception {
     SolrException exception =
         expectThrows(
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 f63d44921b6..0855bdd7817 100644
--- a/solr/core/src/test/org/apache/solr/search/TestIntervalsQParserPlugin.java
+++ b/solr/core/src/test/org/apache/solr/search/TestIntervalsQParserPlugin.java
@@ -444,6 +444,89 @@ public class TestIntervalsQParserPlugin extends 
SolrTestCaseJ4 {
         SolrException.ErrorCode.BAD_REQUEST);
   }
 
+  @Test
+  public void testIntervalsScriptFilterOnStart() throws Exception {
+    assertU(adoc("id", "500", "v_ws", "sta_a sta_b sta_c sta_d"));
+    assertU(adoc("id", "501", "v_ws", "sta_x sta_a sta_b"));
+    assertU(commit());
+
+    // "sta_a sta_b" starts at position 0 in doc 500 and at position 1 in doc 
501
+    assertJJQ(
+        "{ query:{intervals:{use_field:v_ws,"
+            + "match:{query:'sta_a 
sta_b',ordered:true,filter:{script:{source:'interval.start == 0'}}}}},"
+            + "fields:id}",
+        
"/response=={'numFound':1,'start':0,'numFoundExact':true,'docs':[{'id':'500'}]}");
+  }
+
+  @Test
+  public void testIntervalsScriptFilterOnEnd() throws Exception {
+    assertU(adoc("id", "510", "v_ws", "end_a end_b end_c"));
+    assertU(adoc("id", "511", "v_ws", "end_x end_a end_b"));
+    assertU(commit());
+
+    // "end_a end_b" ends at position 1 in doc 510 and at position 2 in doc 511
+    assertJJQ(
+        "{ query:{intervals:{use_field:v_ws,"
+            + "match:{query:'end_a 
end_b',ordered:true,filter:{script:{source:'interval.end == 1'}}}}},"
+            + "fields:id}",
+        
"/response=={'numFound':1,'start':0,'numFoundExact':true,'docs':[{'id':'510'}]}");
+  }
+
+  @Test
+  public void testIntervalsScriptFilterOnGap() throws Exception {
+    assertU(adoc("id", "520", "v_ws", "gap_a gap_b gap_c"));
+    assertU(adoc("id", "521", "v_ws", "gap_a gap_x gap_y gap_c"));
+    assertU(commit());
+
+    // "gap_a gap_c" spans positions 0..2 (gap 1) in doc 520 and 0..3 (gap 2) 
in doc 521
+    assertJJQ(
+        "{ query:{intervals:{use_field:v_ws,"
+            + "match:{query:'gap_a 
gap_c',max_gaps:2,ordered:true,filter:{script:{source:'interval.gap == 1'}}}}},"
+            + "fields:id}",
+        
"/response=={'numFound':1,'start':0,'numFoundExact':true,'docs':[{'id':'520'}]}");
+  }
+
+  @Test
+  public void testIntervalsScriptFilterCombinesVariables() throws Exception {
+    assertU(adoc("id", "530", "v_ws", "cob_b cob_c cob_d cob_a"));
+    assertU(adoc("id", "531", "v_ws", "cob_x cob_b cob_c cob_d"));
+    assertU(commit());
+
+    // "cob_b cob_c cob_d" is [0..2] in doc 530 and [1..3] in doc 531
+    assertJJQ(
+        "{ query:{intervals:{use_field:v_ws,"
+            + "match:{query:'cob_b cob_c cob_d',ordered:true,"
+            + "filter:{script:{source:'interval.start == 0 && interval.end == 
2 && interval.gap == 0'}}}}},"
+            + "fields:id}",
+        
"/response=={'numFound':1,'start':0,'numFoundExact':true,'docs':[{'id':'530'}]}");
+  }
+
+  @Test
+  public void testIntervalsScriptFilterInvalidScriptThrows() throws Exception {
+    assertQEx(
+        "script filter with a syntactically invalid expression should throw 
BAD_REQUEST",
+        "Invalid script expression",
+        req(
+            "json",
+            "{query:{intervals:{use_field:v_ws,"
+                + " match:{query:foo,filter:{script:{source:'interval.start 
=='}}}}},"
+                + "fields:id}"),
+        SolrException.ErrorCode.BAD_REQUEST);
+  }
+
+  @Test
+  public void testIntervalsScriptFilterUnknownVariableThrows() throws 
Exception {
+    assertQEx(
+        "script filter referencing an unknown variable should throw 
BAD_REQUEST",
+        "Unknown variable",
+        req(
+            "json",
+            "{query:{intervals:{use_field:v_ws,"
+                + " match:{query:foo,filter:{script:{source:'interval.start + 
missing_var'}}}}},"
+                + "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
diff --git 
a/solr/core/src/test/org/apache/solr/search/TestScriptFilterIntervalsSource.java
 
b/solr/core/src/test/org/apache/solr/search/TestScriptFilterIntervalsSource.java
new file mode 100644
index 00000000000..79f01875eff
--- /dev/null
+++ 
b/solr/core/src/test/org/apache/solr/search/TestScriptFilterIntervalsSource.java
@@ -0,0 +1,111 @@
+/*
+ * 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 org.apache.lucene.document.Document;
+import org.apache.lucene.document.Field;
+import org.apache.lucene.document.TextField;
+import org.apache.lucene.expressions.Expression;
+import org.apache.lucene.expressions.js.JavascriptCompiler;
+import org.apache.lucene.index.DirectoryReader;
+import org.apache.lucene.index.IndexWriter;
+import org.apache.lucene.index.IndexWriterConfig;
+import org.apache.lucene.index.LeafReaderContext;
+import org.apache.lucene.queries.intervals.IntervalMatchesIterator;
+import org.apache.lucene.queries.intervals.Intervals;
+import org.apache.lucene.store.Directory;
+import org.apache.lucene.tests.analysis.MockAnalyzer;
+import org.apache.lucene.tests.analysis.MockTokenizer;
+import org.apache.solr.SolrTestCase;
+
+/** Tests {@link ScriptFilterIntervalsSource#matches}, which highlighting and 
explain rely on. */
+public class TestScriptFilterIntervalsSource extends SolrTestCase {
+
+  private static final String FIELD = "f";
+
+  private static ScriptFilterIntervalsSource filter(String script) throws 
Exception {
+    Expression expression = JavascriptCompiler.compile(script);
+    return new ScriptFilterIntervalsSource(
+        Intervals.term("a"), expression, new 
ScriptFilterIntervalsSource.IntervalBindings());
+  }
+
+  /** The caller must keep the reader open while consuming the returned 
iterator. */
+  private static IntervalMatchesIterator matches(
+      LeafReaderContext ctx, ScriptFilterIntervalsSource source, int doc) 
throws IOException {
+    return source.matches(FIELD, ctx, doc);
+  }
+
+  private static Directory index(String... docs) throws IOException {
+    Directory dir = newDirectory();
+    IndexWriterConfig cfg =
+        new IndexWriterConfig(new MockAnalyzer(random(), 
MockTokenizer.WHITESPACE, false));
+    cfg.setMergePolicy(newLogMergePolicy());
+    try (IndexWriter w = new IndexWriter(dir, cfg)) {
+      for (String text : docs) {
+        Document d = new Document();
+        d.add(new TextField(FIELD, text, Field.Store.NO));
+        w.addDocument(d);
+      }
+      w.forceMerge(1);
+    }
+    return dir;
+  }
+
+  public void testMatchesSkipsRejectedLeadingIntervals() throws Exception {
+    // "a" occurs at positions 0, 2 and 3; only the later two satisfy start >= 
2
+    try (Directory dir = index("a b a a");
+        DirectoryReader reader = DirectoryReader.open(dir)) {
+      IntervalMatchesIterator mi =
+          matches(reader.leaves().get(0), filter("interval.start >= 2"), 0);
+      assertNotNull(mi);
+      assertTrue(mi.next());
+      assertEquals(2, mi.startPosition());
+      assertEquals(2, mi.endPosition());
+      assertTrue(mi.next());
+      assertEquals(3, mi.startPosition());
+      assertFalse(mi.next());
+    }
+  }
+
+  public void testMatchesFirstAcceptedIntervalIsNotLost() throws Exception {
+    // the first interval is accepted: it must be reported exactly once
+    try (Directory dir = index("a b a");
+        DirectoryReader reader = DirectoryReader.open(dir)) {
+      IntervalMatchesIterator mi =
+          matches(reader.leaves().get(0), filter("interval.start == 0"), 0);
+      assertNotNull(mi);
+      assertTrue(mi.next());
+      assertEquals(0, mi.startPosition());
+      assertFalse(mi.next());
+    }
+  }
+
+  public void testMatchesNullWhenNoIntervalAccepted() throws Exception {
+    try (Directory dir = index("a b a");
+        DirectoryReader reader = DirectoryReader.open(dir)) {
+      assertNull(matches(reader.leaves().get(0), filter("interval.start > 
10"), 0));
+    }
+  }
+
+  public void testMatchesNullWhenNoTermInDocument() throws Exception {
+    try (Directory dir = index("a b", "c d");
+        DirectoryReader reader = DirectoryReader.open(dir)) {
+      assertNull(matches(reader.leaves().get(0), filter("interval.start >= 
0"), 1));
+    }
+  }
+}
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 2e9efb0e149..d3da6f5e124 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
@@ -664,7 +664,7 @@ Example:
 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:
+The `filter` object contains exactly one of the following operators as its 
key, mapped to a nested rule object (except `script`, which is mapped to an 
object with a `source` property containing the expression):
 
 [%autowidth,frame=none]
 |===
@@ -693,6 +693,9 @@ The `filter` object contains exactly one of the following 
operators as its key,
 
 |`overlapping`
 |Source must overlap the filter interval.
+
+|`script`
+|Source interval is kept only when a script evaluates to a non-zero value for 
it.
 |===
 
 Example — match "solr" only when it appears after "apache":
@@ -709,6 +712,54 @@ Example — match "solr" only when it appears after "apache":
 }
 ----
 
+=== `script` Filter Operator
+
+The `script` filter operator evaluates a Lucene Expressions script for each 
candidate interval and keeps the interval only when the script returns a 
non-zero value.
+The script is a xref:expression-value-source-parser.adoc[Lucene Expressions] 
JavaScript expression and exposes the current interval through the following 
variables:
+
+[%autowidth,frame=none]
+|===
+|Variable |Description
+
+|`interval.start`
+|The start position of the current interval.
+
+|`interval.end`
+|The end position of the current interval.
+
+|`interval.gap`
+|The number of gaps within the current interval (between its immediate 
sub-intervals).
+|===
+
+Example — match "solr" only when it appears at the start of the field:
+
+[source,json]
+----
+{
+  "match": {
+    "query": "solr",
+    "filter": {
+      "script": { "source": "interval.start == 0" }
+    }
+  }
+}
+----
+
+Example — match a phrase only when it spans two adjacent positions:
+
+[source,json]
+----
+{
+  "match": {
+    "query": "apache solr",
+    "ordered": true,
+    "filter": {
+      "script": { "source": "interval.end - interval.start == 1" }
+    }
+  }
+}
+----
+
 == Full Example
 
 Find documents where the `title` field contains the phrase "apache solr" 
followed within 5 positions by the word "search":

Reply via email to