From 48705c79f6245c862cc43ab748a3afca494ef3c6 Mon Sep 17 00:00:00 2001 From: fschade Date: Sun, 3 Aug 2025 12:48:29 +0200 Subject: [PATCH] enhancement(search): implement os dsl range field --- .../pkg/opensearch/internal/test/test.go | 1 + services/search/pkg/opensearch/os_dsl.go | 18 ++ .../pkg/opensearch/os_dsl_query_bool_test.go | 2 +- .../os_dsl_query_full_text_match_phrase.go | 4 +- .../opensearch/os_dsl_query_term_level_ids.go | 4 +- .../os_dsl_query_term_level_range.go | 92 ++++++++++ .../os_dsl_query_term_level_range_test.go | 164 ++++++++++++++++++ .../os_dsl_query_term_level_term.go | 4 +- .../os_dsl_query_term_level_term_test.go | 4 +- .../os_dsl_query_term_level_wildcard.go | 4 +- 10 files changed, 282 insertions(+), 15 deletions(-) create mode 100644 services/search/pkg/opensearch/os_dsl_query_term_level_range.go create mode 100644 services/search/pkg/opensearch/os_dsl_query_term_level_range_test.go diff --git a/services/search/pkg/opensearch/internal/test/test.go b/services/search/pkg/opensearch/internal/test/test.go index d19ea47cd..5653721e3 100644 --- a/services/search/pkg/opensearch/internal/test/test.go +++ b/services/search/pkg/opensearch/internal/test/test.go @@ -11,6 +11,7 @@ type TableTest[G any, W any] struct { Name string Got G Want W + Err error } func ToJSON(t *testing.T, data any) string { diff --git a/services/search/pkg/opensearch/os_dsl.go b/services/search/pkg/opensearch/os_dsl.go index 1c259c072..222f2e0d0 100644 --- a/services/search/pkg/opensearch/os_dsl.go +++ b/services/search/pkg/opensearch/os_dsl.go @@ -43,6 +43,24 @@ func (f BuilderFunc) String() string { return string(b) } +func applyValue[T any](target map[string]any, key string, v T) { + if target == nil || isEmpty(key) || isEmpty(v) { + return + } + + target[key] = v +} + +func applyValues[T any](target map[string]any, values map[string]T) { + if target == nil || isEmpty(values) { + return + } + + for k, v := range values { + applyValue[T](target, k, v) + } +} + func applyBuilder(target map[string]any, key string, builder Builder) error { if target == nil || isEmpty(key) || isEmpty(builder) { return nil diff --git a/services/search/pkg/opensearch/os_dsl_query_bool_test.go b/services/search/pkg/opensearch/os_dsl_query_bool_test.go index aaa069e86..8b7dde977 100644 --- a/services/search/pkg/opensearch/os_dsl_query_bool_test.go +++ b/services/search/pkg/opensearch/os_dsl_query_bool_test.go @@ -17,7 +17,7 @@ func TestBoolQuery(t *testing.T) { Want: nil, }, { - Name: "naked", + Name: "with-options", Got: opensearch.NewBoolQuery(opensearch.BoolQueryOptions{ MinimumShouldMatch: 10, Boost: 10, diff --git a/services/search/pkg/opensearch/os_dsl_query_full_text_match_phrase.go b/services/search/pkg/opensearch/os_dsl_query_full_text_match_phrase.go index 9277c18e3..a8ca72902 100644 --- a/services/search/pkg/opensearch/os_dsl_query_full_text_match_phrase.go +++ b/services/search/pkg/opensearch/os_dsl_query_full_text_match_phrase.go @@ -31,9 +31,7 @@ func (q *MatchPhraseQuery) Map() (map[string]any, error) { return nil, err } - if !isEmpty(q.query) { - data["query"] = q.query - } + applyValue(data, "query", q.query) if isEmpty(data) { return nil, nil diff --git a/services/search/pkg/opensearch/os_dsl_query_term_level_ids.go b/services/search/pkg/opensearch/os_dsl_query_term_level_ids.go index a5d60e13d..0c0b7a0f9 100644 --- a/services/search/pkg/opensearch/os_dsl_query_term_level_ids.go +++ b/services/search/pkg/opensearch/os_dsl_query_term_level_ids.go @@ -24,9 +24,7 @@ func (q *IDsQuery) Map() (map[string]any, error) { return nil, err } - if !isEmpty(q.values) { - data["values"] = q.values - } + applyValue(data, "values", q.values) if isEmpty(data) { return nil, nil diff --git a/services/search/pkg/opensearch/os_dsl_query_term_level_range.go b/services/search/pkg/opensearch/os_dsl_query_term_level_range.go new file mode 100644 index 000000000..a1853162f --- /dev/null +++ b/services/search/pkg/opensearch/os_dsl_query_term_level_range.go @@ -0,0 +1,92 @@ +package opensearch + +import ( + "encoding/json" + "errors" + "time" +) + +type RangeQuery[T time.Time | string] struct { + field string + gt T + gte T + lt T + lte T + options RangeQueryOptions +} + +type RangeQueryOptions struct { + Format string `json:"format,omitempty"` + Relation string `json:"relation,omitempty"` + Boost float32 `json:"boost,omitempty"` + TimeZone string `json:"time_zone,omitempty"` +} + +func NewRangeQuery[T time.Time | string](field string, o ...RangeQueryOptions) *RangeQuery[T] { + return &RangeQuery[T]{field: field, options: merge(o...)} +} + +func (q *RangeQuery[T]) Gt(v T) *RangeQuery[T] { + q.gt = v + return q +} + +func (q *RangeQuery[T]) Gte(v T) *RangeQuery[T] { + q.gte = v + return q +} + +func (q *RangeQuery[T]) Lt(v T) *RangeQuery[T] { + q.lt = v + return q +} + +func (q *RangeQuery[T]) Lte(v T) *RangeQuery[T] { + q.lte = v + return q +} + +func (q *RangeQuery[T]) Map() (map[string]any, error) { + data, err := convert[map[string]any](q.options) + if err != nil { + return nil, err + } + + if !isEmpty(q.gt) && !isEmpty(q.gte) { + return nil, errors.New("cannot set both gt and gte in RangeQuery") + } + + if !isEmpty(q.lt) && !isEmpty(q.lte) { + return nil, errors.New("cannot set both lt and lte in RangeQuery") + } + + applyValues(data, map[string]T{ + "gt": q.gt, + "gte": q.gte, + "lt": q.lt, + "lte": q.lte, + }) + + if isEmpty(data) { + return nil, nil + } + + return map[string]any{ + "range": map[string]any{ + q.field: data, + }, + }, nil +} + +func (q *RangeQuery[T]) MarshalJSON() ([]byte, error) { + data, err := q.Map() + if err != nil { + return nil, err + } + return json.Marshal(data) +} + +func (q *RangeQuery[T]) String() string { + b, _ := q.MarshalJSON() + return string(b) +} diff --git a/services/search/pkg/opensearch/os_dsl_query_term_level_range_test.go b/services/search/pkg/opensearch/os_dsl_query_term_level_range_test.go new file mode 100644 index 000000000..4498c39be --- /dev/null +++ b/services/search/pkg/opensearch/os_dsl_query_term_level_range_test.go @@ -0,0 +1,164 @@ +package opensearch_test + +import ( + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/opencloud-eu/opencloud/services/search/pkg/opensearch" + "github.com/opencloud-eu/opencloud/services/search/pkg/opensearch/internal/test" +) + +func TestRangeQuery(t *testing.T) { + now := time.Now() + tests := []opensearchtest.TableTest[opensearch.Builder, map[string]any]{ + { + Name: "empty", + Got: opensearch.NewRangeQuery[string]("empty"), + Want: nil, + }, + { + Name: "gt string", + Got: opensearch.NewRangeQuery[string]("created").Gt("2023-01-01T00:00:00Z"), + Want: map[string]any{ + "range": map[string]any{ + "created": map[string]any{ + "gt": "2023-01-01T00:00:00Z", + }, + }, + }, + }, + { + Name: "gt time", + Got: opensearch.NewRangeQuery[time.Time]("created").Gt(now), + Want: map[string]any{ + "range": map[string]any{ + "created": map[string]any{ + "gt": now, + }, + }, + }, + }, + { + Name: "gte string", + Got: opensearch.NewRangeQuery[string]("created").Gte("2023-01-01T00:00:00Z"), + Want: map[string]any{ + "range": map[string]any{ + "created": map[string]any{ + "gte": "2023-01-01T00:00:00Z", + }, + }, + }, + }, + { + Name: "gte time", + Got: opensearch.NewRangeQuery[time.Time]("created").Gte(now), + Want: map[string]any{ + "range": map[string]any{ + "created": map[string]any{ + "gte": now, + }, + }, + }, + }, + { + Name: "gt & gte", + Got: opensearch.NewRangeQuery[time.Time]("created").Gt(now).Gte(now), + Want: nil, + Err: errors.New(""), + }, + { + Name: "gt string", + Got: opensearch.NewRangeQuery[string]("created").Lt("2023-01-01T00:00:00Z"), + Want: map[string]any{ + "range": map[string]any{ + "created": map[string]any{ + "lt": "2023-01-01T00:00:00Z", + }, + }, + }, + }, + { + Name: "lt time", + Got: opensearch.NewRangeQuery[time.Time]("created").Lt(now), + Want: map[string]any{ + "range": map[string]any{ + "created": map[string]any{ + "lt": now, + }, + }, + }, + }, + { + Name: "lte string", + Got: opensearch.NewRangeQuery[string]("created").Lte("2023-01-01T00:00:00Z"), + Want: map[string]any{ + "range": map[string]any{ + "created": map[string]any{ + "lte": "2023-01-01T00:00:00Z", + }, + }, + }, + }, + { + Name: "lte time", + Got: opensearch.NewRangeQuery[time.Time]("created").Lte(now), + Want: map[string]any{ + "range": map[string]any{ + "created": map[string]any{ + "lte": now, + }, + }, + }, + }, + { + Name: "lt & lte", + Got: opensearch.NewRangeQuery[time.Time]("created").Lt(now).Lte(now), + Want: nil, + Err: errors.New(""), + }, + { + Name: "options", + Got: opensearch.NewRangeQuery[time.Time]("created", opensearch.RangeQueryOptions{ + Format: "strict_date_optional_time", + Relation: "within", + Boost: 1.0, + TimeZone: "UTC", + }).Lte(now).Gte(now), + Want: map[string]any{ + "range": map[string]any{ + "created": map[string]any{ + "lte": now, + "gte": now, + "format": "strict_date_optional_time", + "relation": "within", + "boost": 1.0, + "time_zone": "UTC", + }, + }, + }, + }, + } + + for _, test := range tests { + t.Run(test.Name, func(t *testing.T) { + got, err := test.Got.MarshalJSON() + switch { + case test.Err != nil && test.Err.Error() == "": // Expecting any error + assert.Error(t, err) + assert.Nil(t, got) + return + case test.Err != nil && test.Err.Error() != "": // Expecting a specific error + assert.ErrorIs(t, test.Err, err) + assert.Nil(t, got) + return + } + + require.NoError(t, err) + assert.JSONEq(t, opensearchtest.JSONMustMarshal(t, test.Want), string(got)) + }) + } +} diff --git a/services/search/pkg/opensearch/os_dsl_query_term_level_term.go b/services/search/pkg/opensearch/os_dsl_query_term_level_term.go index 6c7540e7d..a9620e8fc 100644 --- a/services/search/pkg/opensearch/os_dsl_query_term_level_term.go +++ b/services/search/pkg/opensearch/os_dsl_query_term_level_term.go @@ -31,9 +31,7 @@ func (q *TermQuery[T]) Map() (map[string]any, error) { return nil, err } - if !isEmpty(q.value) { - data["value"] = q.value - } + applyValue(data, "value", q.value) if isEmpty(data) { return nil, nil diff --git a/services/search/pkg/opensearch/os_dsl_query_term_level_term_test.go b/services/search/pkg/opensearch/os_dsl_query_term_level_term_test.go index 4d36cba09..56f58b737 100644 --- a/services/search/pkg/opensearch/os_dsl_query_term_level_term_test.go +++ b/services/search/pkg/opensearch/os_dsl_query_term_level_term_test.go @@ -17,7 +17,7 @@ func TestTermQuery(t *testing.T) { Want: nil, }, { - Name: "naked", + Name: "op-options", Got: opensearch.NewTermQuery[bool]("deleted").Value(false), Want: map[string]any{ "term": map[string]any{ @@ -28,7 +28,7 @@ func TestTermQuery(t *testing.T) { }, }, { - Name: "term", + Name: "with-options", Got: opensearch.NewTermQuery[bool]("deleted", opensearch.TermQueryOptions{ Boost: 1.0, CaseInsensitive: true, diff --git a/services/search/pkg/opensearch/os_dsl_query_term_level_wildcard.go b/services/search/pkg/opensearch/os_dsl_query_term_level_wildcard.go index 87be4119e..b6010e1f8 100644 --- a/services/search/pkg/opensearch/os_dsl_query_term_level_wildcard.go +++ b/services/search/pkg/opensearch/os_dsl_query_term_level_wildcard.go @@ -31,9 +31,7 @@ func (q *WildcardQuery) Map() (map[string]any, error) { return nil, err } - if !isEmpty(q.value) { - data["value"] = q.value - } + applyValue(data, "value", q.value) if isEmpty(data) { return nil, nil