From ef2072af3a013bf335e621b49904619592ad53a2 Mon Sep 17 00:00:00 2001 From: Mikhail Khludnev Date: Sat, 24 Oct 2026 21:57:52 +0300 Subject: [PATCH 01/10] SOLR-18585: intervals script support --- .../unreleased/intervals-script-filter.yml | 10 + .../solr/search/IntervalsQParserPlugin.java | 38 +++- .../search/ScriptFilterIntervalsSource.java | 172 ++++++++++++++++++ .../solr/search/DistributedQParserTest.java | 82 +++++++++ .../search/TestIntervalsQParserPlugin.java | 83 +++++++++ .../pages/intervals-query-parser.adoc | 52 +++++- 6 files changed, 434 insertions(+), 3 deletions(-) create mode 100644 changelog/unreleased/intervals-script-filter.yml create mode 100644 solr/core/src/java/org/apache/solr/search/ScriptFilterIntervalsSource.java diff --git a/changelog/unreleased/intervals-script-filter.yml b/changelog/unreleased/intervals-script-filter.yml new file mode 100644 index 000000000000..0c71b9f21591 --- /dev/null +++ b/changelog/unreleased/intervals-script-filter.yml @@ -0,0 +1,10 @@ +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 +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 02d696d87b31..d5c7ee59872a 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 @@ private IntervalsSource applyFilter( Map.Entry entry = filterMap.entrySet().iterator().next(); String op = entry.getKey(); if ("script".equals(op)) { - throw new SolrException( - SolrException.ErrorCode.BAD_REQUEST, "Filter operator 'script' is not supported"); + return parseScriptFilter(source, entry.getValue()); } IntervalsSource other = parseRuleObject( @@ -455,6 +457,38 @@ private IntervalsSource applyFilter( }; } + /** + * 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 000000000000..6c2a49fc7395 --- /dev/null +++ b/solr/core/src/java/org/apache/solr/search/ScriptFilterIntervalsSource.java @@ -0,0 +1,172 @@ +/* + * 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.IntervalIterator; +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; + +/** + * An {@link FilteredIntervalsSource} that filters intervals by evaluating a Lucene Expressions + * script against the current interval. + * + *

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(IntervalIterator it) { + return it.start(); + } + }; + private static final DoubleValuesSource END = + new IntervalDoubleValuesSource("end") { + @Override + protected double value(IntervalIterator it) { + return it.end(); + } + }; + private static final DoubleValuesSource GAP = + new IntervalDoubleValuesSource("gap") { + @Override + protected double value(IntervalIterator it) { + return it.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(IntervalIterator it); + } + + 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; + } + + @Override + protected boolean accept(IntervalIterator it) { + try { + DoubleValues[] functionValues = new DoubleValues[expression.variables.length]; + for (int i = 0; i < functionValues.length; i++) { + DoubleValuesSource source = bindings.getDoubleValuesSource(expression.variables[i]); + functionValues[i] = + new DoubleValues() { + @Override + public double doubleValue() { + return source instanceof IntervalDoubleValuesSource ids + ? ids.value(it) + : Double.NaN; + } + + @Override + public boolean advanceExact(int doc) { + return true; + } + }; + } + return expression.evaluate(functionValues) != 0; + } 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 1b46186a076b..44ca000e305e 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 void testIntervalsJsonQParser() throws Exception { 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 f63d44921b61..0855bdd78170 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 void testIntervalsUnknownAnalyzerFieldTypeThrows() throws Exception { 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/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 2e9efb0e149f..4735fa74b6f6 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,53 @@ 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 a single position: + +[source,json] +---- +{ + "match": { + "query": "apache solr", + "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": From c5a7c0b3569e16bb995c7727d99e938e346ebf23 Mon Sep 17 00:00:00 2001 From: mkhludnev <807522+mkhludnev@users.noreply.github.com> Date: Sat, 24 Oct 2026 21:29:28 +0000 Subject: [PATCH 02/10] Optimize script interval filtering Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../search/ScriptFilterIntervalsSource.java | 176 ++++++++++++++++-- .../pages/intervals-query-parser.adoc | 3 +- 2 files changed, 163 insertions(+), 16 deletions(-) diff --git a/solr/core/src/java/org/apache/solr/search/ScriptFilterIntervalsSource.java b/solr/core/src/java/org/apache/solr/search/ScriptFilterIntervalsSource.java index 6c2a49fc7395..3c0901dd057e 100644 --- a/solr/core/src/java/org/apache/solr/search/ScriptFilterIntervalsSource.java +++ b/solr/core/src/java/org/apache/solr/search/ScriptFilterIntervalsSource.java @@ -21,11 +21,15 @@ 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 @@ -42,22 +46,22 @@ public static class IntervalBindings extends Bindings { private static final DoubleValuesSource START = new IntervalDoubleValuesSource("start") { @Override - protected double value(IntervalIterator it) { - return it.start(); + protected double value(IntervalValues interval) { + return interval.start(); } }; private static final DoubleValuesSource END = new IntervalDoubleValuesSource("end") { @Override - protected double value(IntervalIterator it) { - return it.end(); + protected double value(IntervalValues interval) { + return interval.end(); } }; private static final DoubleValuesSource GAP = new IntervalDoubleValuesSource("gap") { @Override - protected double value(IntervalIterator it) { - return it.gaps(); + protected double value(IntervalValues interval) { + return interval.gaps(); } }; @@ -122,7 +126,38 @@ public final String toString() { return "interval." + variable; } - protected abstract double value(IntervalIterator it); + 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; @@ -142,18 +177,21 @@ public ScriptFilterIntervalsSource( this.bindings = bindings; } - @Override - protected boolean accept(IntervalIterator it) { - try { - DoubleValues[] functionValues = new DoubleValues[expression.variables.length]; - for (int i = 0; i < functionValues.length; i++) { + /** 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]); - functionValues[i] = + values[i] = new DoubleValues() { @Override public double doubleValue() { return source instanceof IntervalDoubleValuesSource ids - ? ids.value(it) + ? ids.value(Evaluator.this.interval) : Double.NaN; } @@ -163,7 +201,115 @@ public boolean advanceExact(int doc) { } }; } - return expression.evaluate(functionValues) != 0; + } + + 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(); + } + }); + return new IntervalMatchesIterator() { + @Override + public boolean next() throws IOException { + 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/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 4735fa74b6f6..d3da6f5e1244 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 @@ -745,13 +745,14 @@ Example — match "solr" only when it appears at the start of the field: } ---- -Example — match a phrase only when it spans a single position: +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" } } From 9cf8268812d2ed740bd728368f3712b9fdf0a854 Mon Sep 17 00:00:00 2001 From: Mikhail Khludnev Date: Sun, 25 Oct 2026 01:15:23 +0300 Subject: [PATCH 03/10] Implement acceptance check in ScriptFilterIntervalsSource Add logic to check for acceptance in ScriptFilterIntervalsSource. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../solr/search/ScriptFilterIntervalsSource.java | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/solr/core/src/java/org/apache/solr/search/ScriptFilterIntervalsSource.java b/solr/core/src/java/org/apache/solr/search/ScriptFilterIntervalsSource.java index 3c0901dd057e..1680111ba101 100644 --- a/solr/core/src/java/org/apache/solr/search/ScriptFilterIntervalsSource.java +++ b/solr/core/src/java/org/apache/solr/search/ScriptFilterIntervalsSource.java @@ -253,9 +253,25 @@ 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; From 4ebdbd58300cb3fd0a8b0bf056d54d38b7c59f26 Mon Sep 17 00:00:00 2001 From: mkhludnev <807522+mkhludnev@users.noreply.github.com> Date: Sat, 24 Oct 2026 22:18:37 +0000 Subject: [PATCH 04/10] Test ScriptFilterIntervalsSource.matches with rejected leading intervals Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../TestScriptFilterIntervalsSource.java | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 solr/core/src/test/org/apache/solr/search/TestScriptFilterIntervalsSource.java 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 000000000000..bbee563a2300 --- /dev/null +++ b/solr/core/src/test/org/apache/solr/search/TestScriptFilterIntervalsSource.java @@ -0,0 +1,107 @@ +/* + * 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.analysis.MockAnalyzer; +import org.apache.lucene.analysis.MockTokenizer; +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.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()); + } + + private static IntervalMatchesIterator matches( + Directory dir, ScriptFilterIntervalsSource source, int doc) throws IOException { + try (DirectoryReader reader = DirectoryReader.open(dir)) { + LeafReaderContext ctx = reader.leaves().get(0); + 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")) { + IntervalMatchesIterator mi = matches(dir, 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")) { + IntervalMatchesIterator mi = matches(dir, 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")) { + assertNull(matches(dir, filter("interval.start > 10"), 0)); + } + } + + public void testMatchesNullWhenNoTermInDocument() throws Exception { + try (Directory dir = index("a b", "c d")) { + assertNull(matches(dir, filter("interval.start >= 0"), 1)); + } + } +} From 542cafb0e00bd7ef95eba6ca53f066e80e2f26e1 Mon Sep 17 00:00:00 2001 From: Mikhail Khludnev Date: Sun, 25 Oct 2026 09:26:41 +0300 Subject: [PATCH 05/10] Update imports for MockAnalyzer and MockTokenizer Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../apache/solr/search/TestScriptFilterIntervalsSource.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/solr/core/src/test/org/apache/solr/search/TestScriptFilterIntervalsSource.java b/solr/core/src/test/org/apache/solr/search/TestScriptFilterIntervalsSource.java index bbee563a2300..3e2b53d8fbfd 100644 --- a/solr/core/src/test/org/apache/solr/search/TestScriptFilterIntervalsSource.java +++ b/solr/core/src/test/org/apache/solr/search/TestScriptFilterIntervalsSource.java @@ -17,8 +17,8 @@ package org.apache.solr.search; import java.io.IOException; -import org.apache.lucene.analysis.MockAnalyzer; -import org.apache.lucene.analysis.MockTokenizer; +import org.apache.lucene.tests.analysis.MockAnalyzer; +import org.apache.lucene.tests.analysis.MockTokenizer; import org.apache.lucene.document.Document; import org.apache.lucene.document.Field; import org.apache.lucene.document.TextField; From f28c6e79c82a9fb74c3e07607b86e74b3d4331d0 Mon Sep 17 00:00:00 2001 From: Mikhail Khludnev Date: Sun, 25 Oct 2026 22:58:48 +0300 Subject: [PATCH 06/10] tidy --- .../apache/solr/search/TestScriptFilterIntervalsSource.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/solr/core/src/test/org/apache/solr/search/TestScriptFilterIntervalsSource.java b/solr/core/src/test/org/apache/solr/search/TestScriptFilterIntervalsSource.java index 3e2b53d8fbfd..3927d94120a8 100644 --- a/solr/core/src/test/org/apache/solr/search/TestScriptFilterIntervalsSource.java +++ b/solr/core/src/test/org/apache/solr/search/TestScriptFilterIntervalsSource.java @@ -17,8 +17,6 @@ package org.apache.solr.search; import java.io.IOException; -import org.apache.lucene.tests.analysis.MockAnalyzer; -import org.apache.lucene.tests.analysis.MockTokenizer; import org.apache.lucene.document.Document; import org.apache.lucene.document.Field; import org.apache.lucene.document.TextField; @@ -31,6 +29,8 @@ 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. */ From 1f3ed5658862b8fd682fcb999a1d3dd12863957f Mon Sep 17 00:00:00 2001 From: Nick Shanin Date: Sun, 25 Oct 2026 19:39:04 -0400 Subject: [PATCH 07/10] SOLR-18585: keep interval test readers open during iteration --- .../TestScriptFilterIntervalsSource.java | 28 ++++++++++--------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/solr/core/src/test/org/apache/solr/search/TestScriptFilterIntervalsSource.java b/solr/core/src/test/org/apache/solr/search/TestScriptFilterIntervalsSource.java index 3927d94120a8..77ca249044cb 100644 --- a/solr/core/src/test/org/apache/solr/search/TestScriptFilterIntervalsSource.java +++ b/solr/core/src/test/org/apache/solr/search/TestScriptFilterIntervalsSource.java @@ -44,12 +44,10 @@ private static ScriptFilterIntervalsSource filter(String script) throws Exceptio Intervals.term("a"), expression, new ScriptFilterIntervalsSource.IntervalBindings()); } + /** The caller must keep the reader open while consuming the returned iterator. */ private static IntervalMatchesIterator matches( - Directory dir, ScriptFilterIntervalsSource source, int doc) throws IOException { - try (DirectoryReader reader = DirectoryReader.open(dir)) { - LeafReaderContext ctx = reader.leaves().get(0); - return source.matches(FIELD, ctx, doc); - } + LeafReaderContext ctx, ScriptFilterIntervalsSource source, int doc) throws IOException { + return source.matches(FIELD, ctx, doc); } private static Directory index(String... docs) throws IOException { @@ -70,8 +68,9 @@ private static Directory index(String... docs) throws IOException { 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")) { - IntervalMatchesIterator mi = matches(dir, filter("interval.start >= 2"), 0); + 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()); @@ -84,8 +83,9 @@ public void testMatchesSkipsRejectedLeadingIntervals() throws Exception { public void testMatchesFirstAcceptedIntervalIsNotLost() throws Exception { // the first interval is accepted: it must be reported exactly once - try (Directory dir = index("a b a")) { - IntervalMatchesIterator mi = matches(dir, filter("interval.start == 0"), 0); + 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()); @@ -94,14 +94,16 @@ public void testMatchesFirstAcceptedIntervalIsNotLost() throws Exception { } public void testMatchesNullWhenNoIntervalAccepted() throws Exception { - try (Directory dir = index("a b a")) { - assertNull(matches(dir, filter("interval.start > 10"), 0)); + 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")) { - assertNull(matches(dir, filter("interval.start >= 0"), 1)); + try (Directory dir = index("a b", "c d"); + DirectoryReader reader = DirectoryReader.open(dir)) { + assertNull(matches(reader.leaves().get(0), filter("interval.start >= 0"), 1)); } } } From 72a0daacd37478f3954272f4fa55511703af4b78 Mon Sep 17 00:00:00 2001 From: Nick Shanin Date: Sun, 25 Oct 2026 23:54:23 +0000 Subject: [PATCH 08/10] Tidy TestScriptFilterIntervalsSource line wrapping --- .../apache/solr/search/TestScriptFilterIntervalsSource.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/solr/core/src/test/org/apache/solr/search/TestScriptFilterIntervalsSource.java b/solr/core/src/test/org/apache/solr/search/TestScriptFilterIntervalsSource.java index 77ca249044cb..79f01875effe 100644 --- a/solr/core/src/test/org/apache/solr/search/TestScriptFilterIntervalsSource.java +++ b/solr/core/src/test/org/apache/solr/search/TestScriptFilterIntervalsSource.java @@ -70,7 +70,8 @@ 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); + IntervalMatchesIterator mi = + matches(reader.leaves().get(0), filter("interval.start >= 2"), 0); assertNotNull(mi); assertTrue(mi.next()); assertEquals(2, mi.startPosition()); @@ -85,7 +86,8 @@ 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); + IntervalMatchesIterator mi = + matches(reader.leaves().get(0), filter("interval.start == 0"), 0); assertNotNull(mi); assertTrue(mi.next()); assertEquals(0, mi.startPosition()); From 6b3d4a30ba09334c69cf2fea32797cc59efa04f7 Mon Sep 17 00:00:00 2001 From: Mikhail Khludnev Date: Sun, 1 Nov 2026 01:58:01 +0300 Subject: [PATCH 09/10] creds to nick-boss-tech --- changelog/unreleased/intervals-script-filter.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/changelog/unreleased/intervals-script-filter.yml b/changelog/unreleased/intervals-script-filter.yml index 0c71b9f21591..47a39199e572 100644 --- a/changelog/unreleased/intervals-script-filter.yml +++ b/changelog/unreleased/intervals-script-filter.yml @@ -5,6 +5,8 @@ type: added authors: - name: Mikhail Khludnev nick: mkhl + - name: Nick + nick: nick-boss-tech links: - name: SOLR-18585 url: https://issues.apache.org/jira/browse/SOLR-18585 From 48679534eec9bfc2765a91ae06c13eb34a38531a Mon Sep 17 00:00:00 2001 From: Mikhail Khludnev Date: Sun, 1 Nov 2026 02:17:39 +0300 Subject: [PATCH 10/10] Update author name for Nick in changelog --- changelog/unreleased/intervals-script-filter.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog/unreleased/intervals-script-filter.yml b/changelog/unreleased/intervals-script-filter.yml index 47a39199e572..4daedb3d38fa 100644 --- a/changelog/unreleased/intervals-script-filter.yml +++ b/changelog/unreleased/intervals-script-filter.yml @@ -5,7 +5,7 @@ type: added authors: - name: Mikhail Khludnev nick: mkhl - - name: Nick + - name: Nick Shanin nick: nick-boss-tech links: - name: SOLR-18585