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":