From be6dfd55d992375cd1fa029a5961e0d77a92b433 Mon Sep 17 00:00:00 2001 From: ofekshenawa <104765379+ofekshenawa@users.noreply.github.com> Date: Wed, 16 Jul 2025 23:06:47 +0300 Subject: [PATCH] Add search module builders and tests (#1) * Add search module builders and tests * Add tests --- search_builders.go | 759 ++++++++++++++++++++++++++++++++++++++++ search_builders_test.go | 680 +++++++++++++++++++++++++++++++++++ 2 files changed, 1439 insertions(+) create mode 100644 search_builders.go create mode 100644 search_builders_test.go diff --git a/search_builders.go b/search_builders.go new file mode 100644 index 000000000..964b26878 --- /dev/null +++ b/search_builders.go @@ -0,0 +1,759 @@ +package redis + +import ( + "context" +) + +// ---------------------- +// Search Module Builders +// ---------------------- + +// SearchBuilder provides a fluent API for FT.SEARCH +// (see original FTSearchOptions for all options). +type SearchBuilder struct { + c *Client + ctx context.Context + index string + query string + options *FTSearchOptions +} + +// Search starts building an FT.SEARCH command. +func (c *Client) Search(ctx context.Context, index, query string) *SearchBuilder { + b := &SearchBuilder{c: c, ctx: ctx, index: index, query: query, options: &FTSearchOptions{LimitOffset: -1}} + return b +} + +// WithScores includes WITHSCORES. +func (b *SearchBuilder) WithScores() *SearchBuilder { + b.options.WithScores = true + return b +} + +// NoContent includes NOCONTENT. +func (b *SearchBuilder) NoContent() *SearchBuilder { b.options.NoContent = true; return b } + +// Verbatim includes VERBATIM. +func (b *SearchBuilder) Verbatim() *SearchBuilder { b.options.Verbatim = true; return b } + +// NoStopWords includes NOSTOPWORDS. +func (b *SearchBuilder) NoStopWords() *SearchBuilder { b.options.NoStopWords = true; return b } + +// WithPayloads includes WITHPAYLOADS. +func (b *SearchBuilder) WithPayloads() *SearchBuilder { + b.options.WithPayloads = true + return b +} + +// WithSortKeys includes WITHSORTKEYS. +func (b *SearchBuilder) WithSortKeys() *SearchBuilder { + b.options.WithSortKeys = true + return b +} + +// Filter adds a FILTER clause: FILTER . +func (b *SearchBuilder) Filter(field string, min, max interface{}) *SearchBuilder { + b.options.Filters = append(b.options.Filters, FTSearchFilter{ + FieldName: field, + Min: min, + Max: max, + }) + return b +} + +// GeoFilter adds a GEOFILTER clause: GEOFILTER . +func (b *SearchBuilder) GeoFilter(field string, lon, lat, radius float64, unit string) *SearchBuilder { + b.options.GeoFilter = append(b.options.GeoFilter, FTSearchGeoFilter{ + FieldName: field, + Longitude: lon, + Latitude: lat, + Radius: radius, + Unit: unit, + }) + return b +} + +// InKeys restricts the search to the given keys. +func (b *SearchBuilder) InKeys(keys ...interface{}) *SearchBuilder { + b.options.InKeys = append(b.options.InKeys, keys...) + return b +} + +// InFields restricts the search to the given fields. +func (b *SearchBuilder) InFields(fields ...interface{}) *SearchBuilder { + b.options.InFields = append(b.options.InFields, fields...) + return b +} + +// ReturnFields adds simple RETURN ... +func (b *SearchBuilder) ReturnFields(fields ...string) *SearchBuilder { + for _, f := range fields { + b.options.Return = append(b.options.Return, FTSearchReturn{FieldName: f}) + } + return b +} + +// ReturnAs adds RETURN AS . +func (b *SearchBuilder) ReturnAs(field, alias string) *SearchBuilder { + b.options.Return = append(b.options.Return, FTSearchReturn{FieldName: field, As: alias}) + return b +} + +// Slop adds SLOP . +func (b *SearchBuilder) Slop(slop int) *SearchBuilder { + b.options.Slop = slop + return b +} + +// Timeout adds TIMEOUT . +func (b *SearchBuilder) Timeout(timeout int) *SearchBuilder { + b.options.Timeout = timeout + return b +} + +// InOrder includes INORDER. +func (b *SearchBuilder) InOrder() *SearchBuilder { + b.options.InOrder = true + return b +} + +// Language sets LANGUAGE . +func (b *SearchBuilder) Language(lang string) *SearchBuilder { + b.options.Language = lang + return b +} + +// Expander sets EXPANDER . +func (b *SearchBuilder) Expander(expander string) *SearchBuilder { + b.options.Expander = expander + return b +} + +// Scorer sets SCORER . +func (b *SearchBuilder) Scorer(scorer string) *SearchBuilder { + b.options.Scorer = scorer + return b +} + +// ExplainScore includes EXPLAINSCORE. +func (b *SearchBuilder) ExplainScore() *SearchBuilder { + b.options.ExplainScore = true + return b +} + +// Payload sets PAYLOAD . +func (b *SearchBuilder) Payload(payload string) *SearchBuilder { + b.options.Payload = payload + return b +} + +// SortBy adds SORTBY ASC|DESC. +func (b *SearchBuilder) SortBy(field string, asc bool) *SearchBuilder { + b.options.SortBy = append(b.options.SortBy, FTSearchSortBy{ + FieldName: field, + Asc: asc, + Desc: !asc, + }) + return b +} + +// WithSortByCount includes WITHCOUNT (when used with SortBy). +func (b *SearchBuilder) WithSortByCount() *SearchBuilder { + b.options.SortByWithCount = true + return b +} + +// Param adds a single PARAMS . +func (b *SearchBuilder) Param(key string, value interface{}) *SearchBuilder { + if b.options.Params == nil { + b.options.Params = make(map[string]interface{}, 1) + } + b.options.Params[key] = value + return b +} + +// ParamsMap adds multiple PARAMS at once. +func (b *SearchBuilder) ParamsMap(p map[string]interface{}) *SearchBuilder { + if b.options.Params == nil { + b.options.Params = make(map[string]interface{}, len(p)) + } + for k, v := range p { + b.options.Params[k] = v + } + return b +} + +// Dialect sets DIALECT . +func (b *SearchBuilder) Dialect(version int) *SearchBuilder { + b.options.DialectVersion = version + return b +} + +// Limit sets OFFSET and COUNT. CountOnly uses LIMIT 0 0. +func (b *SearchBuilder) Limit(offset, count int) *SearchBuilder { + b.options.LimitOffset = offset + b.options.Limit = count + return b +} +func (b *SearchBuilder) CountOnly() *SearchBuilder { b.options.CountOnly = true; return b } + +// Run executes FT.SEARCH and returns a typed result. +func (b *SearchBuilder) Run() (FTSearchResult, error) { + cmd := b.c.FTSearchWithArgs(b.ctx, b.index, b.query, b.options) + return cmd.Result() +} + +// ---------------------- +// AggregateBuilder for FT.AGGREGATE +// ---------------------- + +type AggregateBuilder struct { + c *Client + ctx context.Context + index string + query string + options *FTAggregateOptions +} + +// Aggregate starts building an FT.AGGREGATE command. +func (c *Client) Aggregate(ctx context.Context, index, query string) *AggregateBuilder { + return &AggregateBuilder{c: c, ctx: ctx, index: index, query: query, options: &FTAggregateOptions{LimitOffset: -1}} +} + +// Verbatim includes VERBATIM. +func (b *AggregateBuilder) Verbatim() *AggregateBuilder { b.options.Verbatim = true; return b } + +// AddScores includes ADDSCORES. +func (b *AggregateBuilder) AddScores() *AggregateBuilder { b.options.AddScores = true; return b } + +// Scorer sets SCORER . +func (b *AggregateBuilder) Scorer(s string) *AggregateBuilder { + b.options.Scorer = s + return b +} + +// LoadAll includes LOAD * (mutually exclusive with Load). +func (b *AggregateBuilder) LoadAll() *AggregateBuilder { + b.options.LoadAll = true + return b +} + +// Load adds LOAD [AS alias]... +// You can call it multiple times for multiple fields. +func (b *AggregateBuilder) Load(field string, alias ...string) *AggregateBuilder { + // each Load entry becomes one element in options.Load + l := FTAggregateLoad{Field: field} + if len(alias) > 0 { + l.As = alias[0] + } + b.options.Load = append(b.options.Load, l) + return b +} + +// Timeout sets TIMEOUT . +func (b *AggregateBuilder) Timeout(ms int) *AggregateBuilder { + b.options.Timeout = ms + return b +} + +// Apply adds APPLY [AS alias]. +func (b *AggregateBuilder) Apply(field string, alias ...string) *AggregateBuilder { + a := FTAggregateApply{Field: field} + if len(alias) > 0 { + a.As = alias[0] + } + b.options.Apply = append(b.options.Apply, a) + return b +} + +// GroupBy starts a new GROUPBY clause. +func (b *AggregateBuilder) GroupBy(fields ...interface{}) *AggregateBuilder { + b.options.GroupBy = append(b.options.GroupBy, FTAggregateGroupBy{ + Fields: fields, + }) + return b +} + +// Reduce adds a REDUCE [<#args> ] clause to the *last* GROUPBY. +func (b *AggregateBuilder) Reduce(fn SearchAggregator, args ...interface{}) *AggregateBuilder { + if len(b.options.GroupBy) == 0 { + // no GROUPBY yet — nothing to attach to + return b + } + idx := len(b.options.GroupBy) - 1 + b.options.GroupBy[idx].Reduce = append(b.options.GroupBy[idx].Reduce, FTAggregateReducer{ + Reducer: fn, + Args: args, + }) + return b +} + +// ReduceAs does the same but also sets an alias: REDUCE … AS +func (b *AggregateBuilder) ReduceAs(fn SearchAggregator, alias string, args ...interface{}) *AggregateBuilder { + if len(b.options.GroupBy) == 0 { + return b + } + idx := len(b.options.GroupBy) - 1 + b.options.GroupBy[idx].Reduce = append(b.options.GroupBy[idx].Reduce, FTAggregateReducer{ + Reducer: fn, + Args: args, + As: alias, + }) + return b +} + +// SortBy adds SORTBY ASC|DESC. +func (b *AggregateBuilder) SortBy(field string, asc bool) *AggregateBuilder { + sb := FTAggregateSortBy{FieldName: field, Asc: asc, Desc: !asc} + b.options.SortBy = append(b.options.SortBy, sb) + return b +} + +// SortByMax sets MAX (only if SortBy was called). +func (b *AggregateBuilder) SortByMax(max int) *AggregateBuilder { + b.options.SortByMax = max + return b +} + +// Filter sets FILTER . +func (b *AggregateBuilder) Filter(expr string) *AggregateBuilder { + b.options.Filter = expr + return b +} + +// WithCursor enables WITHCURSOR [COUNT ] [MAXIDLE ]. +func (b *AggregateBuilder) WithCursor(count, maxIdle int) *AggregateBuilder { + b.options.WithCursor = true + if b.options.WithCursorOptions == nil { + b.options.WithCursorOptions = &FTAggregateWithCursor{} + } + b.options.WithCursorOptions.Count = count + b.options.WithCursorOptions.MaxIdle = maxIdle + return b +} + +// Params adds PARAMS pairs. +func (b *AggregateBuilder) Params(p map[string]interface{}) *AggregateBuilder { + if b.options.Params == nil { + b.options.Params = make(map[string]interface{}, len(p)) + } + for k, v := range p { + b.options.Params[k] = v + } + return b +} + +// Dialect sets DIALECT . +func (b *AggregateBuilder) Dialect(version int) *AggregateBuilder { + b.options.DialectVersion = version + return b +} + +// Run executes FT.AGGREGATE and returns a typed result. +func (b *AggregateBuilder) Run() (*FTAggregateResult, error) { + cmd := b.c.FTAggregateWithArgs(b.ctx, b.index, b.query, b.options) + return cmd.Result() +} + +// ---------------------- +// CreateIndexBuilder for FT.CREATE +// ---------------------- + +type CreateIndexBuilder struct { + c *Client + ctx context.Context + index string + options *FTCreateOptions + schema []*FieldSchema +} + +// CreateIndex starts building an FT.CREATE command. +func (c *Client) CreateIndex(ctx context.Context, index string) *CreateIndexBuilder { + return &CreateIndexBuilder{c: c, ctx: ctx, index: index, options: &FTCreateOptions{}} +} + +// OnHash sets ON HASH. +func (b *CreateIndexBuilder) OnHash() *CreateIndexBuilder { b.options.OnHash = true; return b } + +// OnJSON sets ON JSON. +func (b *CreateIndexBuilder) OnJSON() *CreateIndexBuilder { b.options.OnJSON = true; return b } + +// Prefix sets PREFIX. +func (b *CreateIndexBuilder) Prefix(prefixes ...interface{}) *CreateIndexBuilder { + b.options.Prefix = prefixes + return b +} + +// Filter sets FILTER. +func (b *CreateIndexBuilder) Filter(filter string) *CreateIndexBuilder { + b.options.Filter = filter + return b +} + +// DefaultLanguage sets LANGUAGE. +func (b *CreateIndexBuilder) DefaultLanguage(lang string) *CreateIndexBuilder { + b.options.DefaultLanguage = lang + return b +} + +// LanguageField sets LANGUAGE_FIELD. +func (b *CreateIndexBuilder) LanguageField(field string) *CreateIndexBuilder { + b.options.LanguageField = field + return b +} + +// Score sets SCORE. +func (b *CreateIndexBuilder) Score(score float64) *CreateIndexBuilder { + b.options.Score = score + return b +} + +// ScoreField sets SCORE_FIELD. +func (b *CreateIndexBuilder) ScoreField(field string) *CreateIndexBuilder { + b.options.ScoreField = field + return b +} + +// PayloadField sets PAYLOAD_FIELD. +func (b *CreateIndexBuilder) PayloadField(field string) *CreateIndexBuilder { + b.options.PayloadField = field + return b +} + +// NoOffsets includes NOOFFSETS. +func (b *CreateIndexBuilder) NoOffsets() *CreateIndexBuilder { b.options.NoOffsets = true; return b } + +// Temporary sets TEMPORARY seconds. +func (b *CreateIndexBuilder) Temporary(sec int) *CreateIndexBuilder { + b.options.Temporary = sec + return b +} + +// NoHL includes NOHL. +func (b *CreateIndexBuilder) NoHL() *CreateIndexBuilder { b.options.NoHL = true; return b } + +// NoFields includes NOFIELDS. +func (b *CreateIndexBuilder) NoFields() *CreateIndexBuilder { b.options.NoFields = true; return b } + +// NoFreqs includes NOFREQS. +func (b *CreateIndexBuilder) NoFreqs() *CreateIndexBuilder { b.options.NoFreqs = true; return b } + +// StopWords sets STOPWORDS. +func (b *CreateIndexBuilder) StopWords(words ...interface{}) *CreateIndexBuilder { + b.options.StopWords = words + return b +} + +// SkipInitialScan includes SKIPINITIALSCAN. +func (b *CreateIndexBuilder) SkipInitialScan() *CreateIndexBuilder { + b.options.SkipInitialScan = true + return b +} + +// Schema adds a FieldSchema. +func (b *CreateIndexBuilder) Schema(field *FieldSchema) *CreateIndexBuilder { + b.schema = append(b.schema, field) + return b +} + +// Run executes FT.CREATE and returns the status. +func (b *CreateIndexBuilder) Run() (string, error) { + cmd := b.c.FTCreate(b.ctx, b.index, b.options, b.schema...) + return cmd.Result() +} + +// ---------------------- +// DropIndexBuilder for FT.DROPINDEX +// ---------------------- + +type DropIndexBuilder struct { + c *Client + ctx context.Context + index string + options *FTDropIndexOptions +} + +// DropIndex starts FT.DROPINDEX builder. +func (c *Client) DropIndex(ctx context.Context, index string) *DropIndexBuilder { + return &DropIndexBuilder{c: c, ctx: ctx, index: index} +} + +// DeleteRuncs includes DD. +func (b *DropIndexBuilder) DeleteDocs() *DropIndexBuilder { b.options.DeleteDocs = true; return b } + +// Run executes FT.DROPINDEX. +func (b *DropIndexBuilder) Run() (string, error) { + cmd := b.c.FTDropIndexWithArgs(b.ctx, b.index, b.options) + return cmd.Result() +} + +// ---------------------- +// AliasBuilder for FT.ALIAS* commands +// ---------------------- + +type AliasBuilder struct { + c *Client + ctx context.Context + alias string + index string + action string // add|del|update +} + +// AliasAdd starts FT.ALIASADD builder. +func (c *Client) AliasAdd(ctx context.Context, alias, index string) *AliasBuilder { + return &AliasBuilder{c: c, ctx: ctx, alias: alias, index: index, action: "add"} +} + +// AliasDel starts FT.ALIASDEL builder. +func (c *Client) AliasDel(ctx context.Context, alias string) *AliasBuilder { + return &AliasBuilder{c: c, ctx: ctx, alias: alias, action: "del"} +} + +// AliasUpdate starts FT.ALIASUPDATE builder. +func (c *Client) AliasUpdate(ctx context.Context, alias, index string) *AliasBuilder { + return &AliasBuilder{c: c, ctx: ctx, alias: alias, index: index, action: "update"} +} + +// Run executes the configured alias command. +func (b *AliasBuilder) Run() (string, error) { + switch b.action { + case "add": + cmd := b.c.FTAliasAdd(b.ctx, b.index, b.alias) + return cmd.Result() + case "del": + cmd := b.c.FTAliasDel(b.ctx, b.alias) + return cmd.Result() + case "update": + cmd := b.c.FTAliasUpdate(b.ctx, b.index, b.alias) + return cmd.Result() + } + return "", nil +} + +// ---------------------- +// ExplainBuilder for FT.EXPLAIN +// ---------------------- + +type ExplainBuilder struct { + c *Client + ctx context.Context + index string + query string + options *FTExplainOptions +} + +// Explain starts FT.EXPLAIN builder. +func (c *Client) Explain(ctx context.Context, index, query string) *ExplainBuilder { + return &ExplainBuilder{c: c, ctx: ctx, index: index, query: query, options: &FTExplainOptions{}} +} + +// Dialect sets dialect for EXPLAINCLI. +func (b *ExplainBuilder) Dialect(d string) *ExplainBuilder { b.options.Dialect = d; return b } + +// Run executes FT.EXPLAIN and returns the plan. +func (b *ExplainBuilder) Run() (string, error) { + cmd := b.c.FTExplainWithArgs(b.ctx, b.index, b.query, b.options) + return cmd.Result() +} + +// ---------------------- +// InfoBuilder for FT.INFO +// ---------------------- + +type FTInfoBuilder struct { + c *Client + ctx context.Context + index string +} + +// SearchInfo starts building an FT.INFO command for RediSearch. +func (c *Client) SearchInfo(ctx context.Context, index string) *FTInfoBuilder { + return &FTInfoBuilder{c: c, ctx: ctx, index: index} +} + +// Run executes FT.INFO and returns detailed info. +func (b *FTInfoBuilder) Run() (FTInfoResult, error) { + cmd := b.c.FTInfo(b.ctx, b.index) + return cmd.Result() +} + +// ---------------------- +// SpellCheckBuilder for FT.SPELLCHECK +// ---------------------- + +type SpellCheckBuilder struct { + c *Client + ctx context.Context + index string + query string + options *FTSpellCheckOptions +} + +// SpellCheck starts FT.SPELLCHECK builder. +func (c *Client) SpellCheck(ctx context.Context, index, query string) *SpellCheckBuilder { + return &SpellCheckBuilder{c: c, ctx: ctx, index: index, query: query, options: &FTSpellCheckOptions{}} +} + +// Distance sets MAXDISTANCE. +func (b *SpellCheckBuilder) Distance(d int) *SpellCheckBuilder { b.options.Distance = d; return b } + +// Terms sets INCLUDE or EXCLUDE terms. +func (b *SpellCheckBuilder) Terms(include bool, dictionary string, terms ...interface{}) *SpellCheckBuilder { + if b.options.Terms == nil { + b.options.Terms = &FTSpellCheckTerms{} + } + if include { + b.options.Terms.Inclusion = "INCLUDE" + } else { + b.options.Terms.Inclusion = "EXCLUDE" + } + b.options.Terms.Dictionary = dictionary + b.options.Terms.Terms = terms + return b +} + +// Dialect sets dialect version. +func (b *SpellCheckBuilder) Dialect(d int) *SpellCheckBuilder { b.options.Dialect = d; return b } + +// Run executes FT.SPELLCHECK and returns suggestions. +func (b *SpellCheckBuilder) Run() ([]SpellCheckResult, error) { + cmd := b.c.FTSpellCheckWithArgs(b.ctx, b.index, b.query, b.options) + return cmd.Result() +} + +// ---------------------- +// DictBuilder for FT.DICT* commands +// ---------------------- + +type DictBuilder struct { + c *Client + ctx context.Context + dict string + terms []interface{} + action string // add|del|dump +} + +// DictAdd starts FT.DICTADD builder. +func (c *Client) DictAdd(ctx context.Context, dict string, terms ...interface{}) *DictBuilder { + return &DictBuilder{c: c, ctx: ctx, dict: dict, terms: terms, action: "add"} +} + +// DictDel starts FT.DICTDEL builder. +func (c *Client) DictDel(ctx context.Context, dict string, terms ...interface{}) *DictBuilder { + return &DictBuilder{c: c, ctx: ctx, dict: dict, terms: terms, action: "del"} +} + +// DictDump starts FT.DICTDUMP builder. +func (c *Client) DictDump(ctx context.Context, dict string) *DictBuilder { + return &DictBuilder{c: c, ctx: ctx, dict: dict, action: "dump"} +} + +// Run executes the configured dictionary command. +func (b *DictBuilder) Run() (interface{}, error) { + switch b.action { + case "add": + cmd := b.c.FTDictAdd(b.ctx, b.dict, b.terms...) + return cmd.Result() + case "del": + cmd := b.c.FTDictDel(b.ctx, b.dict, b.terms...) + return cmd.Result() + case "dump": + cmd := b.c.FTDictDump(b.ctx, b.dict) + return cmd.Result() + } + return nil, nil +} + +// ---------------------- +// TagValsBuilder for FT.TAGVALS +// ---------------------- + +type TagValsBuilder struct { + c *Client + ctx context.Context + index string + field string +} + +// TagVals starts FT.TAGVALS builder. +func (c *Client) TagVals(ctx context.Context, index, field string) *TagValsBuilder { + return &TagValsBuilder{c: c, ctx: ctx, index: index, field: field} +} + +// Run executes FT.TAGVALS and returns tag values. +func (b *TagValsBuilder) Run() ([]string, error) { + cmd := b.c.FTTagVals(b.ctx, b.index, b.field) + return cmd.Result() +} + +// ---------------------- +// CursorBuilder for FT.CURSOR* +// ---------------------- + +type CursorBuilder struct { + c *Client + ctx context.Context + index string + cursorId int64 + count int + action string // read|del +} + +// CursorRead starts FT.CURSOR READ builder. +func (c *Client) CursorRead(ctx context.Context, index string, cursorId int64) *CursorBuilder { + return &CursorBuilder{c: c, ctx: ctx, index: index, cursorId: cursorId, action: "read"} +} + +// CursorDel starts FT.CURSOR DEL builder. +func (c *Client) CursorDel(ctx context.Context, index string, cursorId int64) *CursorBuilder { + return &CursorBuilder{c: c, ctx: ctx, index: index, cursorId: cursorId, action: "del"} +} + +// Count for READ. +func (b *CursorBuilder) Count(count int) *CursorBuilder { b.count = count; return b } + +// Run executes the cursor command. +func (b *CursorBuilder) Run() (interface{}, error) { + switch b.action { + case "read": + cmd := b.c.FTCursorRead(b.ctx, b.index, int(b.cursorId), b.count) + return cmd.Result() + case "del": + cmd := b.c.FTCursorDel(b.ctx, b.index, int(b.cursorId)) + return cmd.Result() + } + return nil, nil +} + +// ---------------------- +// SynUpdateBuilder for FT.SYNUPDATE +// ---------------------- + +type SynUpdateBuilder struct { + c *Client + ctx context.Context + index string + groupId interface{} + options *FTSynUpdateOptions + terms []interface{} +} + +// SynUpdate starts FT.SYNUPDATE builder. +func (c *Client) SynUpdate(ctx context.Context, index string, groupId interface{}) *SynUpdateBuilder { + return &SynUpdateBuilder{c: c, ctx: ctx, index: index, groupId: groupId, options: &FTSynUpdateOptions{}} +} + +// SkipInitialScan includes SKIPINITIALSCAN. +func (b *SynUpdateBuilder) SkipInitialScan() *SynUpdateBuilder { + b.options.SkipInitialScan = true + return b +} + +// Terms adds synonyms to the group. +func (b *SynUpdateBuilder) Terms(terms ...interface{}) *SynUpdateBuilder { b.terms = terms; return b } + +// Run executes FT.SYNUPDATE. +func (b *SynUpdateBuilder) Run() (string, error) { + cmd := b.c.FTSynUpdateWithArgs(b.ctx, b.index, b.groupId, b.options, b.terms) + return cmd.Result() +} diff --git a/search_builders_test.go b/search_builders_test.go new file mode 100644 index 000000000..0fedf83a9 --- /dev/null +++ b/search_builders_test.go @@ -0,0 +1,680 @@ +package redis_test + +import ( + "context" + "fmt" + + . "github.com/bsm/ginkgo/v2" + . "github.com/bsm/gomega" + "github.com/redis/go-redis/v9" +) + +var _ = Describe("RediSearch Builders", Label("search", "builders"), func() { + ctx := context.Background() + var client *redis.Client + + BeforeEach(func() { + client = redis.NewClient(&redis.Options{Addr: ":6379", Protocol: 2}) + Expect(client.FlushDB(ctx).Err()).NotTo(HaveOccurred()) + }) + + AfterEach(func() { + expectCloseErr := client.Close() + Expect(expectCloseErr).NotTo(HaveOccurred()) + }) + + It("should create index and search with scores using builders", Label("search", "ftcreate", "ftsearch"), func() { + createVal, err := client.CreateIndex(ctx, "idx1"). + OnHash(). + Schema(&redis.FieldSchema{FieldName: "foo", FieldType: redis.SearchFieldTypeText}). + Run() + Expect(err).NotTo(HaveOccurred()) + Expect(createVal).To(Equal("OK")) + + WaitForIndexing(client, "idx1") + + client.HSet(ctx, "doc1", "foo", "hello world") + client.HSet(ctx, "doc2", "foo", "hello redis") + + res, err := client.Search(ctx, "idx1", "hello").WithScores().Run() + Expect(err).NotTo(HaveOccurred()) + Expect(res.Total).To(Equal(2)) + for _, doc := range res.Docs { + Expect(*doc.Score).To(BeNumerically(">", 0)) + } + }) + + It("should aggregate using builders", Label("search", "ftaggregate"), func() { + _, err := client.CreateIndex(ctx, "idx2"). + OnHash(). + Schema(&redis.FieldSchema{FieldName: "n", FieldType: redis.SearchFieldTypeNumeric}). + Run() + Expect(err).NotTo(HaveOccurred()) + WaitForIndexing(client, "idx2") + + client.HSet(ctx, "d1", "n", 1) + client.HSet(ctx, "d2", "n", 2) + + agg, err := client.Aggregate(ctx, "idx2", "*"). + GroupBy("@n"). + ReduceAs(redis.SearchCount, "count"). + Run() + Expect(err).NotTo(HaveOccurred()) + Expect(len(agg.Rows)).To(Equal(2)) + }) + + It("should drop index using builder", Label("search", "ftdropindex"), func() { + Expect(client.CreateIndex(ctx, "idx3"). + OnHash(). + Schema(&redis.FieldSchema{FieldName: "x", FieldType: redis.SearchFieldTypeText}). + Run()).To(Equal("OK")) + WaitForIndexing(client, "idx3") + + dropVal, err := client.DropIndex(ctx, "idx3").Run() + Expect(err).NotTo(HaveOccurred()) + Expect(dropVal).To(Equal("OK")) + }) + + It("should manage aliases using builder", Label("search", "ftalias"), func() { + Expect(client.CreateIndex(ctx, "idx4"). + OnHash(). + Schema(&redis.FieldSchema{FieldName: "t", FieldType: redis.SearchFieldTypeText}). + Run()).To(Equal("OK")) + WaitForIndexing(client, "idx4") + + addVal, err := client.AliasAdd(ctx, "alias1", "idx4").Run() + Expect(err).NotTo(HaveOccurred()) + Expect(addVal).To(Equal("OK")) + + _, err = client.Search(ctx, "alias1", "*").Run() + Expect(err).NotTo(HaveOccurred()) + + delVal, err := client.AliasDel(ctx, "alias1").Run() + Expect(err).NotTo(HaveOccurred()) + Expect(delVal).To(Equal("OK")) + }) + + It("should explain query using ExplainBuilder", Label("search", "builders", "ftexplain"), func() { + createVal, err := client.CreateIndex(ctx, "idx_explain"). + OnHash(). + Schema(&redis.FieldSchema{FieldName: "foo", FieldType: redis.SearchFieldTypeText}). + Run() + Expect(err).NotTo(HaveOccurred()) + Expect(createVal).To(Equal("OK")) + WaitForIndexing(client, "idx_explain") + + expl, err := client.Explain(ctx, "idx_explain", "foo").Run() + Expect(err).NotTo(HaveOccurred()) + Expect(expl).To(ContainSubstring("UNION")) + }) + + It("should retrieve info using SearchInfo builder", Label("search", "builders", "ftinfo"), func() { + createVal, err := client.CreateIndex(ctx, "idx_info"). + OnHash(). + Schema(&redis.FieldSchema{FieldName: "foo", FieldType: redis.SearchFieldTypeText}). + Run() + Expect(err).NotTo(HaveOccurred()) + Expect(createVal).To(Equal("OK")) + WaitForIndexing(client, "idx_info") + + i, err := client.SearchInfo(ctx, "idx_info").Run() + Expect(err).NotTo(HaveOccurred()) + Expect(i.IndexName).To(Equal("idx_info")) + }) + + It("should spellcheck using builder", Label("search", "builders", "ftspellcheck"), func() { + createVal, err := client.CreateIndex(ctx, "idx_spell"). + OnHash(). + Schema(&redis.FieldSchema{FieldName: "foo", FieldType: redis.SearchFieldTypeText}). + Run() + Expect(err).NotTo(HaveOccurred()) + Expect(createVal).To(Equal("OK")) + WaitForIndexing(client, "idx_spell") + + client.HSet(ctx, "doc1", "foo", "bar") + + _, err = client.SpellCheck(ctx, "idx_spell", "ba").Distance(1).Run() + Expect(err).NotTo(HaveOccurred()) + }) + + It("should manage dictionary using DictBuilder", Label("search", "ftdict"), func() { + addCount, err := client.DictAdd(ctx, "dict1", "a", "b").Run() + Expect(err).NotTo(HaveOccurred()) + Expect(addCount).To(Equal(int64(2))) + + dump, err := client.DictDump(ctx, "dict1").Run() + Expect(err).NotTo(HaveOccurred()) + Expect(dump).To(ContainElements("a", "b")) + + delCount, err := client.DictDel(ctx, "dict1", "a").Run() + Expect(err).NotTo(HaveOccurred()) + Expect(delCount).To(Equal(int64(1))) + }) + + It("should tag values using TagValsBuilder", Label("search", "builders", "fttagvals"), func() { + createVal, err := client.CreateIndex(ctx, "idx_tag"). + OnHash(). + Schema(&redis.FieldSchema{FieldName: "tags", FieldType: redis.SearchFieldTypeTag}). + Run() + Expect(err).NotTo(HaveOccurred()) + Expect(createVal).To(Equal("OK")) + WaitForIndexing(client, "idx_tag") + + client.HSet(ctx, "doc1", "tags", "red,blue") + client.HSet(ctx, "doc2", "tags", "green,blue") + + vals, err := client.TagVals(ctx, "idx_tag", "tags").Run() + Expect(err).NotTo(HaveOccurred()) + Expect(vals).To(BeAssignableToTypeOf([]string{})) + }) + + It("should cursor read and delete using CursorBuilder", Label("search", "builders", "ftcursor"), func() { + Expect(client.CreateIndex(ctx, "idx5"). + OnHash(). + Schema(&redis.FieldSchema{FieldName: "f", FieldType: redis.SearchFieldTypeText}). + Run()).To(Equal("OK")) + WaitForIndexing(client, "idx5") + client.HSet(ctx, "doc1", "f", "hello") + client.HSet(ctx, "doc2", "f", "world") + + cursorBuilder := client.CursorRead(ctx, "idx5", 1) + Expect(cursorBuilder).NotTo(BeNil()) + + cursorBuilder = cursorBuilder.Count(10) + Expect(cursorBuilder).NotTo(BeNil()) + + delBuilder := client.CursorDel(ctx, "idx5", 1) + Expect(delBuilder).NotTo(BeNil()) + }) + + It("should update synonyms using SynUpdateBuilder", Label("search", "builders", "ftsynupdate"), func() { + createVal, err := client.CreateIndex(ctx, "idx_syn"). + OnHash(). + Schema(&redis.FieldSchema{FieldName: "foo", FieldType: redis.SearchFieldTypeText}). + Run() + Expect(err).NotTo(HaveOccurred()) + Expect(createVal).To(Equal("OK")) + WaitForIndexing(client, "idx_syn") + + syn, err := client.SynUpdate(ctx, "idx_syn", "grp1").Terms("a", "b").Run() + Expect(err).NotTo(HaveOccurred()) + Expect(syn).To(Equal("OK")) + }) + + It("should test SearchBuilder with NoContent and Verbatim", Label("search", "ftsearch", "builders"), func() { + createVal, err := client.CreateIndex(ctx, "idx_nocontent"). + OnHash(). + Schema(&redis.FieldSchema{FieldName: "title", FieldType: redis.SearchFieldTypeText, Weight: 5}). + Schema(&redis.FieldSchema{FieldName: "body", FieldType: redis.SearchFieldTypeText}). + Run() + Expect(err).NotTo(HaveOccurred()) + Expect(createVal).To(Equal("OK")) + WaitForIndexing(client, "idx_nocontent") + + client.HSet(ctx, "doc1", "title", "RediSearch", "body", "Redisearch implements a search engine on top of redis") + + res, err := client.Search(ctx, "idx_nocontent", "search engine"). + NoContent(). + Verbatim(). + Limit(0, 5). + Run() + Expect(err).NotTo(HaveOccurred()) + Expect(res.Total).To(Equal(1)) + Expect(res.Docs[0].ID).To(Equal("doc1")) + // NoContent means no fields should be returned + Expect(res.Docs[0].Fields).To(BeEmpty()) + }) + + It("should test SearchBuilder with NoStopWords", Label("search", "ftsearch", "builders"), func() { + createVal, err := client.CreateIndex(ctx, "idx_nostop"). + OnHash(). + Schema(&redis.FieldSchema{FieldName: "txt", FieldType: redis.SearchFieldTypeText}). + Run() + Expect(err).NotTo(HaveOccurred()) + Expect(createVal).To(Equal("OK")) + WaitForIndexing(client, "idx_nostop") + + client.HSet(ctx, "doc1", "txt", "hello world") + client.HSet(ctx, "doc2", "txt", "test document") + + // Test that NoStopWords method can be called and search works + res, err := client.Search(ctx, "idx_nostop", "hello").NoContent().NoStopWords().Run() + Expect(err).NotTo(HaveOccurred()) + Expect(res.Total).To(Equal(1)) + }) + + It("should test SearchBuilder with filters", Label("search", "ftsearch", "builders"), func() { + createVal, err := client.CreateIndex(ctx, "idx_filters"). + OnHash(). + Schema(&redis.FieldSchema{FieldName: "txt", FieldType: redis.SearchFieldTypeText}). + Schema(&redis.FieldSchema{FieldName: "num", FieldType: redis.SearchFieldTypeNumeric}). + Schema(&redis.FieldSchema{FieldName: "loc", FieldType: redis.SearchFieldTypeGeo}). + Run() + Expect(err).NotTo(HaveOccurred()) + Expect(createVal).To(Equal("OK")) + WaitForIndexing(client, "idx_filters") + + client.HSet(ctx, "doc1", "txt", "foo bar", "num", 3.141, "loc", "-0.441,51.458") + client.HSet(ctx, "doc2", "txt", "foo baz", "num", 2, "loc", "-0.1,51.2") + + // Test numeric filter + res1, err := client.Search(ctx, "idx_filters", "foo"). + Filter("num", 2, 4). + NoContent(). + Run() + Expect(err).NotTo(HaveOccurred()) + Expect(res1.Total).To(Equal(2)) + + // Test geo filter + res2, err := client.Search(ctx, "idx_filters", "foo"). + GeoFilter("loc", -0.44, 51.45, 10, "km"). + NoContent(). + Run() + Expect(err).NotTo(HaveOccurred()) + Expect(res2.Total).To(Equal(1)) + }) + + It("should test SearchBuilder with sorting", Label("search", "ftsearch", "builders"), func() { + createVal, err := client.CreateIndex(ctx, "idx_sort"). + OnHash(). + Schema(&redis.FieldSchema{FieldName: "txt", FieldType: redis.SearchFieldTypeText}). + Schema(&redis.FieldSchema{FieldName: "num", FieldType: redis.SearchFieldTypeNumeric, Sortable: true}). + Run() + Expect(err).NotTo(HaveOccurred()) + Expect(createVal).To(Equal("OK")) + WaitForIndexing(client, "idx_sort") + + client.HSet(ctx, "doc1", "txt", "foo bar", "num", 1) + client.HSet(ctx, "doc2", "txt", "foo baz", "num", 2) + client.HSet(ctx, "doc3", "txt", "foo qux", "num", 3) + + // Test ascending sort + res1, err := client.Search(ctx, "idx_sort", "foo"). + SortBy("num", true). + NoContent(). + Run() + Expect(err).NotTo(HaveOccurred()) + Expect(res1.Total).To(Equal(3)) + Expect(res1.Docs[0].ID).To(Equal("doc1")) + Expect(res1.Docs[1].ID).To(Equal("doc2")) + Expect(res1.Docs[2].ID).To(Equal("doc3")) + + // Test descending sort + res2, err := client.Search(ctx, "idx_sort", "foo"). + SortBy("num", false). + NoContent(). + Run() + Expect(err).NotTo(HaveOccurred()) + Expect(res2.Total).To(Equal(3)) + Expect(res2.Docs[0].ID).To(Equal("doc3")) + Expect(res2.Docs[1].ID).To(Equal("doc2")) + Expect(res2.Docs[2].ID).To(Equal("doc1")) + }) + + It("should test SearchBuilder with InKeys and InFields", Label("search", "ftsearch", "builders"), func() { + createVal, err := client.CreateIndex(ctx, "idx_in"). + OnHash(). + Schema(&redis.FieldSchema{FieldName: "title", FieldType: redis.SearchFieldTypeText}). + Schema(&redis.FieldSchema{FieldName: "body", FieldType: redis.SearchFieldTypeText}). + Run() + Expect(err).NotTo(HaveOccurred()) + Expect(createVal).To(Equal("OK")) + WaitForIndexing(client, "idx_in") + + client.HSet(ctx, "doc1", "title", "hello world", "body", "lorem ipsum") + client.HSet(ctx, "doc2", "title", "foo bar", "body", "hello world") + client.HSet(ctx, "doc3", "title", "baz qux", "body", "dolor sit") + + // Test InKeys + res1, err := client.Search(ctx, "idx_in", "hello"). + InKeys("doc1", "doc2"). + NoContent(). + Run() + Expect(err).NotTo(HaveOccurred()) + Expect(res1.Total).To(Equal(2)) + + // Test InFields + res2, err := client.Search(ctx, "idx_in", "hello"). + InFields("title"). + NoContent(). + Run() + Expect(err).NotTo(HaveOccurred()) + Expect(res2.Total).To(Equal(1)) + Expect(res2.Docs[0].ID).To(Equal("doc1")) + }) + + It("should test SearchBuilder with Return fields", Label("search", "ftsearch", "builders"), func() { + createVal, err := client.CreateIndex(ctx, "idx_return"). + OnHash(). + Schema(&redis.FieldSchema{FieldName: "title", FieldType: redis.SearchFieldTypeText}). + Schema(&redis.FieldSchema{FieldName: "body", FieldType: redis.SearchFieldTypeText}). + Schema(&redis.FieldSchema{FieldName: "num", FieldType: redis.SearchFieldTypeNumeric}). + Run() + Expect(err).NotTo(HaveOccurred()) + Expect(createVal).To(Equal("OK")) + WaitForIndexing(client, "idx_return") + + client.HSet(ctx, "doc1", "title", "hello", "body", "world", "num", 42) + + // Test ReturnFields + res1, err := client.Search(ctx, "idx_return", "hello"). + ReturnFields("title", "num"). + Run() + Expect(err).NotTo(HaveOccurred()) + Expect(res1.Total).To(Equal(1)) + Expect(res1.Docs[0].Fields).To(HaveKey("title")) + Expect(res1.Docs[0].Fields).To(HaveKey("num")) + Expect(res1.Docs[0].Fields).NotTo(HaveKey("body")) + + // Test ReturnAs + res2, err := client.Search(ctx, "idx_return", "hello"). + ReturnAs("title", "doc_title"). + Run() + Expect(err).NotTo(HaveOccurred()) + Expect(res2.Total).To(Equal(1)) + Expect(res2.Docs[0].Fields).To(HaveKey("doc_title")) + Expect(res2.Docs[0].Fields).NotTo(HaveKey("title")) + }) + + It("should test SearchBuilder with advanced options", Label("search", "ftsearch", "builders"), func() { + createVal, err := client.CreateIndex(ctx, "idx_advanced"). + OnHash(). + Schema(&redis.FieldSchema{FieldName: "description", FieldType: redis.SearchFieldTypeText}). + Run() + Expect(err).NotTo(HaveOccurred()) + Expect(createVal).To(Equal("OK")) + WaitForIndexing(client, "idx_advanced") + + client.HSet(ctx, "doc1", "description", "The quick brown fox jumps over the lazy dog") + client.HSet(ctx, "doc2", "description", "Quick alice was beginning to get very tired of sitting by her quick sister on the bank") + + // Test with scores and different scorers + res1, err := client.Search(ctx, "idx_advanced", "quick"). + WithScores(). + Scorer("TFIDF"). + Run() + Expect(err).NotTo(HaveOccurred()) + Expect(res1.Total).To(Equal(2)) + for _, doc := range res1.Docs { + Expect(*doc.Score).To(BeNumerically(">", 0)) + } + + res2, err := client.Search(ctx, "idx_advanced", "quick"). + WithScores(). + Payload("test_payload"). + NoContent(). + Run() + Expect(err).NotTo(HaveOccurred()) + Expect(res2.Total).To(Equal(2)) + + // Test with Slop and InOrder + res3, err := client.Search(ctx, "idx_advanced", "quick brown"). + Slop(1). + InOrder(). + NoContent(). + Run() + Expect(err).NotTo(HaveOccurred()) + Expect(res3.Total).To(Equal(1)) + + // Test with Language and Expander + res4, err := client.Search(ctx, "idx_advanced", "quick"). + Language("english"). + Expander("SYNONYM"). + NoContent(). + Run() + Expect(err).NotTo(HaveOccurred()) + Expect(res4.Total).To(BeNumerically(">=", 0)) + + // Test with Timeout + res5, err := client.Search(ctx, "idx_advanced", "quick"). + Timeout(1000). + NoContent(). + Run() + Expect(err).NotTo(HaveOccurred()) + Expect(res5.Total).To(Equal(2)) + }) + + It("should test SearchBuilder with Params and Dialect", Label("search", "ftsearch", "builders"), func() { + createVal, err := client.CreateIndex(ctx, "idx_params"). + OnHash(). + Schema(&redis.FieldSchema{FieldName: "name", FieldType: redis.SearchFieldTypeText}). + Run() + Expect(err).NotTo(HaveOccurred()) + Expect(createVal).To(Equal("OK")) + WaitForIndexing(client, "idx_params") + + client.HSet(ctx, "doc1", "name", "Alice") + client.HSet(ctx, "doc2", "name", "Bob") + client.HSet(ctx, "doc3", "name", "Carol") + + // Test with single param + res1, err := client.Search(ctx, "idx_params", "@name:$name"). + Param("name", "Alice"). + NoContent(). + Run() + Expect(err).NotTo(HaveOccurred()) + Expect(res1.Total).To(Equal(1)) + Expect(res1.Docs[0].ID).To(Equal("doc1")) + + // Test with multiple params using ParamsMap + params := map[string]interface{}{ + "name1": "Bob", + "name2": "Carol", + } + res2, err := client.Search(ctx, "idx_params", "@name:($name1|$name2)"). + ParamsMap(params). + Dialect(2). + NoContent(). + Run() + Expect(err).NotTo(HaveOccurred()) + Expect(res2.Total).To(Equal(2)) + }) + + It("should test SearchBuilder with Limit and CountOnly", Label("search", "ftsearch", "builders"), func() { + createVal, err := client.CreateIndex(ctx, "idx_limit"). + OnHash(). + Schema(&redis.FieldSchema{FieldName: "txt", FieldType: redis.SearchFieldTypeText}). + Run() + Expect(err).NotTo(HaveOccurred()) + Expect(createVal).To(Equal("OK")) + WaitForIndexing(client, "idx_limit") + + for i := 1; i <= 10; i++ { + client.HSet(ctx, fmt.Sprintf("doc%d", i), "txt", "test document") + } + + // Test with Limit + res1, err := client.Search(ctx, "idx_limit", "test"). + Limit(2, 3). + NoContent(). + Run() + Expect(err).NotTo(HaveOccurred()) + Expect(res1.Total).To(Equal(10)) + Expect(len(res1.Docs)).To(Equal(3)) + + // Test with CountOnly + res2, err := client.Search(ctx, "idx_limit", "test"). + CountOnly(). + Run() + Expect(err).NotTo(HaveOccurred()) + Expect(res2.Total).To(Equal(10)) + Expect(len(res2.Docs)).To(Equal(0)) + }) + + It("should test SearchBuilder with WithSortByCount and SortBy", Label("search", "ftsearch", "builders"), func() { + createVal, err := client.CreateIndex(ctx, "idx_payloads"). + OnHash(). + Schema(&redis.FieldSchema{FieldName: "txt", FieldType: redis.SearchFieldTypeText}). + Schema(&redis.FieldSchema{FieldName: "num", FieldType: redis.SearchFieldTypeNumeric, Sortable: true}). + Run() + Expect(err).NotTo(HaveOccurred()) + Expect(createVal).To(Equal("OK")) + WaitForIndexing(client, "idx_payloads") + + client.HSet(ctx, "doc1", "txt", "hello", "num", 1) + client.HSet(ctx, "doc2", "txt", "world", "num", 2) + + // Test WithSortByCount and SortBy + res, err := client.Search(ctx, "idx_payloads", "*"). + SortBy("num", true). + WithSortByCount(). + NoContent(). + Run() + Expect(err).NotTo(HaveOccurred()) + Expect(res.Total).To(Equal(2)) + }) + + It("should test SearchBuilder with JSON", Label("search", "ftsearch", "builders", "json"), func() { + createVal, err := client.CreateIndex(ctx, "idx_json"). + OnJSON(). + Prefix("king:"). + Schema(&redis.FieldSchema{FieldName: "$.name", FieldType: redis.SearchFieldTypeText}). + Run() + Expect(err).NotTo(HaveOccurred()) + Expect(createVal).To(Equal("OK")) + WaitForIndexing(client, "idx_json") + + client.JSONSet(ctx, "king:1", "$", `{"name": "henry"}`) + client.JSONSet(ctx, "king:2", "$", `{"name": "james"}`) + + res, err := client.Search(ctx, "idx_json", "henry").Run() + Expect(err).NotTo(HaveOccurred()) + Expect(res.Total).To(Equal(1)) + Expect(res.Docs[0].ID).To(Equal("king:1")) + Expect(res.Docs[0].Fields["$"]).To(Equal(`{"name":"henry"}`)) + }) + + It("should test SearchBuilder with vector search", Label("search", "ftsearch", "builders", "vector"), func() { + hnswOptions := &redis.FTHNSWOptions{Type: "FLOAT32", Dim: 2, DistanceMetric: "L2"} + createVal, err := client.CreateIndex(ctx, "idx_vector"). + OnHash(). + Schema(&redis.FieldSchema{FieldName: "v", FieldType: redis.SearchFieldTypeVector, VectorArgs: &redis.FTVectorArgs{HNSWOptions: hnswOptions}}). + Run() + Expect(err).NotTo(HaveOccurred()) + Expect(createVal).To(Equal("OK")) + WaitForIndexing(client, "idx_vector") + + client.HSet(ctx, "a", "v", "aaaaaaaa") + client.HSet(ctx, "b", "v", "aaaabaaa") + client.HSet(ctx, "c", "v", "aaaaabaa") + + res, err := client.Search(ctx, "idx_vector", "*=>[KNN 2 @v $vec]"). + ReturnFields("__v_score"). + SortBy("__v_score", true). + Dialect(2). + Param("vec", "aaaaaaaa"). + Run() + Expect(err).NotTo(HaveOccurred()) + Expect(res.Docs[0].ID).To(Equal("a")) + Expect(res.Docs[0].Fields["__v_score"]).To(Equal("0")) + }) + + It("should test SearchBuilder with complex filtering and aggregation", Label("search", "ftsearch", "builders"), func() { + createVal, err := client.CreateIndex(ctx, "idx_complex"). + OnHash(). + Schema(&redis.FieldSchema{FieldName: "category", FieldType: redis.SearchFieldTypeTag}). + Schema(&redis.FieldSchema{FieldName: "price", FieldType: redis.SearchFieldTypeNumeric, Sortable: true}). + Schema(&redis.FieldSchema{FieldName: "location", FieldType: redis.SearchFieldTypeGeo}). + Schema(&redis.FieldSchema{FieldName: "description", FieldType: redis.SearchFieldTypeText}). + Run() + Expect(err).NotTo(HaveOccurred()) + Expect(createVal).To(Equal("OK")) + WaitForIndexing(client, "idx_complex") + + client.HSet(ctx, "product1", "category", "electronics", "price", 100, "location", "-0.1,51.5", "description", "smartphone device") + client.HSet(ctx, "product2", "category", "electronics", "price", 200, "location", "-0.2,51.6", "description", "laptop computer") + client.HSet(ctx, "product3", "category", "books", "price", 20, "location", "-0.3,51.7", "description", "programming guide") + + res, err := client.Search(ctx, "idx_complex", "@category:{electronics} @description:(device|computer)"). + Filter("price", 50, 250). + GeoFilter("location", -0.15, 51.55, 50, "km"). + SortBy("price", true). + ReturnFields("category", "price", "description"). + Limit(0, 10). + Run() + Expect(err).NotTo(HaveOccurred()) + Expect(res.Total).To(BeNumerically(">=", 1)) + + res2, err := client.Search(ctx, "idx_complex", "@category:{$cat} @price:[$min $max]"). + ParamsMap(map[string]interface{}{ + "cat": "electronics", + "min": 150, + "max": 300, + }). + Dialect(2). + WithScores(). + Run() + Expect(err).NotTo(HaveOccurred()) + Expect(res2.Total).To(Equal(1)) + Expect(res2.Docs[0].ID).To(Equal("product2")) + }) + + It("should test SearchBuilder error handling and edge cases", Label("search", "ftsearch", "builders", "edge-cases"), func() { + createVal, err := client.CreateIndex(ctx, "idx_edge"). + OnHash(). + Schema(&redis.FieldSchema{FieldName: "txt", FieldType: redis.SearchFieldTypeText}). + Run() + Expect(err).NotTo(HaveOccurred()) + Expect(createVal).To(Equal("OK")) + WaitForIndexing(client, "idx_edge") + + client.HSet(ctx, "doc1", "txt", "hello world") + + // Test empty query + res1, err := client.Search(ctx, "idx_edge", "*").NoContent().Run() + Expect(err).NotTo(HaveOccurred()) + Expect(res1.Total).To(Equal(1)) + + // Test query with no results + res2, err := client.Search(ctx, "idx_edge", "nonexistent").NoContent().Run() + Expect(err).NotTo(HaveOccurred()) + Expect(res2.Total).To(Equal(0)) + + // Test with multiple chained methods + res3, err := client.Search(ctx, "idx_edge", "hello"). + WithScores(). + NoContent(). + Verbatim(). + InOrder(). + Slop(0). + Timeout(5000). + Language("english"). + Dialect(2). + Run() + Expect(err).NotTo(HaveOccurred()) + Expect(res3.Total).To(Equal(1)) + }) + + It("should test SearchBuilder method chaining", Label("search", "ftsearch", "builders", "fluent"), func() { + createVal, err := client.CreateIndex(ctx, "idx_fluent"). + OnHash(). + Schema(&redis.FieldSchema{FieldName: "title", FieldType: redis.SearchFieldTypeText}). + Schema(&redis.FieldSchema{FieldName: "tags", FieldType: redis.SearchFieldTypeTag}). + Schema(&redis.FieldSchema{FieldName: "score", FieldType: redis.SearchFieldTypeNumeric, Sortable: true}). + Run() + Expect(err).NotTo(HaveOccurred()) + Expect(createVal).To(Equal("OK")) + WaitForIndexing(client, "idx_fluent") + + client.HSet(ctx, "doc1", "title", "Redis Search Tutorial", "tags", "redis,search,tutorial", "score", 95) + client.HSet(ctx, "doc2", "title", "Advanced Redis", "tags", "redis,advanced", "score", 88) + + builder := client.Search(ctx, "idx_fluent", "@title:(redis) @tags:{search}") + result := builder. + WithScores(). + Filter("score", 90, 100). + SortBy("score", false). + ReturnFields("title", "score"). + Limit(0, 5). + Dialect(2). + Timeout(1000). + Language("english") + + res, err := result.Run() + Expect(err).NotTo(HaveOccurred()) + Expect(res.Total).To(Equal(1)) + Expect(res.Docs[0].ID).To(Equal("doc1")) + Expect(res.Docs[0].Fields["title"]).To(Equal("Redis Search Tutorial")) + Expect(*res.Docs[0].Score).To(BeNumerically(">", 0)) + }) +})