Repository: blevesearch/bleve
Pull request #1858 (2023-07-26 11:11:25)

MB-27696: Allow a unique ID to be set for a Search Request
## Jira

[MB-27696](https://issues.couchbase.com/browse/MB-27696)

## Description
Add a unique ID for a bleve Search Request which will be echo'd back in the Search Result. Would be useful in a parallel search query scenario to differentiate between the queries.


Patch:
diff --git a/index_impl.go b/index_impl.go
index 592b3c69..6c47872d 100644
--- a/index_impl.go
+++ b/index_impl.go
@@ -530,10 +530,23 @@ func (i *indexImpl) SearchInContext(ctx context.Context, req *SearchRequest) (sr
 			} else if facetRequest.DateTimeRanges != nil {
 				// build date range facet
 				facetBuilder := facet.NewDateTimeFacetBuilder(facetRequest.Field, facetRequest.Size)
-				dateTimeParser := i.m.DateTimeParserNamed("")
 				for _, dr := range facetRequest.DateTimeRanges {
-					start, end := dr.ParseDates(dateTimeParser)
-					facetBuilder.AddRange(dr.Name, start, end)
+					dateTimeParserName := defaultDateTimeParser
+					if dr.DateTimeParser != "" {
+						dateTimeParserName = dr.DateTimeParser
+					}
+					dateTimeParser := i.m.DateTimeParserNamed(dateTimeParserName)
+					if dateTimeParser == nil {
+						return nil, fmt.Errorf("no date time parser named `%s` registered", dateTimeParserName)
+					}
+					start, end, startLayout, endLayout, err := dr.ParseDates(dateTimeParser)
+					if err != nil {
+						return nil, fmt.Errorf("ParseDates err: %v, using date time parser named %s", err, dateTimeParserName)
+					}
+					if start.IsZero() && end.IsZero() {
+						return nil, fmt.Errorf("date range query must specify either start, end or both for date range name '%s'", dr.Name)
+					}
+					facetBuilder.AddRange(dr.Name, start, end, startLayout, endLayout)
 				}
 				facetsBuilder.Add(facetName, facetBuilder)
 			} else {
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 a0fb024f..06b3c2fe 100644
--- a/search.go
+++ b/search.go
@@ -46,36 +46,44 @@ func init() {
 }
 
 type dateTimeRange struct {
-	Name        string    `json:"name,omitempty"`
-	Start       time.Time `json:"start,omitempty"`
-	End         time.Time `json:"end,omitempty"`
-	startString *string
-	endString   *string
+	Name           string    `json:"name,omitempty"`
+	Start          time.Time `json:"start,omitempty"`
+	End            time.Time `json:"end,omitempty"`
+	DateTimeParser string    `json:"datetime_parser,omitempty"`
+	startString    *string
+	endString      *string
 }
 
-func (dr *dateTimeRange) ParseDates(dateTimeParser analysis.DateTimeParser) (start, end time.Time) {
+func (dr *dateTimeRange) ParseDates(dateTimeParser analysis.DateTimeParser) (start, end time.Time, startLayout, endLayout string, err error) {
 	start = dr.Start
+	startLayout = time.RFC3339Nano
 	if dr.Start.IsZero() && dr.startString != nil {
-		s, _, err := dateTimeParser.ParseDateTime(*dr.startString)
-		if err == nil {
-			start = s
+		s, layout, parseError := dateTimeParser.ParseDateTime(*dr.startString)
+		if parseError != nil {
+			return start, end, startLayout, endLayout, fmt.Errorf("error parsing start date '%s' for date range name '%s': %v", *dr.startString, dr.Name, parseError)
 		}
+		start = s
+		startLayout = layout
 	}
 	end = dr.End
+	endLayout = time.RFC3339Nano
 	if dr.End.IsZero() && dr.endString != nil {
-		e, _, err := dateTimeParser.ParseDateTime(*dr.endString)
-		if err == nil {
-			end = e
+		e, layout, parseError := dateTimeParser.ParseDateTime(*dr.endString)
+		if parseError != nil {
+			return start, end, startLayout, endLayout, fmt.Errorf("error parsing end date '%s' for date range name '%s': %v", *dr.endString, dr.Name, parseError)
 		}
+		end = e
+		endLayout = layout
 	}
-	return start, end
+	return start, end, startLayout, endLayout, err
 }
 
 func (dr *dateTimeRange) UnmarshalJSON(input []byte) error {
 	var temp struct {
-		Name  string  `json:"name,omitempty"`
-		Start *string `json:"start,omitempty"`
-		End   *string `json:"end,omitempty"`
+		Name           string  `json:"name,omitempty"`
+		Start          *string `json:"start,omitempty"`
+		End            *string `json:"end,omitempty"`
+		DateTimeParser string  `json:"datetime_parser,omitempty"`
 	}
 
 	if err := json.Unmarshal(input, &temp); err != nil {
@@ -89,22 +97,33 @@ func (dr *dateTimeRange) UnmarshalJSON(input []byte) error {
 	if temp.End != nil {
 		dr.endString = temp.End
 	}
+	if temp.DateTimeParser != "" {
+		dr.DateTimeParser = temp.DateTimeParser
+	}
 
 	return nil
 }
 
 func (dr *dateTimeRange) MarshalJSON() ([]byte, error) {
 	rv := map[string]interface{}{
-		"name":  dr.Name,
-		"start": dr.Start,
-		"end":   dr.End,
+		"name": dr.Name,
 	}
-	if dr.Start.IsZero() && dr.startString != nil {
+
+	if !dr.Start.IsZero() {
+		rv["start"] = dr.Start
+	} else if dr.startString != nil {
 		rv["start"] = dr.startString
 	}
-	if dr.End.IsZero() && dr.endString != nil {
+
+	if !dr.End.IsZero() {
+		rv["end"] = dr.End
+	} else if dr.endString != nil {
 		rv["end"] = dr.endString
 	}
+
+	if dr.DateTimeParser != "" {
+		rv["datetime_parser"] = dr.DateTimeParser
+	}
 	return json.Marshal(rv)
 }
 
@@ -138,7 +157,7 @@ func (fr *FacetRequest) Validate() error {
 	nrCount := len(fr.NumericRanges)
 	drCount := len(fr.DateTimeRanges)
 	if nrCount > 0 && drCount > 0 {
-		return fmt.Errorf("facet can only conain numeric ranges or date ranges, not both")
+		return fmt.Errorf("facet can only contain numeric ranges or date ranges, not both")
 	}
 
 	if nrCount > 0 {
@@ -164,9 +183,16 @@ func (fr *FacetRequest) Validate() error {
 				return fmt.Errorf("date ranges contains duplicate name '%s'", dr.Name)
 			}
 			drNames[dr.Name] = struct{}{}
-			start, end := dr.ParseDates(dateTimeParser)
-			if start.IsZero() && end.IsZero() {
-				return fmt.Errorf("date range query must specify either start, end or both for range name '%s'", dr.Name)
+			if dr.DateTimeParser == "" {
+				// cannot parse the date range dates as the defaultDateTimeParser is overridden
+				// so perform this validation at query time
+				start, end, _, _, err := dr.ParseDates(dateTimeParser)
+				if err != nil {
+					return fmt.Errorf("ParseDates err: %v, using date time parser named %s", err, defaultDateTimeParser)
+				}
+				if start.IsZero() && end.IsZero() {
+					return fmt.Errorf("date range query must specify either start, end or both for range name '%s'", dr.Name)
+				}
 			}
 		}
 	}
@@ -186,7 +212,7 @@ func (fr *FacetRequest) AddDateTimeRange(name string, start, end time.Time) {
 }
 
 // AddDateTimeRangeString adds a bucket to a field
-// containing date values.
+// containing date values. Uses defaultDateTimeParser to parse the date strings.
 func (fr *FacetRequest) AddDateTimeRangeString(name string, start, end *string) {
 	if fr.DateTimeRanges == nil {
 		fr.DateTimeRanges = make([]*dateTimeRange, 0, 1)
@@ -195,6 +221,17 @@ func (fr *FacetRequest) AddDateTimeRangeString(name string, start, end *string)
 		&dateTimeRange{Name: name, startString: start, endString: end})
 }
 
+// AddDateTimeRangeString adds a bucket to a field
+// containing date values. Uses the specified parser to parse the date strings.
+// provided the parser is registered in the index mapping.
+func (fr *FacetRequest) AddDateTimeRangeStringWithParser(name string, start, end *string, parser string) {
+	if fr.DateTimeRanges == nil {
+		fr.DateTimeRanges = make([]*dateTimeRange, 0, 1)
+	}
+	fr.DateTimeRanges = append(fr.DateTimeRanges,
+		&dateTimeRange{Name: name, startString: start, endString: end, DateTimeParser: parser})
+}
+
 // AddNumericRange adds a bucket to a field
 // containing numeric values.  Documents with a
 // numeric value falling into this range are
@@ -268,6 +305,7 @@ func (h *HighlightRequest) AddField(field string) {
 //
 // A special field named "*" can be used to return all fields.
 type SearchRequest struct {
+	ClientContextID  string            `json:"client_context_id,omitempty"`
 	Query            query.Query       `json:"query"`
 	Size             int               `json:"size"`
 	From             int               `json:"from"`
@@ -284,6 +322,10 @@ type SearchRequest struct {
 	sortFunc func(sort.Interface)
 }
 
+func (r *SearchRequest) SetClientContextID(id string) {
+	r.ClientContextID = id
+}
+
 func (r *SearchRequest) Validate() error {
 	if srq, ok := r.Query.(query.ValidatableQuery); ok {
 		if err := srq.Validate(); err != nil {
@@ -355,6 +397,7 @@ func (r *SearchRequest) SetSearchBefore(before []string) {
 func (r *SearchRequest) UnmarshalJSON(input []byte) error {
 	var (
 		temp struct {
+			ClientContextID  string            `json:"client_context_id"`
 			Q                json.RawMessage   `json:"query"`
 			Size             *int              `json:"size"`
 			From             int               `json:"from"`
@@ -387,6 +430,7 @@ func (r *SearchRequest) UnmarshalJSON(input []byte) error {
 			return err
 		}
 	}
+	r.ClientContextID = temp.ClientContextID
 	r.From = temp.From
 	r.Explain = temp.Explain
 	r.Highlight = temp.Highlight
diff --git a/search/facet/facet_builder_datetime.go b/search/facet/facet_builder_datetime.go
index ff5167f2..c272396b 100644
--- a/search/facet/facet_builder_datetime.go
+++ b/search/facet/facet_builder_datetime.go
@@ -17,6 +17,7 @@ package facet
 import (
 	"reflect"
 	"sort"
+	"strconv"
 	"time"
 
 	"github.com/blevesearch/bleve/v2/numeric"
@@ -35,8 +36,10 @@ func init() {
 }
 
 type dateTimeRange struct {
-	start time.Time
-	end   time.Time
+	start       time.Time
+	end         time.Time
+	startLayout string
+	endLayout   string
 }
 
 type DateTimeFacetBuilder struct {
@@ -75,10 +78,12 @@ func (fb *DateTimeFacetBuilder) Size() int {
 	return sizeInBytes
 }
 
-func (fb *DateTimeFacetBuilder) AddRange(name string, start, end time.Time) {
+func (fb *DateTimeFacetBuilder) AddRange(name string, start, end time.Time, startLayout string, endLayout string) {
 	r := dateTimeRange{
-		start: start,
-		end:   end,
+		start:       start,
+		end:         end,
+		startLayout: startLayout,
+		endLayout:   endLayout,
 	}
 	fb.ranges[name] = &r
 }
@@ -134,11 +139,23 @@ func (fb *DateTimeFacetBuilder) Result() *search.FacetResult {
 			Count: count,
 		}
 		if !dateRange.start.IsZero() {
-			start := dateRange.start.Format(time.RFC3339Nano)
+			var start string
+			if dateRange.startLayout == "" {
+				// layout not set probably means it is probably a timestamp
+				start = strconv.FormatInt(dateRange.start.UnixNano(), 10)
+			} else {
+				start = dateRange.start.Format(dateRange.startLayout)
+			}
 			tf.Start = &start
 		}
 		if !dateRange.end.IsZero() {
-			end := dateRange.end.Format(time.RFC3339Nano)
+			var end string
+			if dateRange.endLayout == "" {
+				// layout not set probably means it is probably a timestamp
+				end = strconv.FormatInt(dateRange.end.UnixNano(), 10)
+			} else {
+				end = dateRange.end.Format(dateRange.endLayout)
+			}
 			tf.End = &end
 		}
 		rv.DateRanges = append(rv.DateRanges, tf)
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..7f1ef0b8 100644
--- a/search_test.go
+++ b/search_test.go
@@ -282,7 +282,7 @@ func TestUnmarshalingSearchResult(t *testing.T) {
 func TestFacetNumericDateRangeRequests(t *testing.T) {
 	var drMissingErr = fmt.Errorf("date range query must specify either start, end or both for range name 'testName'")
 	var nrMissingErr = fmt.Errorf("numeric range query must specify either min, max or both for range name 'testName'")
-	var drNrErr = fmt.Errorf("facet can only conain numeric ranges or date ranges, not both")
+	var drNrErr = fmt.Errorf("facet can only contain numeric ranges or date ranges, not both")
 	var drNameDupErr = fmt.Errorf("date ranges contains duplicate name 'testName'")
 	var nrNameDupErr = fmt.Errorf("numeric ranges contains duplicate name 'testName'")
 	value := float64(5)
@@ -2473,3 +2473,524 @@ 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])
+			}
+		}
+	}
+}
+
+func TestDateRangeFaceQueriesWithCustomDateTimeParser(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)
+	}
+
+	query := NewMatchAllQuery()
+
+	type testFacetResult struct {
+		name  string
+		start string
+		end   string
+		count int
+		err   error
+	}
+
+	type testFacetRequest struct {
+		name   string
+		start  string
+		end    string
+		parser string
+		result testFacetResult
+	}
+
+	tests := []testFacetRequest{
+		{
+			// Test without a query time override of the parser (use default parser)
+			name:  "test",
+			start: "2001-08-20 18:00:00",
+			end:   "2001-08-20 18:10:00",
+			result: testFacetResult{
+				name:  "test",
+				start: "2001-08-20 18:00:00",
+				end:   "2001-08-20 18:10:00",
+				count: 2,
+				err:   nil,
+			},
+		},
+		{
+			name:   "test",
+			start:  "20/08/2001 6:00PM",
+			end:    "20/08/2001 6:10PM",
+			parser: "queryDT",
+			result: testFacetResult{
+				name:  "test",
+				start: "20/08/2001 6:00PM",
+				end:   "20/08/2001 6:10PM",
+				count: 2,
+				err:   nil,
+			},
+		},
+		{
+			name:   "test",
+			start:  "20/08/2001 15:00:00",
+			end:    "2001/08/20 6:10PM",
+			parser: "customDT",
+			result: testFacetResult{
+				name:  "test",
+				start: "20/08/2001 15:00:00",
+				end:   "2001/08/20 6:10PM",
+				count: 2,
+				err:   nil,
+			},
+		},
+		{
+			name:   "test",
+			end:    "2001/08/20 6:15PM",
+			parser: "customDT",
+			result: testFacetResult{
+				name:  "test",
+				end:   "2001/08/20 6:15PM",
+				count: 3,
+				err:   nil,
+			},
+		},
+		{
+			name:   "test",
+			start:  "20/08/2001 6:15PM",
+			parser: "queryDT",
+			result: testFacetResult{
+				name:  "test",
+				start: "20/08/2001 6:15PM",
+				count: 2,
+				err:   nil,
+			},
+		},
+		// some error cases
+		{
+			name:   "test",
+			parser: "queryDT",
+			result: testFacetResult{
+				name: "test",
+				err:  fmt.Errorf("date range query must specify either start, end or both for date range name 'test'"),
+			},
+		},
+		{
+			// default parser is used for the query, but the start time is not in the correct format (RFC3339),
+			// so it should throw an error
+			name:  "test",
+			start: "20/08/2001 6:15PM",
+			result: testFacetResult{
+				name: "test",
+				err:  fmt.Errorf("ParseDates err: error parsing start date '20/08/2001 6:15PM' for date range name 'test': unable to parse datetime with any of the layouts, using date time parser named dateTimeOptional"),
+			},
+		},
+	}
+
+	for _, test := range tests {
+		searchRequest := NewSearchRequest(query)
+
+		fr := NewFacetRequest("date", 100)
+		start := &test.start
+		if test.start == "" {
+			start = nil
+		}
+		end := &test.end
+		if test.end == "" {
+			end = nil
+		}
+
+		fr.AddDateTimeRangeStringWithParser(test.name, start, end, test.parser)
+		searchRequest.AddFacet("dateFacet", fr)
+
+		searchResults, err := idx.Search(searchRequest)
+		if err != nil {
+			if test.result.err == nil {
+				t.Fatalf("Unexpected error: %v", err)
+			}
+			if err.Error() != test.result.err.Error() {
+				t.Fatalf("Expected error %v, got %v", test.result.err, err)
+			}
+			continue
+		}
+		for _, facetResult := range searchResults.Facets {
+			if len(facetResult.DateRanges) != 1 {
+				t.Fatal("Expected 1 date range facet")
+			}
+			result := facetResult.DateRanges[0]
+			if result.Name != test.result.name {
+				t.Fatalf("Expected name %s, got %s", test.result.name, result.Name)
+			}
+			if result.Start != nil && *result.Start != test.result.start {
+				t.Fatalf("Expected start %s, got %s", test.result.start, *result.Start)
+			}
+			if result.End != nil && *result.End != test.result.end {
+				t.Fatalf("Expected end %s, got %s", test.result.end, *result.End)
+			}
+			if result.Start == nil && test.result.start != "" {
+				t.Fatalf("Expected start %s, got nil", test.result.start)
+			}
+			if result.End == nil && test.result.end != "" {
+				t.Fatalf("Expected end %s, got nil", test.result.end)
+			}
+			if result.Count != test.result.count {
+				t.Fatalf("Expected count %d, got %d", test.result.count, result.Count)
+			}
+		}
+	}
+}
diff --git a/test/tests/facet/searches.json b/test/tests/facet/searches.json
index 6752282a..33ac3977 100644
--- a/test/tests/facet/searches.json
+++ b/test/tests/facet/searches.json
@@ -129,12 +129,12 @@
 						{
 							"name": "new",
 							"count": 9,
-							"start": "2012-01-01T00:00:00Z"
+							"start": "2012-01-01"
 						},
 						{
 							"name": "old",
 							"count": 1,
-							"end": "2012-01-01T00:00:00Z"
+							"end": "2012-01-01"
 						}
 					]
 				}