Skip to content

Commit 86f3b47

Browse files
author
Christoph Büscher
authored
Make date_range query rounding consistent with date (#50237) (#51741)
Currently the rounding used in range queries can behave differently for `date` and `date_range` as explained in #50009. The behaviour on `date` fields is the one we document in https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-range-query.html#range-query-date-math-rounding. This change adapts the rounding behaviour for RangeType.DATE so it uses the same logic as the `date` for the `date_range` type. Backport of #50237
1 parent e372854 commit 86f3b47

File tree

10 files changed

+176
-29
lines changed

10 files changed

+176
-29
lines changed

docs/reference/migration/migrate_7_7.asciidoc

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,3 +50,18 @@ will fail to start.
5050

5151
The `order` config of authentication realms must be unique in version 8.0.0.
5252
If you configure more than one realm of any type with the same order, the node will fail to start.
53+
54+
[discrete]
55+
[[breaking_77_search_changes]]
56+
=== Search changes
57+
58+
[discrete]
59+
==== Consistent rounding of range queries on `date_range` fields
60+
`range` queries on `date_range` field currently can have slightly differently
61+
boundaries than their equivalent query on a pure `date` field. This can e.g.
62+
happen when using date math or dates that don't specify up to the last
63+
millisecond. While queries on `date` field round up to the latest millisecond
64+
for `gt` and `lte` boundaries, the same queries on `date_range` fields didn't
65+
do this. The behavior is now the same for both field types like documented in
66+
<<range-query-date-math-rounding>>.
67+

rest-api-spec/src/main/resources/rest-api-spec/test/range/10_basic.yml

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,3 +391,59 @@ setup:
391391
body: { "size" : 0, "query" : { "range" : { "date_range" : { "gte": "2017-09-03", "lte" : "2017-09-04", "relation": "within" } } } }
392392

393393
- match: { hits.total: 0 }
394+
395+
---
396+
"Date range rounding":
397+
- skip:
398+
version: " - 7.6.99"
399+
reason: "This part tests rounding behaviour changed in 7.7"
400+
401+
- do:
402+
index:
403+
index: test
404+
id: 1
405+
body: { "date_range" : { "gte": "2019-12-14T12:00:00.000Z", "lte": "2019-12-14T13:00:00.000Z" } }
406+
407+
- do:
408+
index:
409+
index: test
410+
id: 2
411+
body: { "date_range" : { "gte": "2019-12-15T12:00:00.000Z", "lte": "2019-12-15T13:00:00.000Z" } }
412+
413+
- do:
414+
index:
415+
index: test
416+
id: 3
417+
body: { "date_range" : { "gte": "2019-12-16T12:00:00.000Z", "lte": "2019-12-16T13:00:00.000Z" } }
418+
419+
420+
- do:
421+
indices.refresh: {}
422+
423+
- do:
424+
search:
425+
rest_total_hits_as_int: true
426+
body: { "size" : 0, "query" : { "range" : { "date_range" : { "gt": "2019-12-15||/d", "relation": "within" } } } }
427+
428+
- match: { hits.total: 1 }
429+
430+
- do:
431+
search:
432+
rest_total_hits_as_int: true
433+
body: { "size" : 0, "query" : { "range" : { "date_range" : { "gte": "2019-12-15||/d", "relation": "within" } } } }
434+
435+
- match: { hits.total: 2 }
436+
437+
- do:
438+
search:
439+
rest_total_hits_as_int: true
440+
body: { "size" : 0, "query" : { "range" : { "date_range" : { "lt": "2019-12-15||/d", "relation": "within" } } } }
441+
442+
- match: { hits.total: 1 }
443+
444+
- do:
445+
search:
446+
rest_total_hits_as_int: true
447+
body: { "size" : 0, "query" : { "range" : { "date_range" : { "lte": "2019-12-15||/d", "relation": "within" } } } }
448+
449+
- match: { hits.total: 2 }

server/src/main/java/org/elasticsearch/index/mapper/DateFieldMapper.java

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@
6363
import java.util.Locale;
6464
import java.util.Map;
6565
import java.util.Objects;
66+
import java.util.function.LongSupplier;
6667

6768
import static org.elasticsearch.common.time.DateUtils.toLong;
6869

@@ -371,15 +372,15 @@ public Query rangeQuery(Object lowerTerm, Object upperTerm, boolean includeLower
371372
if (lowerTerm == null) {
372373
l = Long.MIN_VALUE;
373374
} else {
374-
l = parseToLong(lowerTerm, !includeLower, timeZone, parser, context);
375+
l = parseToLong(lowerTerm, !includeLower, timeZone, parser, context::nowInMillis);
375376
if (includeLower == false) {
376377
++l;
377378
}
378379
}
379380
if (upperTerm == null) {
380381
u = Long.MAX_VALUE;
381382
} else {
382-
u = parseToLong(upperTerm, includeUpper, timeZone, parser, context);
383+
u = parseToLong(upperTerm, includeUpper, timeZone, parser, context::nowInMillis);
383384
if (includeUpper == false) {
384385
--u;
385386
}
@@ -393,7 +394,7 @@ public Query rangeQuery(Object lowerTerm, Object upperTerm, boolean includeLower
393394
}
394395

395396
public long parseToLong(Object value, boolean roundUp,
396-
@Nullable ZoneId zone, @Nullable DateMathParser forcedDateParser, QueryRewriteContext context) {
397+
@Nullable ZoneId zone, @Nullable DateMathParser forcedDateParser, LongSupplier now) {
397398
DateMathParser dateParser = dateMathParser();
398399
if (forcedDateParser != null) {
399400
dateParser = forcedDateParser;
@@ -405,7 +406,7 @@ public long parseToLong(Object value, boolean roundUp,
405406
} else {
406407
strValue = value.toString();
407408
}
408-
Instant instant = dateParser.parse(strValue, context::nowInMillis, roundUp, zone);
409+
Instant instant = dateParser.parse(strValue, now, roundUp, zone);
409410
return resolution.convert(instant);
410411
}
411412

@@ -419,7 +420,7 @@ public Relation isFieldWithinQuery(IndexReader reader,
419420

420421
long fromInclusive = Long.MIN_VALUE;
421422
if (from != null) {
422-
fromInclusive = parseToLong(from, !includeLower, timeZone, dateParser, context);
423+
fromInclusive = parseToLong(from, !includeLower, timeZone, dateParser, context::nowInMillis);
423424
if (includeLower == false) {
424425
if (fromInclusive == Long.MAX_VALUE) {
425426
return Relation.DISJOINT;
@@ -430,7 +431,7 @@ public Relation isFieldWithinQuery(IndexReader reader,
430431

431432
long toInclusive = Long.MAX_VALUE;
432433
if (to != null) {
433-
toInclusive = parseToLong(to, includeUpper, timeZone, dateParser, context);
434+
toInclusive = parseToLong(to, includeUpper, timeZone, dateParser, context::nowInMillis);
434435
if (includeUpper == false) {
435436
if (toInclusive == Long.MIN_VALUE) {
436437
return Relation.DISJOINT;

server/src/main/java/org/elasticsearch/index/mapper/RangeType.java

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -232,12 +232,14 @@ public Query rangeQuery(String field, boolean hasDocValues, Object lowerTerm, Ob
232232

233233
DateMathParser dateMathParser = (parser == null) ?
234234
DateFieldMapper.DEFAULT_DATE_TIME_FORMATTER.toDateMathParser() : parser;
235+
boolean roundUp = includeLower == false; // using "gt" should round lower bound up
235236
Long low = lowerTerm == null ? Long.MIN_VALUE :
236237
dateMathParser.parse(lowerTerm instanceof BytesRef ? ((BytesRef) lowerTerm).utf8ToString() : lowerTerm.toString(),
237-
context::nowInMillis, false, zone).toEpochMilli();
238+
context::nowInMillis, roundUp, zone).toEpochMilli();
239+
roundUp = includeUpper; // using "lte" should round upper bound up
238240
Long high = upperTerm == null ? Long.MAX_VALUE :
239241
dateMathParser.parse(upperTerm instanceof BytesRef ? ((BytesRef) upperTerm).utf8ToString() : upperTerm.toString(),
240-
context::nowInMillis, false, zone).toEpochMilli();
242+
context::nowInMillis, roundUp, zone).toEpochMilli();
241243

242244
return super.rangeQuery(field, hasDocValues, low, high, includeLower, includeUpper, relation, zone,
243245
dateMathParser, context);

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

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,16 +30,15 @@
3030
import org.elasticsearch.common.io.stream.StreamOutput;
3131
import org.elasticsearch.common.lucene.search.Queries;
3232
import org.elasticsearch.common.unit.DistanceUnit;
33+
import org.elasticsearch.common.unit.TimeValue;
3334
import org.elasticsearch.common.xcontent.ConstructingObjectParser;
3435
import org.elasticsearch.common.xcontent.ObjectParser;
3536
import org.elasticsearch.common.xcontent.XContentBuilder;
3637
import org.elasticsearch.common.xcontent.XContentParser;
37-
import org.elasticsearch.common.unit.TimeValue;
38-
3938
import org.elasticsearch.index.mapper.DateFieldMapper;
40-
import org.elasticsearch.index.mapper.MappedFieldType;
41-
import org.elasticsearch.index.mapper.GeoPointFieldMapper.GeoPointFieldType;
4239
import org.elasticsearch.index.mapper.DateFieldMapper.DateFieldType;
40+
import org.elasticsearch.index.mapper.GeoPointFieldMapper.GeoPointFieldType;
41+
import org.elasticsearch.index.mapper.MappedFieldType;
4342

4443
import java.io.IOException;
4544
import java.util.Objects;
@@ -122,7 +121,7 @@ protected Query doToQuery(QueryShardContext context) throws IOException {
122121
}
123122
Object originObj = origin.origin();
124123
if (fieldType instanceof DateFieldType) {
125-
long originLong = ((DateFieldType) fieldType).parseToLong(originObj, true, null, null, context);
124+
long originLong = ((DateFieldType) fieldType).parseToLong(originObj, true, null, null, context::nowInMillis);
126125
TimeValue pivotVal = TimeValue.parseTimeValue(pivot, DistanceFeatureQueryBuilder.class.getSimpleName() + ".pivot");
127126
if (((DateFieldType) fieldType).resolution() == DateFieldMapper.Resolution.MILLISECONDS) {
128127
return LongPoint.newDistanceFeatureQuery(field, boost, originLong, pivotVal.getMillis());
@@ -213,7 +212,9 @@ Object origin() {
213212

214213
@Override
215214
public final boolean equals(Object other) {
216-
if ((other instanceof Origin) == false) return false;
215+
if ((other instanceof Origin) == false) {
216+
return false;
217+
}
217218
Object otherOrigin = ((Origin) other).origin();
218219
return this.origin().equals(otherOrigin);
219220
}

server/src/main/java/org/elasticsearch/index/query/functionscore/DecayFunctionBuilder.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -318,7 +318,7 @@ private AbstractDistanceScoreFunction parseDateVariable(XContentParser parser, Q
318318
if (originString == null) {
319319
origin = context.nowInMillis();
320320
} else {
321-
origin = ((DateFieldMapper.DateFieldType) dateFieldType).parseToLong(originString, false, null, null, context);
321+
origin = ((DateFieldMapper.DateFieldType) dateFieldType).parseToLong(originString, false, null, null, context::nowInMillis);
322322
}
323323

324324
if (scaleString == null) {

server/src/test/java/org/elasticsearch/index/mapper/RangeFieldQueryStringQueryBuilderTests.java

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@
2323
import org.apache.lucene.document.FloatRange;
2424
import org.apache.lucene.document.InetAddressRange;
2525
import org.apache.lucene.document.IntRange;
26+
import org.apache.lucene.document.LongPoint;
2627
import org.apache.lucene.document.LongRange;
28+
import org.apache.lucene.document.SortedNumericDocValuesField;
2729
import org.apache.lucene.queries.BinaryDocValuesRangeQuery;
2830
import org.apache.lucene.search.IndexOrDocValuesQuery;
2931
import org.apache.lucene.search.PointRangeQuery;
@@ -102,14 +104,29 @@ public void testDateRangeQuery() throws Exception {
102104
RangeFieldMapper.RangeFieldType type = (RangeFieldMapper.RangeFieldType) context.fieldMapper(DATE_RANGE_FIELD_NAME);
103105
DateMathParser parser = type.dateMathParser;
104106
Query query = new QueryStringQueryBuilder(DATE_RANGE_FIELD_NAME + ":[2010-01-01 TO 2018-01-01]").toQuery(createShardContext());
107+
String lowerBoundExact = "2010-01-01T00:00:00.000";
108+
String upperBoundExact = "2018-01-01T23:59:59.999";
105109
Query range = LongRange.newIntersectsQuery(DATE_RANGE_FIELD_NAME,
106-
new long[]{ parser.parse("2010-01-01", () -> 0).toEpochMilli()},
107-
new long[]{ parser.parse("2018-01-01", () -> 0).toEpochMilli()});
110+
new long[]{ parser.parse(lowerBoundExact, () -> 0).toEpochMilli()},
111+
new long[]{ parser.parse(upperBoundExact, () -> 0).toEpochMilli()});
108112
Query dv = RangeType.DATE.dvRangeQuery(DATE_RANGE_FIELD_NAME,
109113
BinaryDocValuesRangeQuery.QueryType.INTERSECTS,
110-
parser.parse("2010-01-01", () -> 0).toEpochMilli(),
111-
parser.parse("2018-01-01", () -> 0).toEpochMilli(), true, true);
114+
parser.parse(lowerBoundExact, () -> 0).toEpochMilli(),
115+
parser.parse(upperBoundExact, () -> 0).toEpochMilli(), true, true);
112116
assertEquals(new IndexOrDocValuesQuery(range, dv), query);
117+
118+
// also make sure the produced bounds are the same as on a regular `date` field
119+
DateFieldMapper.DateFieldType dateType = (DateFieldMapper.DateFieldType) context.fieldMapper(DATE_FIELD_NAME);
120+
parser = dateType.dateMathParser;
121+
Query queryOnDateField = new QueryStringQueryBuilder(DATE_FIELD_NAME + ":[2010-01-01 TO 2018-01-01]").toQuery(createShardContext());
122+
Query controlQuery = LongPoint.newRangeQuery(DATE_FIELD_NAME,
123+
new long[]{ parser.parse(lowerBoundExact, () -> 0).toEpochMilli()},
124+
new long[]{ parser.parse(upperBoundExact, () -> 0).toEpochMilli()});
125+
126+
Query controlDv = SortedNumericDocValuesField.newSlowRangeQuery(DATE_FIELD_NAME,
127+
parser.parse(lowerBoundExact, () -> 0).toEpochMilli(),
128+
parser.parse(upperBoundExact, () -> 0).toEpochMilli());
129+
assertEquals(new IndexOrDocValuesQuery(controlQuery, controlDv), queryOnDateField);
113130
}
114131

115132
public void testIPRangeQuery() throws Exception {

server/src/test/java/org/elasticsearch/index/mapper/RangeFieldTypeTests.java

Lines changed: 60 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
import org.elasticsearch.common.time.DateFormatter;
4141
import org.elasticsearch.common.util.BigArrays;
4242
import org.elasticsearch.index.IndexSettings;
43+
import org.elasticsearch.index.mapper.DateFieldMapper.DateFieldType;
4344
import org.elasticsearch.index.mapper.RangeFieldMapper.RangeFieldType;
4445
import org.elasticsearch.index.query.QueryShardContext;
4546
import org.elasticsearch.test.IndexSettingsModule;
@@ -162,7 +163,7 @@ public void testRangeQueryIntersectsAdjacentValues() throws Exception {
162163
assertThat(rangeQuery, instanceOf(IndexOrDocValuesQuery.class));
163164
assertThat(((IndexOrDocValuesQuery) rangeQuery).getIndexQuery(), instanceOf(MatchNoDocsQuery.class));
164165
}
165-
166+
166167
/**
167168
* check that we catch cases where the user specifies larger "from" than "to" value, not counting the include upper/lower settings
168169
*/
@@ -231,14 +232,15 @@ private QueryShardContext createContext() {
231232
return new QueryShardContext(0, idxSettings, BigArrays.NON_RECYCLING_INSTANCE, null, null, null, null, null,
232233
xContentRegistry(), writableRegistry(), null, null, () -> nowInMillis, null, null);
233234
}
234-
235+
235236
public void testDateRangeQueryUsingMappingFormat() {
236237
QueryShardContext context = createContext();
237238
RangeFieldType fieldType = new RangeFieldType(RangeType.DATE);
238239
fieldType.setName(FIELDNAME);
239240
fieldType.setIndexOptions(IndexOptions.DOCS);
240241
fieldType.setHasDocValues(false);
241-
ShapeRelation relation = randomFrom(ShapeRelation.values());
242+
// don't use DISJOINT here because it doesn't work on date fields which we want to compare bounds with
243+
ShapeRelation relation = randomValueOtherThan(ShapeRelation.DISJOINT,() -> randomFrom(ShapeRelation.values()));
242244

243245
// dates will break the default format, month/day of month is turned around in the format
244246
final String from = "2016-15-06T15:29:50+08:00";
@@ -257,7 +259,61 @@ public void testDateRangeQueryUsingMappingFormat() {
257259

258260
fieldType.setDateTimeFormatter(formatter);
259261
final Query query = fieldType.rangeQuery(from, to, true, true, relation, null, null, context);
260-
assertEquals("field:<ranges:[1465975790000 : 1466062190000]>", query.toString());
262+
assertEquals("field:<ranges:[1465975790000 : 1466062190999]>", query.toString());
263+
264+
// compare lower and upper bounds with what we would get on a `date` field
265+
DateFieldType dateFieldType = new DateFieldType();
266+
dateFieldType.setName(FIELDNAME);
267+
dateFieldType.setDateTimeFormatter(formatter);
268+
final Query queryOnDateField = dateFieldType.rangeQuery(from, to, true, true, relation, null, null, context);
269+
assertEquals("field:[1465975790000 TO 1466062190999]", queryOnDateField.toString());
270+
}
271+
272+
/**
273+
* We would like to ensure lower and upper bounds are consistent between queries on a `date` and a`date_range`
274+
* field, so we randomize a few cases and compare the generated queries here
275+
*/
276+
public void testDateVsDateRangeBounds() {
277+
QueryShardContext context = createContext();
278+
RangeFieldType fieldType = new RangeFieldType(RangeType.DATE);
279+
fieldType.setName(FIELDNAME);
280+
fieldType.setIndexOptions(IndexOptions.DOCS);
281+
fieldType.setHasDocValues(false);
282+
283+
// date formatter that truncates seconds, so we get some rounding behavior
284+
final DateFormatter formatter = DateFormatter.forPattern("yyyy-dd-MM'T'HH:mm");
285+
long lower = randomLongBetween(formatter.parseMillis("2000-01-01T00:00"), formatter.parseMillis("2010-01-01T00:00"));
286+
long upper = randomLongBetween(formatter.parseMillis("2011-01-01T00:00"), formatter.parseMillis("2020-01-01T00:00"));
287+
288+
fieldType.setDateTimeFormatter(formatter);
289+
String lowerAsString = formatter.formatMillis(lower);
290+
String upperAsString = formatter.formatMillis(upper);
291+
// also add date math rounding to days occasionally
292+
if (randomBoolean()) {
293+
lowerAsString = lowerAsString + "||/d";
294+
}
295+
if (randomBoolean()) {
296+
upperAsString = upperAsString + "||/d";
297+
}
298+
boolean includeLower = randomBoolean();
299+
boolean includeUpper = randomBoolean();
300+
final Query query = fieldType.rangeQuery(lowerAsString, upperAsString, includeLower, includeUpper, ShapeRelation.INTERSECTS, null,
301+
null, context);
302+
303+
// get exact lower and upper bounds similar to what we would parse for `date` fields for same input strings
304+
DateFieldType dateFieldType = new DateFieldType();
305+
long lowerBoundLong = dateFieldType.parseToLong(lowerAsString, !includeLower, null, formatter.toDateMathParser(), () -> 0);
306+
if (includeLower == false) {
307+
++lowerBoundLong;
308+
}
309+
long upperBoundLong = dateFieldType.parseToLong(upperAsString, includeUpper, null, formatter.toDateMathParser(), () -> 0);
310+
if (includeUpper == false) {
311+
--upperBoundLong;
312+
}
313+
314+
// check that using this bounds we get similar query when constructing equivalent query on date_range field
315+
Query range = LongRange.newIntersectsQuery(FIELDNAME, new long[] { lowerBoundLong }, new long[] { upperBoundLong });
316+
assertEquals(range, query);
261317
}
262318

263319
private Query getExpectedRangeQuery(ShapeRelation relation, Object from, Object to, boolean includeLower, boolean includeUpper) {

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

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,11 @@
2828
import org.elasticsearch.common.unit.DistanceUnit;
2929
import org.elasticsearch.common.unit.TimeValue;
3030
import org.elasticsearch.index.mapper.DateFieldMapper;
31+
import org.elasticsearch.index.mapper.DateFieldMapper.DateFieldType;
3132
import org.elasticsearch.index.mapper.MapperService;
33+
import org.elasticsearch.index.query.DistanceFeatureQueryBuilder.Origin;
3234
import org.elasticsearch.test.AbstractQueryTestCase;
3335
import org.joda.time.DateTime;
34-
import org.elasticsearch.index.query.DistanceFeatureQueryBuilder.Origin;
35-
import org.elasticsearch.index.mapper.DateFieldMapper.DateFieldType;
36-
3736

3837
import java.io.IOException;
3938
import java.time.Instant;
@@ -88,7 +87,7 @@ protected void doAssertLuceneQuery(DistanceFeatureQueryBuilder queryBuilder,
8887
} else { // if (fieldName.equals(DATE_FIELD_NAME))
8988
MapperService mapperService = context.getMapperService();
9089
DateFieldType fieldType = (DateFieldType) mapperService.fullName(fieldName);
91-
long originLong = fieldType.parseToLong(origin, true, null, null, context);
90+
long originLong = fieldType.parseToLong(origin, true, null, null, context::nowInMillis);
9291
TimeValue pivotVal = TimeValue.parseTimeValue(pivot, DistanceFeatureQueryBuilder.class.getSimpleName() + ".pivot");
9392
long pivotLong;
9493
if (fieldType.resolution() == DateFieldMapper.Resolution.MILLISECONDS) {

0 commit comments

Comments
 (0)