Skip to content

Commit ba0eaab

Browse files
Chandan83jimczi
authored andcommitted
Adds SpanGapQueryBuilder in the query DSL (#28636)
This change adds the support for a `span_gap` query inside the span query DSL.
1 parent 942054a commit ba0eaab

File tree

5 files changed

+341
-8
lines changed

5 files changed

+341
-8
lines changed

server/src/main/java/org/elasticsearch/index/query/SpanNearQueryBuilder.java

Lines changed: 210 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,11 @@
2424
import org.apache.lucene.search.spans.SpanQuery;
2525
import org.elasticsearch.common.ParseField;
2626
import org.elasticsearch.common.ParsingException;
27+
import org.elasticsearch.common.Strings;
2728
import org.elasticsearch.common.io.stream.StreamInput;
2829
import org.elasticsearch.common.io.stream.StreamOutput;
2930
import org.elasticsearch.common.xcontent.XContentBuilder;
31+
import org.elasticsearch.common.xcontent.XContentLocation;
3032
import org.elasticsearch.common.xcontent.XContentParser;
3133

3234
import java.io.IOException;
@@ -203,18 +205,54 @@ public static SpanNearQueryBuilder fromXContent(XContentParser parser) throws IO
203205

204206
@Override
205207
protected Query doToQuery(QueryShardContext context) throws IOException {
206-
if (clauses.size() == 1) {
207-
Query query = clauses.get(0).toQuery(context);
208+
SpanQueryBuilder queryBuilder = clauses.get(0);
209+
boolean isGap = queryBuilder instanceof SpanGapQueryBuilder;
210+
Query query = null;
211+
if (!isGap) {
212+
query = queryBuilder.toQuery(context);
208213
assert query instanceof SpanQuery;
214+
}
215+
if (clauses.size() == 1) {
216+
assert !isGap;
209217
return query;
210218
}
211-
SpanQuery[] spanQueries = new SpanQuery[clauses.size()];
212-
for (int i = 0; i < clauses.size(); i++) {
213-
Query query = clauses.get(i).toQuery(context);
214-
assert query instanceof SpanQuery;
215-
spanQueries[i] = (SpanQuery) query;
219+
String spanNearFieldName = null;
220+
if (isGap) {
221+
spanNearFieldName = ((SpanGapQueryBuilder) queryBuilder).fieldName();
222+
} else {
223+
spanNearFieldName = ((SpanQuery) query).getField();
216224
}
217-
return new SpanNearQuery(spanQueries, slop, inOrder);
225+
226+
SpanNearQuery.Builder builder = new SpanNearQuery.Builder(spanNearFieldName, inOrder);
227+
builder.setSlop(slop);
228+
/*
229+
* Lucene SpanNearQuery throws exceptions for certain use cases like adding gap to a
230+
* unordered SpanNearQuery. Should ES have the same checks or wrap those thrown exceptions?
231+
*/
232+
if (isGap) {
233+
int gap = ((SpanGapQueryBuilder) queryBuilder).width();
234+
builder.addGap(gap);
235+
} else {
236+
builder.addClause((SpanQuery) query);
237+
}
238+
239+
for (int i = 1; i < clauses.size(); i++) {
240+
queryBuilder = clauses.get(i);
241+
isGap = queryBuilder instanceof SpanGapQueryBuilder;
242+
if (isGap) {
243+
String fieldName = ((SpanGapQueryBuilder) queryBuilder).fieldName();
244+
if (!spanNearFieldName.equals(fieldName)) {
245+
throw new IllegalArgumentException("[span_near] clauses must have same field");
246+
}
247+
int gap = ((SpanGapQueryBuilder) queryBuilder).width();
248+
builder.addGap(gap);
249+
} else {
250+
query = clauses.get(i).toQuery(context);
251+
assert query instanceof SpanQuery;
252+
builder.addClause((SpanQuery)query);
253+
}
254+
}
255+
return builder.build();
218256
}
219257

220258
@Override
@@ -233,4 +271,168 @@ protected boolean doEquals(SpanNearQueryBuilder other) {
233271
public String getWriteableName() {
234272
return NAME;
235273
}
274+
275+
/**
276+
* SpanGapQueryBuilder enables gaps in a SpanNearQuery.
277+
* Since, SpanGapQuery is private to SpanNearQuery, SpanGapQueryBuilder cannot
278+
* be used to generate a Query (SpanGapQuery) like another QueryBuilder.
279+
* Instead, it just identifies a span_gap clause so that SpanNearQuery.addGap(int)
280+
* can be invoked for it.
281+
* This QueryBuilder is only applicable as a clause in SpanGapQueryBuilder but
282+
* yet to enforce this restriction.
283+
*/
284+
public static class SpanGapQueryBuilder implements SpanQueryBuilder {
285+
public static final String NAME = "span_gap";
286+
287+
/** Name of field to match against. */
288+
private final String fieldName;
289+
290+
/** Width of the gap introduced. */
291+
private final int width;
292+
293+
/**
294+
* Constructs a new SpanGapQueryBuilder term query.
295+
*
296+
* @param fieldName The name of the field
297+
* @param width The width of the gap introduced
298+
*/
299+
public SpanGapQueryBuilder(String fieldName, int width) {
300+
if (Strings.isEmpty(fieldName)) {
301+
throw new IllegalArgumentException("[span_gap] field name is null or empty");
302+
}
303+
//lucene has not coded any restriction on value of width.
304+
//to-do : find if theoretically it makes sense to apply restrictions.
305+
this.fieldName = fieldName;
306+
this.width = width;
307+
}
308+
309+
/**
310+
* Read from a stream.
311+
*/
312+
public SpanGapQueryBuilder(StreamInput in) throws IOException {
313+
fieldName = in.readString();
314+
width = in.readInt();
315+
}
316+
317+
/**
318+
* @return fieldName The name of the field
319+
*/
320+
public String fieldName() {
321+
return fieldName;
322+
}
323+
324+
/**
325+
* @return width The width of the gap introduced
326+
*/
327+
public int width() {
328+
return width;
329+
}
330+
331+
@Override
332+
public Query toQuery(QueryShardContext context) throws IOException {
333+
throw new UnsupportedOperationException();
334+
}
335+
336+
@Override
337+
public Query toFilter(QueryShardContext context) throws IOException {
338+
throw new UnsupportedOperationException();
339+
}
340+
341+
@Override
342+
public String queryName() {
343+
throw new UnsupportedOperationException();
344+
}
345+
346+
@Override
347+
public QueryBuilder queryName(String queryName) {
348+
throw new UnsupportedOperationException();
349+
}
350+
351+
@Override
352+
public float boost() {
353+
throw new UnsupportedOperationException();
354+
}
355+
356+
@Override
357+
public QueryBuilder boost(float boost) {
358+
throw new UnsupportedOperationException();
359+
}
360+
361+
@Override
362+
public String getName() {
363+
return NAME;
364+
}
365+
366+
@Override
367+
public String getWriteableName() {
368+
return NAME;
369+
}
370+
371+
@Override
372+
public final void writeTo(StreamOutput out) throws IOException {
373+
out.writeString(fieldName);
374+
out.writeInt(width);
375+
}
376+
377+
@Override
378+
public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException {
379+
builder.startObject();
380+
builder.startObject(getName());
381+
builder.field(fieldName, width);
382+
builder.endObject();
383+
builder.endObject();
384+
return builder;
385+
}
386+
387+
public static SpanGapQueryBuilder fromXContent(XContentParser parser) throws IOException {
388+
String fieldName = null;
389+
int width = 0;
390+
String currentFieldName = null;
391+
XContentParser.Token token;
392+
while ((token = parser.nextToken()) != XContentParser.Token.END_OBJECT) {
393+
if (token == XContentParser.Token.FIELD_NAME) {
394+
currentFieldName = parser.currentName();
395+
throwParsingExceptionOnMultipleFields(NAME, parser.getTokenLocation(), fieldName, currentFieldName);
396+
fieldName = currentFieldName;
397+
} else if (token.isValue()) {
398+
width = parser.intValue();
399+
}
400+
}
401+
SpanGapQueryBuilder result = new SpanGapQueryBuilder(fieldName, width);
402+
return result;
403+
}
404+
405+
@Override
406+
public final boolean equals(Object obj) {
407+
if (this == obj) {
408+
return true;
409+
}
410+
if (obj == null || getClass() != obj.getClass()) {
411+
return false;
412+
}
413+
SpanGapQueryBuilder other = (SpanGapQueryBuilder) obj;
414+
return Objects.equals(fieldName, other.fieldName) &&
415+
Objects.equals(width, other.width);
416+
}
417+
418+
@Override
419+
public final int hashCode() {
420+
return Objects.hash(getClass(), fieldName, width);
421+
}
422+
423+
424+
@Override
425+
public final String toString() {
426+
return Strings.toString(this, true, true);
427+
}
428+
429+
//copied from AbstractQueryBuilder
430+
protected static void throwParsingExceptionOnMultipleFields(String queryName, XContentLocation contentLocation,
431+
String processedFieldName, String currentFieldName) {
432+
if (processedFieldName != null) {
433+
throw new ParsingException(contentLocation, "[" + queryName + "] query doesn't support multiple fields, found ["
434+
+ processedFieldName + "] and [" + currentFieldName + "]");
435+
}
436+
}
437+
}
236438
}

server/src/main/java/org/elasticsearch/search/SearchModule.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,7 @@
261261

262262
import static java.util.Collections.unmodifiableMap;
263263
import static java.util.Objects.requireNonNull;
264+
import static org.elasticsearch.index.query.SpanNearQueryBuilder.SpanGapQueryBuilder;
264265

265266
/**
266267
* Sets up things that can be done at search time like queries, aggregations, and suggesters.
@@ -743,6 +744,7 @@ private void registerQueryParsers(List<SearchPlugin> plugins) {
743744
FieldMaskingSpanQueryBuilder::fromXContent));
744745
registerQuery(new QuerySpec<>(SpanFirstQueryBuilder.NAME, SpanFirstQueryBuilder::new, SpanFirstQueryBuilder::fromXContent));
745746
registerQuery(new QuerySpec<>(SpanNearQueryBuilder.NAME, SpanNearQueryBuilder::new, SpanNearQueryBuilder::fromXContent));
747+
registerQuery(new QuerySpec<>(SpanGapQueryBuilder.NAME, SpanGapQueryBuilder::new, SpanGapQueryBuilder::fromXContent));
746748
registerQuery(new QuerySpec<>(SpanOrQueryBuilder.NAME, SpanOrQueryBuilder::new, SpanOrQueryBuilder::fromXContent));
747749
registerQuery(new QuerySpec<>(MoreLikeThisQueryBuilder.NAME, MoreLikeThisQueryBuilder::new,
748750
MoreLikeThisQueryBuilder::fromXContent));
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
/*
2+
* Licensed to Elasticsearch under one or more contributor
3+
* license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright
5+
* ownership. Elasticsearch licenses this file to you under
6+
* the Apache License, Version 2.0 (the "License"); you may
7+
* not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
20+
package org.elasticsearch.index.query;
21+
22+
import org.apache.lucene.search.Query;
23+
import org.apache.lucene.search.spans.SpanBoostQuery;
24+
import org.apache.lucene.search.spans.SpanNearQuery;
25+
import org.apache.lucene.search.spans.SpanQuery;
26+
import org.apache.lucene.search.spans.SpanTermQuery;
27+
import org.elasticsearch.common.ParsingException;
28+
import org.elasticsearch.search.internal.SearchContext;
29+
import org.elasticsearch.test.AbstractQueryTestCase;
30+
31+
import java.io.IOException;
32+
import java.util.Iterator;
33+
34+
import static org.elasticsearch.index.query.SpanNearQueryBuilder.SpanGapQueryBuilder;
35+
import static org.hamcrest.CoreMatchers.containsString;
36+
import static org.hamcrest.CoreMatchers.either;
37+
import static org.hamcrest.CoreMatchers.equalTo;
38+
import static org.hamcrest.CoreMatchers.instanceOf;
39+
40+
/*
41+
* SpanGapQueryBuilder, unlike other QBs, is not used to build a Query. Therefore, it is not suited
42+
* to test pattern of AbstractQueryTestCase. Since it is only used in SpanNearQueryBuilder, its test cases
43+
* are same as those of later with SpanGapQueryBuilder included as clauses.
44+
*/
45+
46+
public class SpanGapQueryBuilderTests extends AbstractQueryTestCase<SpanNearQueryBuilder> {
47+
@Override
48+
protected SpanNearQueryBuilder doCreateTestQueryBuilder() {
49+
SpanTermQueryBuilder[] spanTermQueries = new SpanTermQueryBuilderTests().createSpanTermQueryBuilders(randomIntBetween(1, 6));
50+
SpanNearQueryBuilder queryBuilder = new SpanNearQueryBuilder(spanTermQueries[0], randomIntBetween(-10, 10));
51+
for (int i = 1; i < spanTermQueries.length; i++) {
52+
SpanTermQueryBuilder termQB = spanTermQueries[i];
53+
queryBuilder.addClause(termQB);
54+
if (i % 2 == 1) {
55+
SpanGapQueryBuilder gapQB = new SpanGapQueryBuilder(termQB.fieldName(), randomIntBetween(1,2));
56+
queryBuilder.addClause(gapQB);
57+
}
58+
}
59+
queryBuilder.inOrder(true);
60+
return queryBuilder;
61+
}
62+
63+
@Override
64+
protected void doAssertLuceneQuery(SpanNearQueryBuilder queryBuilder, Query query, SearchContext context) throws IOException {
65+
assertThat(query, either(instanceOf(SpanNearQuery.class))
66+
.or(instanceOf(SpanTermQuery.class))
67+
.or(instanceOf(SpanBoostQuery.class))
68+
.or(instanceOf(MatchAllQueryBuilder.class)));
69+
if (query instanceof SpanNearQuery) {
70+
SpanNearQuery spanNearQuery = (SpanNearQuery) query;
71+
assertThat(spanNearQuery.getSlop(), equalTo(queryBuilder.slop()));
72+
assertThat(spanNearQuery.isInOrder(), equalTo(queryBuilder.inOrder()));
73+
assertThat(spanNearQuery.getClauses().length, equalTo(queryBuilder.clauses().size()));
74+
Iterator<SpanQueryBuilder> spanQueryBuilderIterator = queryBuilder.clauses().iterator();
75+
for (SpanQuery spanQuery : spanNearQuery.getClauses()) {
76+
SpanQueryBuilder spanQB = spanQueryBuilderIterator.next();
77+
if (spanQB instanceof SpanGapQueryBuilder) continue;
78+
assertThat(spanQuery, equalTo(spanQB.toQuery(context.getQueryShardContext())));
79+
}
80+
} else if (query instanceof SpanTermQuery || query instanceof SpanBoostQuery) {
81+
assertThat(queryBuilder.clauses().size(), equalTo(1));
82+
assertThat(query, equalTo(queryBuilder.clauses().get(0).toQuery(context.getQueryShardContext())));
83+
}
84+
}
85+
86+
public void testIllegalArguments() {
87+
IllegalArgumentException e = expectThrows(IllegalArgumentException.class, () -> new SpanGapQueryBuilder(null, 1));
88+
assertEquals("[span_gap] field name is null or empty", e.getMessage());
89+
}
90+
91+
public void testFromJson() throws IOException {
92+
String json =
93+
"{\n" +
94+
" \"span_near\" : {\n" +
95+
" \"clauses\" : [ {\n" +
96+
" \"span_term\" : {\n" +
97+
" \"field\" : {\n" +
98+
" \"value\" : \"value1\",\n" +
99+
" \"boost\" : 1.0\n" +
100+
" }\n" +
101+
" }\n" +
102+
" }, {\n" +
103+
" \"span_gap\" : {\n" +
104+
" \"field\" : 2" +
105+
" }\n" +
106+
" }, {\n" +
107+
" \"span_term\" : {\n" +
108+
" \"field\" : {\n" +
109+
" \"value\" : \"value3\",\n" +
110+
" \"boost\" : 1.0\n" +
111+
" }\n" +
112+
" }\n" +
113+
" } ],\n" +
114+
" \"slop\" : 12,\n" +
115+
" \"in_order\" : false,\n" +
116+
" \"boost\" : 1.0\n" +
117+
" }\n" +
118+
"}";
119+
120+
SpanNearQueryBuilder parsed = (SpanNearQueryBuilder) parseQuery(json);
121+
checkGeneratedJson(json, parsed);
122+
123+
assertEquals(json, 3, parsed.clauses().size());
124+
assertEquals(json, 12, parsed.slop());
125+
assertEquals(json, false, parsed.inOrder());
126+
}
127+
}

server/src/test/java/org/elasticsearch/index/query/SpanNearQueryBuilderTests.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,4 +184,5 @@ public void testCollectPayloadsNoLongerSupported() throws Exception {
184184
() -> parseQuery(json));
185185
assertThat(e.getMessage(), containsString("[span_near] query does not support [collect_payloads]"));
186186
}
187+
187188
}

0 commit comments

Comments
 (0)