Skip to content

Commit c327794

Browse files
author
Christoph Büscher
authored
Fix range query on date fields for number inputs (#63692)
Currently, if you write a date range query with numeric 'to' or 'from' bounds, they can be interpreted as years if no format is provided. We use "strict_date_optional_time||epoch_millis" in this case that can interpret inputs like 1000 as the year 1000 for example. This PR change this to always interpret and parse numbers with the "epoch_millis" parser if no other formatter was provided. Closes #63680
1 parent dc64498 commit c327794

File tree

8 files changed

+79
-30
lines changed

8 files changed

+79
-30
lines changed

docs/reference/migration/migrate_8_0/search.asciidoc

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,3 +157,21 @@ Change any use of `-1` as `from` parameter in request body or url parameters by
157157
setting it to `0` or omitting it entirely. Requests containing negative values will
158158
return an error.
159159
====
160+
161+
.Range queries on date fields treat numeric values alwas as milliseconds-since-epoch.
162+
[%collapsible]
163+
====
164+
*Details* +
165+
Range queries on date fields used to misinterpret small numbers (e.g. four digits like 1000)
166+
as a year when no additional format was set, but would interpret other numeric values as
167+
milliseconds since epoch. We now treat all numeric values in absence of a specific `format`
168+
parameter as milliseconds since epoch. If you want to query for years instead, with a missing
169+
`format` you now need to quote the input value (e.g. "1984").
170+
171+
*Impact* +
172+
If you query date fields without a specified `format`, check if the values in your queries are
173+
actually meant to be milliseconds-since-epoch and use a numeric value in this case. If not, use
174+
a string value which gets parsed by either the date format set on the field in the mappings or
175+
by `strict_date_optional_time` by default.
176+
177+
====

docs/reference/query-dsl/range-query.asciidoc

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,14 @@ to `2099-12-01T23:59:59.999_999_999Z`. This date uses the provided year (`2099`)
183183
and month (`12`) but uses the default day (`01`), hour (`23`), minute (`59`),
184184
second (`59`), and nanosecond (`999_999_999`).
185185

186+
[[numeric-date]]
187+
====== Numeric date range value
188+
189+
When no date format is specified and the range query is targeting a date field, numeric
190+
values are interpreted representing milliseconds-since-the-epoch. If you want the value
191+
to represent a year, e.g. 2020, you need to pass it as a String value (e.g. "2020") that
192+
will be parsed according to the default format or the set format.
193+
186194
[[range-query-date-math-rounding]]
187195
====== Date math and rounding
188196
{es} rounds <<date-math,date math>> values in parameters as follows:

docs/reference/release-notes/8.0.0-alpha1.asciidoc

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,5 +29,6 @@ by using appropriate thresholds. If for instance we want to simulate `index.inde
2929
all we need to do is to set `index.indexing.slowlog.threshold.index.debug` and
3030
`index.indexing.slowlog.threshold.index.trace` to `-1` {es-pull}57591[#57591]
3131

32-
32+
Search::
33+
* Consistent treatment of numeric values for range query on date fields without `format` {es-pull}[#63692]
3334

qa/multi-cluster-search/src/test/resources/rest-api-spec/test/multi_cluster/30_field_caps.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@
106106
field_caps:
107107
index: 'field_caps_index_4,my_remote_cluster:field_*'
108108
fields: [number]
109-
body: { index_filter: { range: { created_at: { lt: 2018 } } } }
109+
body: { index_filter: { range: { created_at: { lt: "2018" } } } }
110110

111111
- match: {indices: ["field_caps_index_4","my_remote_cluster:field_caps_index_1"]}
112112
- length: {fields.number: 1}
@@ -117,7 +117,7 @@
117117
field_caps:
118118
index: 'field_caps_index_4,my_remote_cluster:field_*'
119119
fields: [number]
120-
body: { index_filter: { range: { created_at: { gt: 2019 } } } }
120+
body: { index_filter: { range: { created_at: { gt: "2019" } } } }
121121

122122
- match: {indices: ["my_remote_cluster:field_caps_index_3"]}
123123
- length: {fields.number: 1}

rest-api-spec/src/main/resources/rest-api-spec/test/field_caps/30_filter.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ setup:
8181
field_caps:
8282
index: test-*
8383
fields: "*"
84-
body: { index_filter: { range: { timestamp: { gte: 2010 }}}}
84+
body: { index_filter: { range: { timestamp: { gte: "2010" }}}}
8585

8686
- match: {indices: ["test-1", "test-2", "test-3"]}
8787
- length: {fields.field1: 3}
@@ -90,7 +90,7 @@ setup:
9090
field_caps:
9191
index: test-*
9292
fields: "*"
93-
body: { index_filter: { range: { timestamp: { gte: 2019 } } } }
93+
body: { index_filter: { range: { timestamp: { gte: "2019" } } } }
9494

9595
- match: {indices: ["test-2", "test-3"]}
9696
- length: {fields.field1: 2}
@@ -106,7 +106,7 @@ setup:
106106
field_caps:
107107
index: test-*
108108
fields: "*"
109-
body: { index_filter: { range: { timestamp: { lt: 2019 } } } }
109+
body: { index_filter: { range: { timestamp: { lt: "2019" } } } }
110110

111111
- match: {indices: ["test-1"]}
112112
- length: {fields.field1: 1}

server/src/internalClusterTest/java/org/elasticsearch/search/simple/SimpleSearchIT.java

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@
5454
import static org.elasticsearch.test.hamcrest.ElasticsearchAssertions.assertNoFailures;
5555
import static org.hamcrest.Matchers.containsString;
5656
import static org.hamcrest.Matchers.equalTo;
57+
import static org.hamcrest.Matchers.is;
58+
import static org.hamcrest.Matchers.oneOf;
5759

5860
public class SimpleSearchIT extends ESIntegTestCase {
5961

@@ -198,6 +200,7 @@ public void testSimpleDateRange() throws Exception {
198200
createIndex("test");
199201
client().prepareIndex("test").setId("1").setSource("field", "2010-01-05T02:00").get();
200202
client().prepareIndex("test").setId("2").setSource("field", "2010-01-06T02:00").get();
203+
client().prepareIndex("test").setId("3").setSource("field", "1967-01-01T00:00").get();
201204
ensureGreen();
202205
refresh();
203206
SearchResponse searchResponse = client().prepareSearch("test").setQuery(QueryBuilders.rangeQuery("field").gte("2010-01-03||+2d")
@@ -223,6 +226,23 @@ public void testSimpleDateRange() throws Exception {
223226
searchResponse = client().prepareSearch("test").setQuery(
224227
QueryBuilders.queryStringQuery("field:[2010-01-03||+2d TO 2010-01-04||+2d/d]")).get();
225228
assertHitCount(searchResponse, 2L);
229+
230+
// a string value of "1000" should be parsed as the year 1000 and return all three docs
231+
searchResponse = client().prepareSearch("test")
232+
.setQuery(QueryBuilders.rangeQuery("field").gt("1000"))
233+
.get();
234+
assertNoFailures(searchResponse);
235+
assertHitCount(searchResponse, 3L);
236+
237+
// a numeric value of 1000 should be parsed as 1000 millis since epoch and return only docs after 1970
238+
searchResponse = client().prepareSearch("test")
239+
.setQuery(QueryBuilders.rangeQuery("field").gt(1000))
240+
.get();
241+
assertNoFailures(searchResponse);
242+
assertHitCount(searchResponse, 2L);
243+
String[] expectedIds = new String[] {"1", "2"};
244+
assertThat(searchResponse.getHits().getHits()[0].getId(), is(oneOf(expectedIds)));
245+
assertThat(searchResponse.getHits().getHits()[1].getId(), is(oneOf(expectedIds)));
226246
}
227247

228248
public void testRangeQueryKeyword() throws Exception {

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

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ public final class DateFieldMapper extends FieldMapper {
7373
public static final String CONTENT_TYPE = "date";
7474
public static final String DATE_NANOS_CONTENT_TYPE = "date_nanos";
7575
public static final DateFormatter DEFAULT_DATE_TIME_FORMATTER = DateFormatter.forPattern("strict_date_optional_time||epoch_millis");
76+
private static final DateMathParser EPOCH_MILLIS_PARSER = DateFormatter.forPattern("epoch_millis").toDateMathParser();
7677

7778
public enum Resolution {
7879
MILLISECONDS(CONTENT_TYPE, NumericType.DATE) {
@@ -384,9 +385,17 @@ public Query rangeQuery(Object lowerTerm, Object upperTerm, boolean includeLower
384385
throw new IllegalArgumentException("Field [" + name() + "] of type [" + typeName() +
385386
"] does not support DISJOINT ranges");
386387
}
387-
DateMathParser parser = forcedDateParser == null
388-
? dateMathParser
389-
: forcedDateParser;
388+
DateMathParser parser;
389+
if (forcedDateParser == null) {
390+
if (lowerTerm instanceof Number || upperTerm instanceof Number) {
391+
// force epoch_millis
392+
parser = EPOCH_MILLIS_PARSER;
393+
} else {
394+
parser = dateMathParser;
395+
}
396+
} else {
397+
parser = forcedDateParser;
398+
}
390399
return dateRangeQuery(lowerTerm, upperTerm, includeLower, includeUpper, timeZone, parser, context, resolution, (l, u) -> {
391400
Query query = LongPoint.newRangeQuery(name(), l, u);
392401
if (hasDocValues()) {
@@ -479,7 +488,12 @@ public Relation isFieldWithinQuery(IndexReader reader,
479488
Object from, Object to, boolean includeLower, boolean includeUpper,
480489
ZoneId timeZone, DateMathParser dateParser, QueryRewriteContext context) throws IOException {
481490
if (dateParser == null) {
482-
dateParser = this.dateMathParser;
491+
if (from instanceof Number || to instanceof Number) {
492+
// force epoch_millis
493+
dateParser = EPOCH_MILLIS_PARSER;
494+
} else {
495+
dateParser = this.dateMathParser;
496+
}
483497
}
484498

485499
long fromInclusive = Long.MIN_VALUE;

x-pack/plugin/runtime-fields/src/main/java/org/elasticsearch/xpack/runtimefields/mapper/DateScriptFieldType.java

Lines changed: 8 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import com.carrotsearch.hppc.LongHashSet;
1010
import com.carrotsearch.hppc.LongSet;
11+
1112
import org.apache.lucene.search.Query;
1213
import org.elasticsearch.common.CheckedBiConsumer;
1314
import org.elasticsearch.common.Nullable;
@@ -91,10 +92,12 @@ protected AbstractScriptFieldType<?> buildFieldType() {
9192
});
9293

9394
private final DateFormatter dateTimeFormatter;
95+
private final DateMathParser dateMathParser;
9496

9597
private DateScriptFieldType(String name, DateFieldScript.Factory scriptFactory, DateFormatter dateTimeFormatter, Builder builder) {
9698
super(name, (n, params, ctx) -> scriptFactory.newFactory(n, params, ctx, dateTimeFormatter), builder);
9799
this.dateTimeFormatter = dateTimeFormatter;
100+
this.dateMathParser = dateTimeFormatter.toDateMathParser();
98101
}
99102

100103
DateScriptFieldType(
@@ -107,6 +110,7 @@ private DateScriptFieldType(String name, DateFieldScript.Factory scriptFactory,
107110
) {
108111
super(name, (n, params, ctx) -> scriptFactory.newFactory(n, params, ctx, dateTimeFormatter), script, meta, toXContent);
109112
this.dateTimeFormatter = dateTimeFormatter;
113+
this.dateMathParser = dateTimeFormatter.toDateMathParser();
110114
}
111115

112116
@Override
@@ -148,7 +152,7 @@ public Query distanceFeatureQuery(Object origin, String pivot, QueryShardContext
148152
origin,
149153
true,
150154
null,
151-
dateTimeFormatter.toDateMathParser(),
155+
this.dateMathParser,
152156
now,
153157
DateFieldMapper.Resolution.MILLISECONDS
154158
);
@@ -179,7 +183,7 @@ public Query rangeQuery(
179183
@Nullable DateMathParser parser,
180184
QueryShardContext context
181185
) {
182-
parser = parser == null ? dateTimeFormatter.toDateMathParser() : parser;
186+
parser = parser == null ? this.dateMathParser : parser;
183187
checkAllowExpensiveQueries(context);
184188
return DateFieldType.dateRangeQuery(
185189
lowerTerm,
@@ -197,14 +201,7 @@ public Query rangeQuery(
197201
@Override
198202
public Query termQuery(Object value, QueryShardContext context) {
199203
return DateFieldType.handleNow(context, now -> {
200-
long l = DateFieldType.parseToLong(
201-
value,
202-
false,
203-
null,
204-
dateTimeFormatter.toDateMathParser(),
205-
now,
206-
DateFieldMapper.Resolution.MILLISECONDS
207-
);
204+
long l = DateFieldType.parseToLong(value, false, null, this.dateMathParser, now, DateFieldMapper.Resolution.MILLISECONDS);
208205
checkAllowExpensiveQueries(context);
209206
return new LongScriptFieldTermQuery(script, leafFactory(context)::newInstance, name(), l);
210207
});
@@ -218,16 +215,7 @@ public Query termsQuery(List<?> values, QueryShardContext context) {
218215
return DateFieldType.handleNow(context, now -> {
219216
LongSet terms = new LongHashSet(values.size());
220217
for (Object value : values) {
221-
terms.add(
222-
DateFieldType.parseToLong(
223-
value,
224-
false,
225-
null,
226-
dateTimeFormatter.toDateMathParser(),
227-
now,
228-
DateFieldMapper.Resolution.MILLISECONDS
229-
)
230-
);
218+
terms.add(DateFieldType.parseToLong(value, false, null, this.dateMathParser, now, DateFieldMapper.Resolution.MILLISECONDS));
231219
}
232220
checkAllowExpensiveQueries(context);
233221
return new LongScriptFieldTermsQuery(script, leafFactory(context)::newInstance, name(), terms);

0 commit comments

Comments
 (0)