Initial QSfera import
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
package osu
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
|
||||
"dario.cat/mergo"
|
||||
|
||||
"github.com/qsfera/server/pkg/conversions"
|
||||
)
|
||||
|
||||
type Builder interface {
|
||||
json.Marshaler
|
||||
Map() (map[string]any, error)
|
||||
}
|
||||
|
||||
func newBase(v ...any) (map[string]any, error) {
|
||||
base := make(map[string]any)
|
||||
for _, value := range v {
|
||||
data, err := conversions.To[map[string]any](value)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to convert value to map: %w", err)
|
||||
}
|
||||
|
||||
if isEmpty(data) {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := mergo.Merge(&base, data); err != nil {
|
||||
return nil, fmt.Errorf("failed to merge value into base: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return base, nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
data, err := builder.Map()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to map builder %s: %w", key, err)
|
||||
}
|
||||
|
||||
if !isEmpty(data) {
|
||||
target[key] = data
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyBuilders(target map[string]any, key string, bs ...Builder) error {
|
||||
if target == nil || isEmpty(key) || isEmpty(bs) {
|
||||
return nil
|
||||
}
|
||||
|
||||
builders := make([]map[string]any, 0, len(bs))
|
||||
for _, builder := range bs {
|
||||
data, err := builder.Map()
|
||||
switch {
|
||||
case err != nil:
|
||||
return fmt.Errorf("failed to map builder %s: %w", key, err)
|
||||
case isEmpty(data):
|
||||
continue
|
||||
default:
|
||||
builders = append(builders, data)
|
||||
}
|
||||
}
|
||||
|
||||
if len(builders) > 0 {
|
||||
target[key] = builders
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func isEmpty(x any) bool {
|
||||
switch {
|
||||
case x == nil:
|
||||
return true
|
||||
case reflect.ValueOf(x).Kind() == reflect.Bool:
|
||||
return false
|
||||
case reflect.DeepEqual(x, reflect.Zero(reflect.TypeOf(x)).Interface()):
|
||||
return true
|
||||
case reflect.ValueOf(x).Kind() == reflect.Map && reflect.ValueOf(x).Len() == 0:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func merge[T any](vals ...T) T {
|
||||
base := make(map[string]any)
|
||||
|
||||
for _, val := range vals {
|
||||
data, err := conversions.To[map[string]any](val)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
_ = mergo.Merge(&base, data)
|
||||
}
|
||||
|
||||
data, _ := conversions.To[T](base)
|
||||
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package osu
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
type BoolQuery struct {
|
||||
must []Builder
|
||||
mustNot []Builder
|
||||
should []Builder
|
||||
filter []Builder
|
||||
params *BoolQueryParams
|
||||
}
|
||||
|
||||
type BoolQueryParams struct {
|
||||
MinimumShouldMatch int16 `json:"minimum_should_match,omitempty"`
|
||||
Boost float32 `json:"boost,omitempty"`
|
||||
Name string `json:"_name,omitempty"`
|
||||
}
|
||||
|
||||
func NewBoolQuery() *BoolQuery {
|
||||
return &BoolQuery{}
|
||||
}
|
||||
|
||||
func (q *BoolQuery) Params(v *BoolQueryParams) *BoolQuery {
|
||||
q.params = v
|
||||
return q
|
||||
}
|
||||
|
||||
func (q *BoolQuery) Must(v ...Builder) *BoolQuery {
|
||||
q.must = append(q.must, v...)
|
||||
return q
|
||||
}
|
||||
|
||||
func (q *BoolQuery) MustNot(v ...Builder) *BoolQuery {
|
||||
q.mustNot = append(q.mustNot, v...)
|
||||
return q
|
||||
}
|
||||
|
||||
func (q *BoolQuery) Should(v ...Builder) *BoolQuery {
|
||||
q.should = append(q.should, v...)
|
||||
return q
|
||||
}
|
||||
|
||||
func (q *BoolQuery) Filter(v ...Builder) *BoolQuery {
|
||||
q.filter = append(q.filter, v...)
|
||||
return q
|
||||
}
|
||||
|
||||
func (q *BoolQuery) Map() (map[string]any, error) {
|
||||
base, err := newBase(q.params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := applyBuilders(base, "must", q.must...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := applyBuilders(base, "must_not", q.mustNot...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := applyBuilders(base, "should", q.should...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := applyBuilders(base, "filter", q.filter...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if isEmpty(base) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"bool": base,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (q *BoolQuery) MarshalJSON() ([]byte, error) {
|
||||
data, err := q.Map()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(data)
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package osu_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/qsfera/server/services/search/pkg/opensearch/internal/osu"
|
||||
"github.com/qsfera/server/services/search/pkg/opensearch/internal/test"
|
||||
)
|
||||
|
||||
func TestBoolQuery(t *testing.T) {
|
||||
tests := []opensearchtest.TableTest[osu.Builder, map[string]any]{
|
||||
{
|
||||
Name: "empty",
|
||||
Got: osu.NewBoolQuery(),
|
||||
Want: nil,
|
||||
},
|
||||
{
|
||||
Name: "with params",
|
||||
Got: osu.NewBoolQuery().Params(&osu.BoolQueryParams{
|
||||
MinimumShouldMatch: 10,
|
||||
Boost: 10,
|
||||
Name: "some-name",
|
||||
}),
|
||||
Want: map[string]any{
|
||||
"bool": map[string]any{
|
||||
"minimum_should_match": 10,
|
||||
"boost": 10,
|
||||
"_name": "some-name",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "must",
|
||||
Got: osu.NewBoolQuery().Must(osu.NewTermQuery[string]("name").Value("tom")),
|
||||
Want: map[string]any{
|
||||
"bool": map[string]any{
|
||||
"must": []map[string]any{
|
||||
{
|
||||
"term": map[string]any{
|
||||
"name": map[string]any{
|
||||
"value": "tom",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "must_not",
|
||||
Got: osu.NewBoolQuery().MustNot(osu.NewTermQuery[string]("name").Value("tom")),
|
||||
Want: map[string]any{
|
||||
"bool": map[string]any{
|
||||
"must_not": []map[string]any{
|
||||
{
|
||||
"term": map[string]any{
|
||||
"name": map[string]any{
|
||||
"value": "tom",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "should",
|
||||
Got: osu.NewBoolQuery().Should(osu.NewTermQuery[string]("name").Value("tom")),
|
||||
Want: map[string]any{
|
||||
"bool": map[string]any{
|
||||
"should": []map[string]any{
|
||||
{
|
||||
"term": map[string]any{
|
||||
"name": map[string]any{
|
||||
"value": "tom",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "filter",
|
||||
Got: osu.NewBoolQuery().Filter(osu.NewTermQuery[string]("name").Value("tom")),
|
||||
Want: map[string]any{
|
||||
"bool": map[string]any{
|
||||
"filter": []map[string]any{
|
||||
{
|
||||
"term": map[string]any{
|
||||
"name": map[string]any{
|
||||
"value": "tom",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "full",
|
||||
Got: osu.NewBoolQuery().
|
||||
Must(osu.NewTermQuery[string]("name").Value("tom")).
|
||||
MustNot(osu.NewTermQuery[bool]("deleted").Value(true)).
|
||||
Should(osu.NewTermQuery[string]("gender").Value("male")).
|
||||
Filter(osu.NewTermQuery[int]("age").Value(42)),
|
||||
Want: map[string]any{
|
||||
"bool": map[string]any{
|
||||
"must": []map[string]any{
|
||||
{
|
||||
"term": map[string]any{
|
||||
"name": map[string]any{
|
||||
"value": "tom",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"must_not": []map[string]any{
|
||||
{
|
||||
"term": map[string]any{
|
||||
"deleted": map[string]any{
|
||||
"value": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"should": []map[string]any{
|
||||
{
|
||||
"term": map[string]any{
|
||||
"gender": map[string]any{
|
||||
"value": "male",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"filter": []map[string]any{
|
||||
{
|
||||
"term": map[string]any{
|
||||
"age": map[string]any{
|
||||
"value": 42,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.Name, func(t *testing.T) {
|
||||
|
||||
assert.JSONEq(t, opensearchtest.JSONMustMarshal(t, test.Want), opensearchtest.JSONMustMarshal(t, test.Got))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package osu
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
type MatchPhraseQuery struct {
|
||||
field string
|
||||
query string
|
||||
params *MatchPhraseQueryParams
|
||||
}
|
||||
|
||||
type MatchPhraseQueryParams struct {
|
||||
Analyzer string `json:"analyzer,omitempty"`
|
||||
Slop int `json:"slop,omitempty"`
|
||||
ZeroTermsQuery string `json:"zero_terms_query,omitempty"`
|
||||
}
|
||||
|
||||
func NewMatchPhraseQuery(field string) *MatchPhraseQuery {
|
||||
return &MatchPhraseQuery{field: field}
|
||||
}
|
||||
|
||||
func (q *MatchPhraseQuery) Params(v *MatchPhraseQueryParams) *MatchPhraseQuery {
|
||||
q.params = v
|
||||
return q
|
||||
}
|
||||
|
||||
func (q *MatchPhraseQuery) Query(v string) *MatchPhraseQuery {
|
||||
q.query = v
|
||||
return q
|
||||
}
|
||||
|
||||
func (q *MatchPhraseQuery) Map() (map[string]any, error) {
|
||||
base, err := newBase(q.params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
applyValue(base, "query", q.query)
|
||||
|
||||
if isEmpty(base) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"match_phrase": map[string]any{
|
||||
q.field: base,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (q *MatchPhraseQuery) MarshalJSON() ([]byte, error) {
|
||||
data, err := q.Map()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(data)
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
package osu_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/qsfera/server/services/search/pkg/opensearch/internal/osu"
|
||||
"github.com/qsfera/server/services/search/pkg/opensearch/internal/test"
|
||||
)
|
||||
|
||||
func TestNewMatchPhraseQuery(t *testing.T) {
|
||||
tests := []opensearchtest.TableTest[osu.Builder, map[string]any]{
|
||||
{
|
||||
Name: "empty",
|
||||
Got: osu.NewMatchPhraseQuery("empty"),
|
||||
Want: nil,
|
||||
},
|
||||
{
|
||||
Name: "with params",
|
||||
Got: osu.NewMatchPhraseQuery("name").Params(&osu.MatchPhraseQueryParams{
|
||||
Analyzer: "analyzer",
|
||||
Slop: 2,
|
||||
ZeroTermsQuery: "all",
|
||||
}),
|
||||
Want: map[string]any{
|
||||
"match_phrase": map[string]any{
|
||||
"name": map[string]any{
|
||||
"analyzer": "analyzer",
|
||||
"slop": 2,
|
||||
"zero_terms_query": "all",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "query",
|
||||
Got: osu.NewMatchPhraseQuery("name").Query("some match query"),
|
||||
Want: map[string]any{
|
||||
"match_phrase": map[string]any{
|
||||
"name": map[string]any{
|
||||
"query": "some match query",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "full",
|
||||
Got: osu.NewMatchPhraseQuery("name").Params(&osu.MatchPhraseQueryParams{
|
||||
Analyzer: "analyzer",
|
||||
Slop: 2,
|
||||
ZeroTermsQuery: "all",
|
||||
}).Query("some match query"),
|
||||
Want: map[string]any{
|
||||
"match_phrase": map[string]any{
|
||||
"name": map[string]any{
|
||||
"query": "some match query",
|
||||
"analyzer": "analyzer",
|
||||
"slop": 2,
|
||||
"zero_terms_query": "all",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.Name, func(t *testing.T) {
|
||||
assert.JSONEq(t, opensearchtest.JSONMustMarshal(t, test.Want), opensearchtest.JSONMustMarshal(t, test.Got))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package osu
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"slices"
|
||||
)
|
||||
|
||||
type IDsQuery struct {
|
||||
values []string
|
||||
params *IDsQueryParams
|
||||
}
|
||||
|
||||
type IDsQueryParams struct {
|
||||
Boost float32 `json:"boost,omitempty"`
|
||||
}
|
||||
|
||||
func NewIDsQuery(v ...string) *IDsQuery {
|
||||
return &IDsQuery{values: slices.Compact(v)}
|
||||
}
|
||||
|
||||
func (q *IDsQuery) Params(v *IDsQueryParams) *IDsQuery {
|
||||
q.params = v
|
||||
return q
|
||||
}
|
||||
|
||||
func (q *IDsQuery) Map() (map[string]any, error) {
|
||||
base, err := newBase(q.params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
applyValue(base, "values", q.values)
|
||||
|
||||
if isEmpty(base) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"ids": base,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (q *IDsQuery) MarshalJSON() ([]byte, error) {
|
||||
data, err := q.Map()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(data)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package osu_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/qsfera/server/services/search/pkg/opensearch/internal/osu"
|
||||
"github.com/qsfera/server/services/search/pkg/opensearch/internal/test"
|
||||
)
|
||||
|
||||
func TestIDsQuery(t *testing.T) {
|
||||
tests := []opensearchtest.TableTest[osu.Builder, map[string]any]{
|
||||
{
|
||||
Name: "empty",
|
||||
Got: osu.NewIDsQuery(),
|
||||
Want: nil,
|
||||
},
|
||||
{
|
||||
Name: "no params",
|
||||
Got: osu.NewIDsQuery("1", "2", "3", "3"),
|
||||
Want: map[string]any{
|
||||
"ids": map[string]any{
|
||||
"values": []string{"1", "2", "3"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "ids",
|
||||
Got: osu.NewIDsQuery("1", "2", "3", "3").Params(&osu.IDsQueryParams{Boost: 1.0}),
|
||||
Want: map[string]any{
|
||||
"ids": map[string]any{
|
||||
"values": []string{"1", "2", "3"},
|
||||
"boost": 1.0,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.Name, func(t *testing.T) {
|
||||
assert.JSONEq(t, opensearchtest.JSONMustMarshal(t, test.Want), opensearchtest.JSONMustMarshal(t, test.Got))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package osu
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
type RangeQuery[T time.Time | string] struct {
|
||||
field string
|
||||
gt T
|
||||
gte T
|
||||
lt T
|
||||
lte T
|
||||
params *RangeQueryParams
|
||||
}
|
||||
|
||||
type RangeQueryParams 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) *RangeQuery[T] {
|
||||
return &RangeQuery[T]{field: field}
|
||||
}
|
||||
|
||||
func (q *RangeQuery[T]) Params(v *RangeQueryParams) *RangeQuery[T] {
|
||||
q.params = v
|
||||
return q
|
||||
}
|
||||
|
||||
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) {
|
||||
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")
|
||||
}
|
||||
|
||||
base, err := newBase(q.params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
applyValues(base, map[string]T{
|
||||
"gt": q.gt,
|
||||
"gte": q.gte,
|
||||
"lt": q.lt,
|
||||
"lte": q.lte,
|
||||
})
|
||||
|
||||
if isEmpty(base) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"range": map[string]any{
|
||||
q.field: base,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (q *RangeQuery[T]) MarshalJSON() ([]byte, error) {
|
||||
data, err := q.Map()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(data)
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package osu_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/qsfera/server/services/search/pkg/opensearch/internal/osu"
|
||||
"github.com/qsfera/server/services/search/pkg/opensearch/internal/test"
|
||||
)
|
||||
|
||||
func TestRangeQuery(t *testing.T) {
|
||||
now := time.Now()
|
||||
tests := []opensearchtest.TableTest[osu.Builder, map[string]any]{
|
||||
{
|
||||
Name: "empty",
|
||||
Got: osu.NewRangeQuery[string]("empty"),
|
||||
Want: nil,
|
||||
},
|
||||
{
|
||||
Name: "gt string",
|
||||
Got: osu.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: osu.NewRangeQuery[time.Time]("created").Gt(now),
|
||||
Want: map[string]any{
|
||||
"range": map[string]any{
|
||||
"created": map[string]any{
|
||||
"gt": now,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "gte string",
|
||||
Got: osu.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: osu.NewRangeQuery[time.Time]("created").Gte(now),
|
||||
Want: map[string]any{
|
||||
"range": map[string]any{
|
||||
"created": map[string]any{
|
||||
"gte": now,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "gt & gte",
|
||||
Got: osu.NewRangeQuery[time.Time]("created").Gt(now).Gte(now),
|
||||
Want: nil,
|
||||
Err: errors.New(""),
|
||||
},
|
||||
{
|
||||
Name: "gt string",
|
||||
Got: osu.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: osu.NewRangeQuery[time.Time]("created").Lt(now),
|
||||
Want: map[string]any{
|
||||
"range": map[string]any{
|
||||
"created": map[string]any{
|
||||
"lt": now,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "lte string",
|
||||
Got: osu.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: osu.NewRangeQuery[time.Time]("created").Lte(now),
|
||||
Want: map[string]any{
|
||||
"range": map[string]any{
|
||||
"created": map[string]any{
|
||||
"lte": now,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "lt & lte",
|
||||
Got: osu.NewRangeQuery[time.Time]("created").Lt(now).Lte(now),
|
||||
Want: nil,
|
||||
Err: errors.New(""),
|
||||
},
|
||||
{
|
||||
Name: "with params",
|
||||
Got: osu.NewRangeQuery[time.Time]("created").Params(&osu.RangeQueryParams{
|
||||
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))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package osu
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
type TermQuery[T comparable] struct {
|
||||
field string
|
||||
value T
|
||||
params *TermQueryParams
|
||||
}
|
||||
|
||||
type TermQueryParams struct {
|
||||
Boost float32 `json:"boost,omitempty"`
|
||||
CaseInsensitive bool `json:"case_insensitive,omitempty"`
|
||||
Name string `json:"_name,omitempty"`
|
||||
}
|
||||
|
||||
func NewTermQuery[T comparable](field string) *TermQuery[T] {
|
||||
return &TermQuery[T]{field: field}
|
||||
}
|
||||
|
||||
func (q *TermQuery[T]) Params(v *TermQueryParams) *TermQuery[T] {
|
||||
q.params = v
|
||||
return q
|
||||
}
|
||||
|
||||
func (q *TermQuery[T]) Value(v T) *TermQuery[T] {
|
||||
q.value = v
|
||||
return q
|
||||
}
|
||||
|
||||
func (q *TermQuery[T]) Map() (map[string]any, error) {
|
||||
base, err := newBase(q.params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
applyValue(base, "value", q.value)
|
||||
|
||||
if isEmpty(base) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"term": map[string]any{
|
||||
q.field: base,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (q *TermQuery[T]) MarshalJSON() ([]byte, error) {
|
||||
data, err := q.Map()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(data)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package osu_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/qsfera/server/services/search/pkg/opensearch/internal/osu"
|
||||
"github.com/qsfera/server/services/search/pkg/opensearch/internal/test"
|
||||
)
|
||||
|
||||
func TestTermQuery(t *testing.T) {
|
||||
tests := []opensearchtest.TableTest[osu.Builder, map[string]any]{
|
||||
{
|
||||
Name: "empty",
|
||||
Got: osu.NewTermQuery[string]("empty"),
|
||||
Want: nil,
|
||||
},
|
||||
{
|
||||
Name: "no params",
|
||||
Got: osu.NewTermQuery[bool]("deleted").Value(false),
|
||||
Want: map[string]any{
|
||||
"term": map[string]any{
|
||||
"deleted": map[string]any{
|
||||
"value": false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "with params",
|
||||
Got: osu.NewTermQuery[bool]("deleted").Params(&osu.TermQueryParams{
|
||||
Boost: 1.0,
|
||||
CaseInsensitive: true,
|
||||
Name: "is-deleted",
|
||||
}).Value(true),
|
||||
Want: map[string]any{
|
||||
"term": map[string]any{
|
||||
"deleted": map[string]any{
|
||||
"value": true,
|
||||
"boost": 1.0,
|
||||
"case_insensitive": true,
|
||||
"_name": "is-deleted",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.Name, func(t *testing.T) {
|
||||
assert.JSONEq(t, opensearchtest.JSONMustMarshal(t, test.Want), opensearchtest.JSONMustMarshal(t, test.Got))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package osu
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
type WildcardQuery struct {
|
||||
field string
|
||||
value string
|
||||
params *WildcardQueryParams
|
||||
}
|
||||
|
||||
type WildcardQueryParams struct {
|
||||
Boost float32 `json:"boost,omitempty"`
|
||||
CaseInsensitive bool `json:"case_insensitive,omitempty"`
|
||||
Rewrite string `json:"rewrite,omitempty"`
|
||||
}
|
||||
|
||||
func NewWildcardQuery(field string) *WildcardQuery {
|
||||
return &WildcardQuery{field: field}
|
||||
}
|
||||
|
||||
func (q *WildcardQuery) Params(v *WildcardQueryParams) *WildcardQuery {
|
||||
q.params = v
|
||||
return q
|
||||
}
|
||||
|
||||
func (q *WildcardQuery) Value(v string) *WildcardQuery {
|
||||
q.value = v
|
||||
return q
|
||||
}
|
||||
|
||||
func (q *WildcardQuery) Map() (map[string]any, error) {
|
||||
base, err := newBase(q.params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
applyValue(base, "value", q.value)
|
||||
|
||||
if isEmpty(base) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"wildcard": map[string]any{
|
||||
q.field: base,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (q *WildcardQuery) MarshalJSON() ([]byte, error) {
|
||||
data, err := q.Map()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(data)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package osu_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/qsfera/server/services/search/pkg/opensearch/internal/osu"
|
||||
"github.com/qsfera/server/services/search/pkg/opensearch/internal/test"
|
||||
)
|
||||
|
||||
func TestWildcardQuery(t *testing.T) {
|
||||
tests := []opensearchtest.TableTest[osu.Builder, map[string]any]{
|
||||
{
|
||||
Name: "empty",
|
||||
Got: osu.NewWildcardQuery("empty"),
|
||||
Want: nil,
|
||||
},
|
||||
{
|
||||
Name: "wildcard",
|
||||
Got: osu.NewWildcardQuery("name").Params(&osu.WildcardQueryParams{
|
||||
Boost: 1.0,
|
||||
CaseInsensitive: true,
|
||||
Rewrite: "top_terms_blended_freqs_N",
|
||||
}).Value("opencl*"),
|
||||
Want: map[string]any{
|
||||
"wildcard": map[string]any{
|
||||
"name": map[string]any{
|
||||
"value": "opencl*",
|
||||
"boost": 1.0,
|
||||
"case_insensitive": true,
|
||||
"rewrite": "top_terms_blended_freqs_N",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.Name, func(t *testing.T) {
|
||||
assert.JSONEq(t, opensearchtest.JSONMustMarshal(t, test.Want), opensearchtest.JSONMustMarshal(t, test.Got))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package osu
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
|
||||
opensearchgoAPI "github.com/opensearch-project/opensearch-go/v4/opensearchapi"
|
||||
)
|
||||
|
||||
type QueryReqBody[P any] struct {
|
||||
query Builder
|
||||
params P
|
||||
}
|
||||
|
||||
func NewQueryReqBody[P any](q Builder, p ...P) *QueryReqBody[P] {
|
||||
return &QueryReqBody[P]{query: q, params: merge(p...)}
|
||||
}
|
||||
|
||||
func (q QueryReqBody[O]) Map() (map[string]any, error) {
|
||||
base, err := newBase(q.params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := applyBuilder(base, "query", q.query); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return base, nil
|
||||
}
|
||||
|
||||
func (q QueryReqBody[O]) MarshalJSON() ([]byte, error) {
|
||||
data, err := q.Map()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return json.Marshal(data)
|
||||
}
|
||||
|
||||
//----------------------------------------------------------------------------//
|
||||
|
||||
type BodyParamHighlight struct {
|
||||
HighlightOptions
|
||||
Fields map[string]HighlightOptions `json:"fields,omitempty"`
|
||||
}
|
||||
|
||||
type HighlightType string
|
||||
|
||||
const (
|
||||
HighlightTypeUnified HighlightType = "unified"
|
||||
HighlightTypeFvh HighlightType = "fvh"
|
||||
HighlightTypePlain HighlightType = "plain"
|
||||
HighlightTypeSemantic HighlightType = "semantic"
|
||||
)
|
||||
|
||||
type HighlightOptions struct {
|
||||
Type HighlightType `json:"type,omitempty"`
|
||||
FragmentSize int `json:"fragment_size,omitempty"`
|
||||
NumberOfFragments int `json:"number_of_fragments,omitempty"`
|
||||
FragmentOffset int `json:"fragment_offset,omitempty"`
|
||||
BoundaryChars string `json:"boundary_chars,omitempty"`
|
||||
BoundaryMaxScan int `json:"boundary_max_scan,omitempty"`
|
||||
BoundaryScanner string `json:"boundary_scanner,omitempty"`
|
||||
BoundaryScannerLocale string `json:"boundary_scanner_locale,omitempty"`
|
||||
Encoder string `json:"encoder,omitempty"`
|
||||
ForceSource bool `json:"force_source,omitempty"`
|
||||
Fragmenter string `json:"fragmenter,omitempty"`
|
||||
HighlightQuery Builder `json:"highlight_query,omitempty"`
|
||||
Order string `json:"order,omitempty"`
|
||||
NoMatchSize int `json:"no_match_size,omitempty"`
|
||||
RequireFieldMatch bool `json:"require_field_match,omitempty"`
|
||||
MatchedFields []string `json:"matched_fields,omitempty"`
|
||||
PhraseLimit int `json:"phrase_limit,omitempty"`
|
||||
PreTags []string `json:"pre_tags,omitempty"`
|
||||
PostTags []string `json:"post_tags,omitempty"`
|
||||
}
|
||||
|
||||
type BodyParamScript struct {
|
||||
Source string `json:"source,omitempty"`
|
||||
Lang string `json:"lang,omitempty"`
|
||||
Params map[string]any `json:"params,omitempty"`
|
||||
}
|
||||
|
||||
//----------------------------------------------------------------------------//
|
||||
|
||||
func BuildSearchReq(req *opensearchgoAPI.SearchReq, q Builder, p ...SearchBodyParams) (*opensearchgoAPI.SearchReq, error) {
|
||||
body, err := json.Marshal(NewQueryReqBody(q, p...))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Body = bytes.NewReader(body)
|
||||
return req, nil
|
||||
}
|
||||
|
||||
type SearchBodyParams struct {
|
||||
Highlight *BodyParamHighlight `json:"highlight,omitempty"`
|
||||
}
|
||||
|
||||
//----------------------------------------------------------------------------//
|
||||
|
||||
func BuildDocumentDeleteByQueryReq(req opensearchgoAPI.DocumentDeleteByQueryReq, q Builder) (opensearchgoAPI.DocumentDeleteByQueryReq, error) {
|
||||
body, err := json.Marshal(NewQueryReqBody[any](q))
|
||||
if err != nil {
|
||||
return req, err
|
||||
}
|
||||
req.Body = bytes.NewReader(body)
|
||||
return req, nil
|
||||
}
|
||||
|
||||
//----------------------------------------------------------------------------//
|
||||
|
||||
func BuildUpdateByQueryReq(req opensearchgoAPI.UpdateByQueryReq, q Builder, o ...UpdateByQueryBodyParams) (opensearchgoAPI.UpdateByQueryReq, error) {
|
||||
body, err := json.Marshal(NewQueryReqBody(q, o...))
|
||||
if err != nil {
|
||||
return req, err
|
||||
}
|
||||
req.Body = bytes.NewReader(body)
|
||||
return req, nil
|
||||
}
|
||||
|
||||
type UpdateByQueryBodyParams struct {
|
||||
Script *BodyParamScript `json:"script,omitempty"`
|
||||
}
|
||||
|
||||
//----------------------------------------------------------------------------//
|
||||
|
||||
func BuildIndicesCountReq(req *opensearchgoAPI.IndicesCountReq, q Builder) (*opensearchgoAPI.IndicesCountReq, error) {
|
||||
body, err := json.Marshal(NewQueryReqBody[any](q))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Body = bytes.NewReader(body)
|
||||
return req, nil
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package osu_test
|
||||
|
||||
import (
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
opensearchgoAPI "github.com/opensearch-project/opensearch-go/v4/opensearchapi"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/qsfera/server/services/search/pkg/opensearch/internal/osu"
|
||||
opensearchtest "github.com/qsfera/server/services/search/pkg/opensearch/internal/test"
|
||||
)
|
||||
|
||||
func TestRequestBody(t *testing.T) {
|
||||
tests := []opensearchtest.TableTest[osu.Builder, map[string]any]{
|
||||
{
|
||||
Name: "simple",
|
||||
Got: osu.NewQueryReqBody[any](osu.NewTermQuery[string]("name").Value("tom")),
|
||||
Want: map[string]any{
|
||||
"query": map[string]any{
|
||||
"term": map[string]any{
|
||||
"name": map[string]any{
|
||||
"value": "tom",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.Name, func(t *testing.T) {
|
||||
assert.JSONEq(t, opensearchtest.JSONMustMarshal(t, test.Want), opensearchtest.JSONMustMarshal(t, test.Got))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSearchReq(t *testing.T) {
|
||||
tests := []opensearchtest.TableTest[io.Reader, map[string]any]{
|
||||
{
|
||||
Name: "highlight",
|
||||
Got: func() io.Reader {
|
||||
req, _ := osu.BuildSearchReq(
|
||||
&opensearchgoAPI.SearchReq{},
|
||||
osu.NewTermQuery[string]("content").Value("content"),
|
||||
osu.SearchBodyParams{
|
||||
Highlight: &osu.BodyParamHighlight{
|
||||
HighlightOptions: osu.HighlightOptions{
|
||||
PreTags: []string{"<b>"},
|
||||
PostTags: []string{"</b>"},
|
||||
},
|
||||
Fields: map[string]osu.HighlightOptions{
|
||||
"content": {
|
||||
PreTags: []string{"<strong>"},
|
||||
PostTags: []string{"</strong>"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
return req.Body
|
||||
}(),
|
||||
Want: map[string]any{
|
||||
"query": map[string]any{
|
||||
"term": map[string]any{
|
||||
"content": map[string]any{
|
||||
"value": "content",
|
||||
},
|
||||
},
|
||||
},
|
||||
"highlight": map[string]any{
|
||||
"pre_tags": []string{"<b>"},
|
||||
"post_tags": []string{"</b>"},
|
||||
"fields": map[string]any{
|
||||
"content": map[string]any{
|
||||
"pre_tags": []string{"<strong>"},
|
||||
"post_tags": []string{"</strong>"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.Name, func(t *testing.T) {
|
||||
body, err := io.ReadAll(test.Got)
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(t, opensearchtest.JSONMustMarshal(t, test.Want), string(body))
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user