Repository: blevesearch/bleve
Pull request #1860 (2023-07-31 09:13:20)

MB-58034: Custom Date Time parsers not being applied on query object
## Jira

[MB-58034](https://issues.couchbase.com/browse/MB-58034)

## Description

- Add a new query DateRangeStringQuery, which takes in the start and end bounds as strings and then parses them using either the top level QueryDateTimeParser in the top-level bleve package or the DateTimeParser set in the DateRangeStringQuery struct, if present.
-  ParseQuery, which analyzes a marshalled json to identify the query to run, will now default to DateRangeStringQuery instead, which is better since it already has the dates in string format and unmarshalling to DateRangeQuery will always trigger parsing of the date string with Only QueryDateTimeParser, leaving no room for user config.


Patch:
diff --git a/mapping/index.go b/mapping/index.go
index e2ac99f3..99642bc4 100644
--- a/mapping/index.go
+++ b/mapping/index.go
@@ -417,23 +417,6 @@ func (im *IndexMappingImpl) DateTimeParserNamed(name string) analysis.DateTimePa
 	return dateTimeParser
 }
 
-func (im *IndexMappingImpl) datetimeParserNameForPath(path string) string {
-
-	// first we look for explicit mapping on the field
-	for _, docMapping := range im.TypeMapping {
-		pathMapping, _ := docMapping.documentMappingForPath(path)
-		if pathMapping != nil {
-			if len(pathMapping.Fields) > 0 {
-				if pathMapping.Fields[0].Analyzer != "" {
-					return pathMapping.Fields[0].Analyzer
-				}
-			}
-		}
-	}
-
-	return im.DefaultDateTimeParser
-}
-
 func (im *IndexMappingImpl) AnalyzeText(analyzerName string, text []byte) (analysis.TokenStream, error) {
 	analyzer, err := im.cache.AnalyzerNamed(analyzerName)
 	if err != nil {
diff --git a/search.go b/search.go
index db5ced3f..a0fb024f 100644
--- a/search.go
+++ b/search.go
@@ -31,24 +31,18 @@ import (
 	"github.com/blevesearch/bleve/v2/size"
 )
 
-var reflectStaticSizeSearchResult int
-var reflectStaticSizeSearchStatus int
-
-func init() {
-	var sr SearchResult
-	reflectStaticSizeSearchResult = int(reflect.TypeOf(sr).Size())
-	var ss SearchStatus
-	reflectStaticSizeSearchStatus = int(reflect.TypeOf(ss).Size())
-}
+const defaultDateTimeParser = optional.Name
 
 var cache = registry.NewCache()
 
-const defaultDateTimeParser = optional.Name
+var (
+	reflectStaticSizeSearchResult int
+	reflectStaticSizeSearchStatus int
+)
 
-type numericRange struct {
-	Name string   `json:"name,omitempty"`
-	Min  *float64 `json:"min,omitempty"`
-	Max  *float64 `json:"max,omitempty"`
+func init() {
+	reflectStaticSizeSearchResult = int(reflect.TypeOf(SearchResult{}).Size())
+	reflectStaticSizeSearchStatus = int(reflect.TypeOf(SearchStatus{}).Size())
 }
 
 type dateTimeRange struct {
@@ -84,8 +78,7 @@ func (dr *dateTimeRange) UnmarshalJSON(input []byte) error {
 		End   *string `json:"end,omitempty"`
 	}
 
-	err := json.Unmarshal(input, &temp)
-	if err != nil {
+	if err := json.Unmarshal(input, &temp); err != nil {
 		return err
 	}
 
@@ -115,6 +108,12 @@ func (dr *dateTimeRange) MarshalJSON() ([]byte, error) {
 	return json.Marshal(rv)
 }
 
+type numericRange struct {
+	Name string   `json:"name,omitempty"`
+	Min  *float64 `json:"min,omitempty"`
+	Max  *float64 `json:"max,omitempty"`
+}
+
 // A FacetRequest describes a facet or aggregation
 // of the result document set you would like to be
 // built.
@@ -125,6 +124,16 @@ type FacetRequest struct {
 	DateTimeRanges []*dateTimeRange `json:"date_ranges,omitempty"`
 }
 
+// NewFacetRequest creates a facet on the specified
+// field that limits the number of entries to the
+// specified size.
+func NewFacetRequest(field string, size int) *FacetRequest {
+	return &FacetRequest{
+		Field: field,
+		Size:  size,
+	}
+}
+
 func (fr *FacetRequest) Validate() error {
 	nrCount := len(fr.NumericRanges)
 	drCount := len(fr.DateTimeRanges)
@@ -161,17 +170,8 @@ func (fr *FacetRequest) Validate() error {
 			}
 		}
 	}
-	return nil
-}
 
-// NewFacetRequest creates a facet on the specified
-// field that limits the number of entries to the
-// specified size.
-func NewFacetRequest(field string, size int) *FacetRequest {
-	return &FacetRequest{
-		Field: field,
-		Size:  size,
-	}
+	return nil
 }
 
 // AddDateTimeRange adds a bucket to a field
@@ -212,8 +212,7 @@ type FacetsRequest map[string]*FacetRequest
 
 func (fr FacetsRequest) Validate() error {
 	for _, v := range fr {
-		err := v.Validate()
-		if err != nil {
+		if err := v.Validate(); err != nil {
 			return err
 		}
 	}
@@ -287,8 +286,7 @@ type SearchRequest struct {
 
 func (r *SearchRequest) Validate() error {
 	if srq, ok := r.Query.(query.ValidatableQuery); ok {
-		err := srq.Validate()
-		if err != nil {
+		if err := srq.Validate(); err != nil {
 			return err
 		}
 	}
@@ -355,23 +353,25 @@ func (r *SearchRequest) SetSearchBefore(before []string) {
 // UnmarshalJSON deserializes a JSON representation of
 // a SearchRequest
 func (r *SearchRequest) UnmarshalJSON(input []byte) error {
-	var temp struct {
-		Q                json.RawMessage   `json:"query"`
-		Size             *int              `json:"size"`
-		From             int               `json:"from"`
-		Highlight        *HighlightRequest `json:"highlight"`
-		Fields           []string          `json:"fields"`
-		Facets           FacetsRequest     `json:"facets"`
-		Explain          bool              `json:"explain"`
-		Sort             []json.RawMessage `json:"sort"`
-		IncludeLocations bool              `json:"includeLocations"`
-		Score            string            `json:"score"`
-		SearchAfter      []string          `json:"search_after"`
-		SearchBefore     []string          `json:"search_before"`
-	}
-
-	err := json.Unmarshal(input, &temp)
-	if err != nil {
+	var (
+		temp struct {
+			Q                json.RawMessage   `json:"query"`
+			Size             *int              `json:"size"`
+			From             int               `json:"from"`
+			Highlight        *HighlightRequest `json:"highlight"`
+			Fields           []string          `json:"fields"`
+			Facets           FacetsRequest     `json:"facets"`
+			Explain          bool              `json:"explain"`
+			Sort             []json.RawMessage `json:"sort"`
+			IncludeLocations bool              `json:"includeLocations"`
+			Score            string            `json:"score"`
+			SearchAfter      []string          `json:"search_after"`
+			SearchBefore     []string          `json:"search_before"`
+		}
+		err error
+	)
+
+	if err = json.Unmarshal(input, &temp); err != nil {
 		return err
 	}
 
@@ -383,8 +383,7 @@ func (r *SearchRequest) UnmarshalJSON(input []byte) error {
 	if temp.Sort == nil {
 		r.Sort = search.SortOrder{&search.SortScore{Desc: true}}
 	} else {
-		r.Sort, err = search.ParseSortOrderJSON(temp.Sort)
-		if err != nil {
+		if r.Sort, err = search.ParseSortOrderJSON(temp.Sort); err != nil {
 			return err
 		}
 	}
@@ -397,8 +396,7 @@ func (r *SearchRequest) UnmarshalJSON(input []byte) error {
 	r.Score = temp.Score
 	r.SearchAfter = temp.SearchAfter
 	r.SearchBefore = temp.SearchBefore
-	r.Query, err = query.ParseQuery(temp.Q)
-	if err != nil {
+	if r.Query, err = query.ParseQuery(temp.Q); err != nil {
 		return err
 	}
 
@@ -448,8 +446,7 @@ func (iem IndexErrMap) MarshalJSON() ([]byte, error) {
 
 func (iem IndexErrMap) UnmarshalJSON(data []byte) error {
 	var tmp map[string]string
-	err := json.Unmarshal(data, &tmp)
-	if err != nil {
+	if err := json.Unmarshal(data, &tmp); err != nil {
 		return err
 	}
 	for k, v := range tmp {
diff --git a/search/query/date_range.go b/search/query/date_range.go
index 34844c97..bbb2a54e 100644
--- a/search/query/date_range.go
+++ b/search/query/date_range.go
@@ -30,10 +30,10 @@ import (
 	index "github.com/blevesearch/bleve_index_api"
 )
 
-// QueryDateTimeParser controls the default query date time parser
+// QueryDateTimeParser controls the default query date time parser.
 var QueryDateTimeParser = optional.Name
 
-// QueryDateTimeFormat controls the format when Marshaling to JSON
+// QueryDateTimeFormat controls the format when Marshaling to JSON.
 var QueryDateTimeFormat = time.RFC3339
 
 var cache = registry.NewCache()
diff --git a/search/query/date_range_string.go b/search/query/date_range_string.go
new file mode 100644
index 00000000..b5e5c170
--- /dev/null
+++ b/search/query/date_range_string.go
@@ -0,0 +1,176 @@
+//  Copyright (c) 2023 Couchbase, Inc.
+//
+// Licensed 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 query
+
+import (
+	"context"
+	"fmt"
+	"math"
+	"time"
+
+	"github.com/blevesearch/bleve/v2/mapping"
+	"github.com/blevesearch/bleve/v2/numeric"
+	"github.com/blevesearch/bleve/v2/search"
+	"github.com/blevesearch/bleve/v2/search/searcher"
+	index "github.com/blevesearch/bleve_index_api"
+)
+
+// DateRangeStringQuery represents a query for a range of date values.
+// Start and End are the range endpoints, as strings.
+// Start and End are parsed using DateTimeParser, which is a custom date time parser
+// defined in the index mapping. If DateTimeParser is not specified, then the
+// top-level config.QueryDateTimeParser is used.
+type DateRangeStringQuery struct {
+	Start          string `json:"start,omitempty"`
+	End            string `json:"end,omitempty"`
+	InclusiveStart *bool  `json:"inclusive_start,omitempty"`
+	InclusiveEnd   *bool  `json:"inclusive_end,omitempty"`
+	FieldVal       string `json:"field,omitempty"`
+	BoostVal       *Boost `json:"boost,omitempty"`
+	DateTimeParser string `json:"datetime_parser,omitempty"`
+}
+
+// NewDateRangeStringQuery creates a new Query for ranges
+// of date values.
+// Date strings are parsed using the DateTimeParser field of the query struct,
+// which is a custom date time parser defined in the index mapping.
+// if DateTimeParser is not specified, then the
+// top-level config.QueryDateTimeParser is used.
+// Either, but not both endpoints can be nil.
+func NewDateRangeStringQuery(start, end string) *DateRangeStringQuery {
+	return NewDateRangeStringInclusiveQuery(start, end, nil, nil)
+}
+
+// NewDateRangeStringQuery creates a new Query for ranges
+// of date values.
+// Date strings are parsed using the DateTimeParser field of the query struct,
+// which is a custom date time parser defined in the index mapping.
+// if DateTimeParser is not specified, then the
+// top-level config.QueryDateTimeParser is used.
+// Either, but not both endpoints can be nil.
+// startInclusive and endInclusive control inclusion of the endpoints.
+func NewDateRangeStringInclusiveQuery(start, end string, startInclusive, endInclusive *bool) *DateRangeStringQuery {
+	return &DateRangeStringQuery{
+		Start:          start,
+		End:            end,
+		InclusiveStart: startInclusive,
+		InclusiveEnd:   endInclusive,
+	}
+}
+
+func (q *DateRangeStringQuery) SetBoost(b float64) {
+	boost := Boost(b)
+	q.BoostVal = &boost
+}
+
+func (q *DateRangeStringQuery) Boost() float64 {
+	return q.BoostVal.Value()
+}
+
+func (q *DateRangeStringQuery) SetField(f string) {
+	q.FieldVal = f
+}
+
+func (q *DateRangeStringQuery) Field() string {
+	return q.FieldVal
+}
+
+func (q *DateRangeStringQuery) SetDateTimeParser(d string) {
+	q.DateTimeParser = d
+}
+
+func (q *DateRangeStringQuery) DateTimeParserName() string {
+	return q.DateTimeParser
+}
+
+func (q *DateRangeStringQuery) Searcher(ctx context.Context, i index.IndexReader, m mapping.IndexMapping, options search.SearcherOptions) (search.Searcher, error) {
+	field := q.FieldVal
+	if q.FieldVal == "" {
+		field = m.DefaultSearchField()
+	}
+
+	dateTimeParserName := QueryDateTimeParser
+	if q.DateTimeParser != "" {
+		dateTimeParserName = q.DateTimeParser
+	}
+	dateTimeParser := m.DateTimeParserNamed(dateTimeParserName)
+	if dateTimeParser == nil {
+		return nil, fmt.Errorf("no dateTimeParser named '%s' registered", dateTimeParserName)
+	}
+
+	var startTime, endTime time.Time
+	var err error
+	if q.Start != "" {
+		startTime, _, err = dateTimeParser.ParseDateTime(q.Start)
+		if err != nil {
+			return nil, fmt.Errorf("%v, date time parser name: %s", err, dateTimeParserName)
+		}
+	}
+	if q.End != "" {
+		endTime, _, err = dateTimeParser.ParseDateTime(q.End)
+		if err != nil {
+			return nil, fmt.Errorf("%v, date time parser name: %s", err, dateTimeParserName)
+		}
+	}
+
+	min, max, err := q.parseEndpoints(startTime, endTime)
+	if err != nil {
+		return nil, err
+	}
+	return searcher.NewNumericRangeSearcher(ctx, i, min, max, q.InclusiveStart, q.InclusiveEnd, field, q.BoostVal.Value(), options)
+}
+
+func (q *DateRangeStringQuery) parseEndpoints(startTime, endTime time.Time) (*float64, *float64, error) {
+	min := math.Inf(-1)
+	max := math.Inf(1)
+
+	if startTime.IsZero() && endTime.IsZero() {
+		return nil, nil, fmt.Errorf("date range query must specify at least one of start/end")
+	}
+
+	if !startTime.IsZero() {
+		if !isDateTimeWithinRange(startTime) {
+			// overflow
+			return nil, nil, fmt.Errorf("invalid/unsupported date range, start: %v", q.Start)
+		}
+		startInt64 := startTime.UnixNano()
+		min = numeric.Int64ToFloat64(startInt64)
+	}
+	if !endTime.IsZero() {
+		if !isDateTimeWithinRange(endTime) {
+			// overflow
+			return nil, nil, fmt.Errorf("invalid/unsupported date range, end: %v", q.End)
+		}
+		endInt64 := endTime.UnixNano()
+		max = numeric.Int64ToFloat64(endInt64)
+	}
+
+	return &min, &max, nil
+}
+
+func (q *DateRangeStringQuery) Validate() error {
+	// either start or end must be specified
+	if q.Start == "" && q.End == "" {
+		return fmt.Errorf("date range query must specify at least one of start/end")
+	}
+	return nil
+}
+
+func isDateTimeWithinRange(t time.Time) bool {
+	if t.Before(MinRFC3339CompatibleTime) || t.After(MaxRFC3339CompatibleTime) {
+		return false
+	}
+	return true
+}
diff --git a/search/query/query.go b/search/query/query.go
index a4e0f015..1ef97ff8 100644
--- a/search/query/query.go
+++ b/search/query/query.go
@@ -185,7 +185,7 @@ func ParseQuery(input []byte) (Query, error) {
 	_, hasStart := tmp["start"]
 	_, hasEnd := tmp["end"]
 	if hasStart || hasEnd {
-		var rv DateRangeQuery
+		var rv DateRangeStringQuery
 		err := json.Unmarshal(input, &rv)
 		if err != nil {
 			return nil, err


Test patch:
diff --git a/search/query/query_test.go b/search/query/query_test.go
index 0082e8ac..228fb65c 100644
--- a/search/query/query_test.go
+++ b/search/query/query_test.go
@@ -176,7 +176,7 @@ func TestParseQuery(t *testing.T) {
 		{
 			input: []byte(`{"start":"` + startDateStr + `","end":"` + endDateStr + `","field":"desc"}`),
 			output: func() Query {
-				q := NewDateRangeQuery(startDate, endDate)
+				q := NewDateRangeStringQuery(startDateStr, endDateStr)
 				q.SetField("desc")
 				return q
 			}(),
diff --git a/search_test.go b/search_test.go
index 414c907c..6ddb861a 100644
--- a/search_test.go
+++ b/search_test.go
@@ -2473,3 +2473,297 @@ func TestCustomDateTimeParserLayoutValidation(t *testing.T) {
 		}
 	}
 }
+
+func TestDateRangeStringQuery(t *testing.T) {
+	idxMapping := NewIndexMapping()
+
+	err := idxMapping.AddCustomDateTimeParser("customDT", map[string]interface{}{
+		"type": sanitized.Name,
+		"layouts": []interface{}{
+			"02/01/2006 15:04:05",
+			"2006/01/02 3:04PM",
+		},
+	})
+
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	err = idxMapping.AddCustomDateTimeParser("queryDT", map[string]interface{}{
+		"type": sanitized.Name,
+		"layouts": []interface{}{
+			"02/01/2006 3:04PM",
+		},
+	})
+
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	dtmap := NewDateTimeFieldMapping()
+	dtmap.DateFormat = "customDT"
+	idxMapping.DefaultMapping.AddFieldMappingsAt("date", dtmap)
+
+	tmpIndexPath := createTmpIndexPath(t)
+	defer cleanupTmpIndexPath(t, tmpIndexPath)
+
+	idx, err := New(tmpIndexPath, idxMapping)
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer func() {
+		err = idx.Close()
+		if err != nil {
+			t.Fatal(err)
+		}
+	}()
+	documents := map[string]map[string]interface{}{
+		"doc1": {
+			"date": "2001/08/20 6:00PM",
+		},
+		"doc2": {
+			"date": "20/08/2001 18:00:20",
+		},
+		"doc3": {
+			"date": "20/08/2001 18:10:00",
+		},
+		"doc4": {
+			"date": "2001/08/20 6:15PM",
+		},
+		"doc5": {
+			"date": "20/08/2001 18:20:00",
+		},
+	}
+
+	batch := idx.NewBatch()
+	for docID, doc := range documents {
+		err := batch.Index(docID, doc)
+		if err != nil {
+			t.Fatal(err)
+		}
+	}
+	err = idx.Batch(batch)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	type testResult struct {
+		docID    string // doc ID of the hit
+		hitField string // fields returned as part of the hit
+	}
+
+	type testStruct struct {
+		start          string
+		end            string
+		field          string
+		dateTimeParser string // name of the custom date time parser to use if nil, use QueryDateTimeParser
+		includeStart   bool
+		includeEnd     bool
+		expectedHits   []testResult
+		err            error
+	}
+
+	testQueries := []testStruct{
+		// test cases with RFC3339 parser and toggling includeStart and includeEnd
+		{
+			start:        "2001-08-20T18:00:00",
+			end:          "2001-08-20T18:10:00",
+			field:        "date",
+			includeStart: true,
+			includeEnd:   true,
+			expectedHits: []testResult{
+				{
+					docID:    "doc1",
+					hitField: "2001/08/20 6:00PM",
+				},
+				{
+					docID:    "doc2",
+					hitField: "20/08/2001 18:00:20",
+				},
+				{
+					docID:    "doc3",
+					hitField: "20/08/2001 18:10:00",
+				},
+			},
+		},
+		{
+			start:        "2001-08-20T18:00:00",
+			end:          "2001-08-20T18:10:00",
+			field:        "date",
+			includeStart: false,
+			includeEnd:   true,
+			expectedHits: []testResult{
+				{
+					docID:    "doc2",
+					hitField: "20/08/2001 18:00:20",
+				},
+				{
+					docID:    "doc3",
+					hitField: "20/08/2001 18:10:00",
+				},
+			},
+		},
+		{
+			start:        "2001-08-20T18:00:00",
+			end:          "2001-08-20T18:10:00",
+			field:        "date",
+			includeStart: false,
+			includeEnd:   false,
+			expectedHits: []testResult{
+				{
+					docID:    "doc2",
+					hitField: "20/08/2001 18:00:20",
+				},
+			},
+		},
+		// test cases with custom parser and omitting start and end
+		{
+			start:          "20/08/2001 18:00:00",
+			end:            "2001/08/20 6:10PM",
+			field:          "date",
+			dateTimeParser: "customDT",
+			includeStart:   true,
+			includeEnd:     true,
+			expectedHits: []testResult{
+				{
+					docID:    "doc1",
+					hitField: "2001/08/20 6:00PM",
+				},
+				{
+					docID:    "doc2",
+					hitField: "20/08/2001 18:00:20",
+				},
+				{
+					docID:    "doc3",
+					hitField: "20/08/2001 18:10:00",
+				},
+			},
+		},
+		{
+			end:            "20/08/2001 18:15:00",
+			field:          "date",
+			dateTimeParser: "customDT",
+			includeStart:   true,
+			includeEnd:     true,
+			expectedHits: []testResult{
+				{
+					docID:    "doc1",
+					hitField: "2001/08/20 6:00PM",
+				},
+				{
+					docID:    "doc2",
+					hitField: "20/08/2001 18:00:20",
+				},
+				{
+					docID:    "doc3",
+					hitField: "20/08/2001 18:10:00",
+				},
+				{
+					docID:    "doc4",
+					hitField: "2001/08/20 6:15PM",
+				},
+			},
+		},
+		{
+			start:          "2001/08/20 6:15PM",
+			field:          "date",
+			dateTimeParser: "customDT",
+			includeStart:   true,
+			includeEnd:     true,
+			expectedHits: []testResult{
+				{
+					docID:    "doc4",
+					hitField: "2001/08/20 6:15PM",
+				},
+				{
+					docID:    "doc5",
+					hitField: "20/08/2001 18:20:00",
+				},
+			},
+		},
+		{
+			start:          "20/08/2001 6:15PM",
+			field:          "date",
+			dateTimeParser: "queryDT",
+			includeStart:   true,
+			includeEnd:     true,
+			expectedHits: []testResult{
+				{
+					docID:    "doc4",
+					hitField: "2001/08/20 6:15PM",
+				},
+				{
+					docID:    "doc5",
+					hitField: "20/08/2001 18:20:00",
+				},
+			},
+		},
+		// error path test cases
+		{
+			field:          "date",
+			dateTimeParser: "customDT",
+			includeStart:   true,
+			includeEnd:     true,
+			err:            fmt.Errorf("date range query must specify at least one of start/end"),
+		},
+		{
+			field:        "date",
+			includeStart: true,
+			includeEnd:   true,
+			err:          fmt.Errorf("date range query must specify at least one of start/end"),
+		},
+		{
+			start:          "2001-08-20T18:00:00",
+			end:            "2001-08-20T18:10:00",
+			field:          "date",
+			dateTimeParser: "customDT",
+			err:            fmt.Errorf("unable to parse datetime with any of the layouts, date time parser name: customDT"),
+		},
+		{
+			start: "3001-08-20T18:00:00",
+			end:   "2001-08-20T18:10:00",
+			field: "date",
+			err:   fmt.Errorf("invalid/unsupported date range, start: 3001-08-20T18:00:00"),
+		},
+		{
+			start:          "2001/08/20 6:00PM",
+			end:            "3001/08/20 6:30PM",
+			field:          "date",
+			dateTimeParser: "customDT",
+			err:            fmt.Errorf("invalid/unsupported date range, end: 3001/08/20 6:30PM"),
+		},
+	}
+
+	for _, dtq := range testQueries {
+		var err error
+		dateQuery := query.NewDateRangeStringInclusiveQuery(dtq.start, dtq.end, &dtq.includeStart, &dtq.includeEnd)
+		dateQuery.SetDateTimeParser(dtq.dateTimeParser)
+		dateQuery.SetField(dtq.field)
+
+		sr := NewSearchRequest(dateQuery)
+		sr.SortBy([]string{dtq.field})
+		sr.Fields = []string{dtq.field}
+
+		res, err := idx.Search(sr)
+		if err != nil {
+			if dtq.err == nil {
+				t.Fatalf("expected no error, got: %v", err)
+			}
+			if dtq.err.Error() != err.Error() {
+				t.Fatalf("expected error: %v, got: %v", dtq.err, err)
+			}
+			continue
+		}
+		if len(res.Hits) != len(dtq.expectedHits) {
+			t.Fatalf("expected %d hits, got %d", len(dtq.expectedHits), len(res.Hits))
+		}
+		for i, hit := range res.Hits {
+			if hit.ID != dtq.expectedHits[i].docID {
+				t.Fatalf("expected docID %s, got %s", dtq.expectedHits[i].docID, hit.ID)
+			}
+			if hit.Fields[dtq.field].(string) != dtq.expectedHits[i].hitField {
+				t.Fatalf("expected hit field %s, got %s", dtq.expectedHits[i].hitField, hit.Fields[dtq.field])
+			}
+		}
+	}
+}