From 14a9a8536366991f0c414829a6c776bd23bf0874 Mon Sep 17 00:00:00 2001 From: Nedyalko Dyakov <1547186+ndyakov@users.noreply.github.com> Date: Thu, 21 May 2026 14:18:35 +0300 Subject: [PATCH] feat(search): parser resp3 responses for search (#3741) * parse resp3 responses * backwards compatibility * doctests and format * fix parsing * remove unused functions * debug test * debug test x2 peek directly * debug test * remove debug and skip re build * fix typo * add tests and fix parsers for resp3 * add comment for the unreliable Total in RESP3 for aggregate * fix build * remove shadowing rawVal * fix strange redis search float,int,string behaviour handling * mark UnstableResp3 as Deprecated --------- Co-authored-by: ofekshenawa <104765379+ofekshenawa@users.noreply.github.com> --- .github/workflows/test-redis-enterprise.yml | 1 + doctests/geo_index_test.go | 4 +- doctests/home_json_example_test.go | 4 +- doctests/search_quickstart_test.go | 4 +- example/tls-connection/main.go | 11 +- internal/auth/streaming/manager_test.go | 4 +- internal/util.go | 24 + internal/util_test.go | 27 + json_test.go | 7 +- options.go | 4 + osscluster.go | 4 + redis.go | 21 +- search_commands.go | 772 ++++++++++- search_commands_parse_test.go | 1331 +++++++++++++++++++ search_resp_test.go | 372 ++++++ search_test.go | 155 ++- sentinel.go | 3 + tls_cluster_test.go | 1 - tls_standalone_test.go | 1 - tls_test.go | 25 +- universal.go | 3 + 21 files changed, 2627 insertions(+), 151 deletions(-) create mode 100644 search_resp_test.go diff --git a/.github/workflows/test-redis-enterprise.yml b/.github/workflows/test-redis-enterprise.yml index d2c59ff9..e15982d9 100644 --- a/.github/workflows/test-redis-enterprise.yml +++ b/.github/workflows/test-redis-enterprise.yml @@ -10,6 +10,7 @@ permissions: jobs: build: + if: false # Temporarily disabled, should be enabled when we have a current and stable RE build name: build runs-on: ubuntu-latest strategy: diff --git a/doctests/geo_index_test.go b/doctests/geo_index_test.go index 2e944274..acf3f5d4 100644 --- a/doctests/geo_index_test.go +++ b/doctests/geo_index_test.go @@ -199,11 +199,11 @@ func ExampleClient_geoindex() { // OK // OK // OK - // {1 [{product:46885 map[$:{"city":"Denver","description":"Navy Blue Slippers","location":"-104.991531, 39.742043","price":45.99}] }]} + // {1 [{product:46885 map[$:{"city":"Denver","description":"Navy Blue Slippers","location":"-104.991531, 39.742043","price":45.99}] }] []} // OK // OK // OK // OK // OK - // {1 [{shape:4 map[$:[{"geom":"POINT (2 2)","name":"Purple Point"}]] }]} + // {1 [{shape:4 map[$:[{"geom":"POINT (2 2)","name":"Purple Point"}]] }] []} } diff --git a/doctests/home_json_example_test.go b/doctests/home_json_example_test.go index d2af4d73..06a06191 100644 --- a/doctests/home_json_example_test.go +++ b/doctests/home_json_example_test.go @@ -219,7 +219,7 @@ func ExampleClient_search_json() { // STEP_END // Output: - // {1 [{user:3 map[$:{"age":35,"city":"Tel Aviv","email":"paul.zamir@example.com","name":"Paul Zamir"}] }]} + // {1 [{user:3 map[$:{"age":35,"city":"Tel Aviv","email":"paul.zamir@example.com","name":"Paul Zamir"}] }] []} // London // Tel Aviv // 0 @@ -329,5 +329,5 @@ func ExampleClient_search_hash() { // STEP_END // Output: - // {1 [{huser:3 map[age:35 city:Tel Aviv email:paul.zamir@example.com name:Paul Zamir] }]} + // {1 [{huser:3 map[age:35 city:Tel Aviv email:paul.zamir@example.com name:Paul Zamir] }] []} } diff --git a/doctests/search_quickstart_test.go b/doctests/search_quickstart_test.go index a41c9c5c..8359b427 100644 --- a/doctests/search_quickstart_test.go +++ b/doctests/search_quickstart_test.go @@ -257,6 +257,6 @@ func ExampleClient_search_qs() { // Output: // Documents found: 10 - // {1 [{bicycle:0 map[$:{"brand":"Velorim","condition":"new","description":"Small and powerful, the Jigger is the best ride for the smallest of tikes! This is the tiniest kids’ pedal bike on the market available without a coaster brake, the Jigger is the vehicle of choice for the rare tenacious little rider raring to go.","model":"Jigger","price":270}] }]} - // {1 [{bicycle:4 map[$:{"brand":"Noka Bikes","condition":"used","description":"Whether you want to try your hand at XC racing or are looking for a lively trail bike that's just as inspiring on the climbs as it is over rougher ground, the Wilder is one heck of a bike built specifically for short women. Both the frames and components have been tweaked to include a women’s saddle, different bars and unique colourway.","model":"Kahuna","price":3200}] }]} + // {1 [{bicycle:0 map[$:{"brand":"Velorim","condition":"new","description":"Small and powerful, the Jigger is the best ride for the smallest of tikes! This is the tiniest kids’ pedal bike on the market available without a coaster brake, the Jigger is the vehicle of choice for the rare tenacious little rider raring to go.","model":"Jigger","price":270}] }] []} + // {1 [{bicycle:4 map[$:{"brand":"Noka Bikes","condition":"used","description":"Whether you want to try your hand at XC racing or are looking for a lively trail bike that's just as inspiring on the climbs as it is over rougher ground, the Wilder is one heck of a bike built specifically for short women. Both the frames and components have been tweaked to include a women’s saddle, different bars and unique colourway.","model":"Kahuna","price":3200}] }] []} } diff --git a/example/tls-connection/main.go b/example/tls-connection/main.go index f1d106da..41557fc0 100644 --- a/example/tls-connection/main.go +++ b/example/tls-connection/main.go @@ -31,7 +31,7 @@ func main() { // Example 2: TLS with CA certificate verification fmt.Println("\nExample 2: TLS with CA certificate verification") - + // Load CA certificate caCert, err := os.ReadFile("path/to/ca.crt") if err != nil { @@ -58,7 +58,7 @@ func main() { // Example 3: TLS with client certificate (mutual TLS) fmt.Println("\nExample 3: TLS with client certificate (mutual TLS)") - + // Load CA certificate caCert, err = os.ReadFile("path/to/ca.crt") if err != nil { @@ -92,18 +92,18 @@ func main() { // Example 4: Using rediss:// URL scheme fmt.Println("\nExample 4: Using rediss:// URL scheme") - + opt, err := redis.ParseURL("rediss://localhost:6666") if err != nil { fmt.Printf("Failed to parse URL: %v\n", err) return } - + // Add InsecureSkipVerify for testing with self-signed certs opt.TLSConfig = &tls.Config{ InsecureSkipVerify: true, } - + client4 := redis.NewClient(opt) defer client4.Close() @@ -121,4 +121,3 @@ func main() { fmt.Println("The certificate's CN (Common Name) field will be used as the Redis username") fmt.Println("See tls_cert_auth_test.go for a complete working example") } - diff --git a/internal/auth/streaming/manager_test.go b/internal/auth/streaming/manager_test.go index 0ce0ef84..73e68040 100644 --- a/internal/auth/streaming/manager_test.go +++ b/internal/auth/streaming/manager_test.go @@ -86,8 +86,8 @@ func TestManager_Listener_NilConn(t *testing.T) { // Mock pooler for testing type mockPooler struct{} -func (m *mockPooler) NewConn(ctx context.Context) (*pool.Conn, error) { return nil, nil } -func (m *mockPooler) CloseConn(context.Context, *pool.Conn, string, string) error { return nil } +func (m *mockPooler) NewConn(ctx context.Context) (*pool.Conn, error) { return nil, nil } +func (m *mockPooler) CloseConn(context.Context, *pool.Conn, string, string) error { return nil } func (m *mockPooler) Get(ctx context.Context) (*pool.Conn, error) { return nil, nil } func (m *mockPooler) Put(ctx context.Context, conn *pool.Conn) {} func (m *mockPooler) Remove(ctx context.Context, conn *pool.Conn, reason error) {} diff --git a/internal/util.go b/internal/util.go index f77775ff..00516075 100644 --- a/internal/util.go +++ b/internal/util.go @@ -2,6 +2,7 @@ package internal import ( "context" + "math" "net" "strconv" "strings" @@ -10,6 +11,29 @@ import ( "github.com/redis/go-redis/v9/internal/util" ) +// String representations of special float values. +// Values are lowercase for consistency with Redis RESP2 protocol responses. +const ( + NaN = "nan" // Not a Number + Inf = "inf" // Positive infinity + NInf = "-inf" // Negative infinity +) + +// FormatFloat formats a float64 to string, normalizing special values +// (NaN, Inf) to lowercase for consistency with Redis RESP2 protocol. +func FormatFloat(f float64) string { + switch { + case math.IsNaN(f): + return NaN + case math.IsInf(f, 1): + return Inf + case math.IsInf(f, -1): + return NInf + default: + return strconv.FormatFloat(f, 'f', -1, 64) + } +} + func Sleep(ctx context.Context, dur time.Duration) error { t := time.NewTimer(dur) defer t.Stop() diff --git a/internal/util_test.go b/internal/util_test.go index 0bc46646..3019f995 100644 --- a/internal/util_test.go +++ b/internal/util_test.go @@ -1,6 +1,7 @@ package internal import ( + "math" "runtime" "strings" "testing" @@ -106,3 +107,29 @@ func BenchmarkReplaceSpacesUseBuilder(b *testing.B) { _ = ReplaceSpacesUseBuilder(version) } } + +func TestFormatFloat(t *testing.T) { + tests := []struct { + name string + input float64 + expected string + }{ + {"NaN", math.NaN(), NaN}, + {"positive infinity", math.Inf(1), Inf}, + {"negative infinity", math.Inf(-1), NInf}, + {"zero", 0, "0"}, + {"positive integer", 42, "42"}, + {"negative integer", -42, "-42"}, + {"positive float", 3.14159, "3.14159"}, + {"negative float", -3.14159, "-3.14159"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := FormatFloat(tt.input) + if result != tt.expected { + t.Errorf("FormatFloat(%v) = %q, want %q", tt.input, result, tt.expected) + } + }) + } +} diff --git a/json_test.go b/json_test.go index fd612b43..22ae7a3f 100644 --- a/json_test.go +++ b/json_test.go @@ -3,6 +3,7 @@ package redis_test import ( "context" "encoding/json" + "fmt" "time" . "github.com/bsm/ginkgo/v2" @@ -846,12 +847,12 @@ var _ = Describe("Go-Redis Advanced JSON and RediSearch Tests", func() { getCmdRaw := client.JSONGet(ctx, "person:1", ".") rawJSON, err := getCmdRaw.Result() Expect(err).NotTo(HaveOccurred(), "JSON.GET (raw) failed") - GinkgoWriter.Printf("Raw JSON: %s\n", rawJSON) + fmt.Printf("Raw JSON: %s\n", rawJSON) getCmdExpanded := client.JSONGet(ctx, "person:1", ".") expandedJSON, err := getCmdExpanded.Expanded() Expect(err).NotTo(HaveOccurred(), "JSON.GET (expanded) failed") - GinkgoWriter.Printf("Expanded JSON: %+v\n", expandedJSON) + fmt.Printf("Expanded JSON: %+v\n", expandedJSON) Expect(rawJSON).To(MatchJSON(jsonMustMarshal(expandedJSON))) @@ -897,7 +898,7 @@ var _ = Describe("Go-Redis Advanced JSON and RediSearch Tests", func() { searchCmd := client.FTSearchWithArgs(ctx, "person_idx", "@contact_value:(alice\\@example\\.com alice_wonder)", &redis.FTSearchOptions{Return: []redis.FTSearchReturn{{FieldName: "$.person.name"}, {FieldName: "$.person.age"}, {FieldName: "$.person.address.city"}}}) searchResult, err := searchCmd.Result() Expect(err).NotTo(HaveOccurred(), "FT.SEARCH failed") - GinkgoWriter.Printf("Advanced Search result: %+v\n", searchResult) + fmt.Printf("Advanced Search result: %+v\n", searchResult) incrCmd := client.JSONNumIncrBy(ctx, "person:1", "$.person.age", 5) incrResult, err := incrCmd.Result() diff --git a/options.go b/options.go index af902fea..7c16da4e 100644 --- a/options.go +++ b/options.go @@ -292,6 +292,10 @@ type Options struct { // UnstableResp3 enables Unstable mode for Redis Search module with RESP3. // When unstable mode is enabled, the client will use RESP3 protocol and only be able to use RawResult + // + // Deprecated: All RediSearch commands now have stable RESP3 parsing and this + // flag is a no-op. It is kept for backwards compatibility and will be removed + // in a future release. UnstableResp3 bool // Push notifications are always enabled for RESP3 connections (Protocol: 3) diff --git a/osscluster.go b/osscluster.go index 94fdd8b3..cdb3ab55 100644 --- a/osscluster.go +++ b/osscluster.go @@ -164,6 +164,10 @@ type ClusterOptions struct { IdentitySuffix string // Add suffix to client name. Default is empty. // UnstableResp3 enables Unstable mode for Redis Search module with RESP3. + // + // Deprecated: All RediSearch commands now have stable RESP3 parsing and this + // flag is a no-op. It is kept for backwards compatibility and will be removed + // in a future release. UnstableResp3 bool // PushNotificationProcessor is the processor for handling push notifications. diff --git a/redis.go b/redis.go index 923bad62..dd345189 100644 --- a/redis.go +++ b/redis.go @@ -26,6 +26,14 @@ type Scanner = hscan.Scanner // Nil reply returned by Redis when key does not exist. const Nil = proto.Nil +// String representations of special float values. +// Values are lowercase for consistency with Redis RESP2 protocol responses. +const ( + NaN = internal.NaN // Not a Number + Inf = internal.Inf // Positive infinity + NInf = internal.NInf // Negative infinity +) + // SetLogger set custom log // Use with VoidLogger to disable logging. // If logger is nil, the call is ignored and the existing logger is kept. @@ -936,16 +944,9 @@ func classifyCommandError(err error) (errorType, statusCode string, isInternal b } func (c *baseClient) assertUnstableCommand(cmd Cmder) (bool, error) { - switch cmd.(type) { - case *AggregateCmd, *FTInfoCmd, *FTSpellCheckCmd, *FTSearchCmd, *FTSynDumpCmd: - if c.opt.UnstableResp3 { - return true, nil - } else { - return false, fmt.Errorf("RESP3 responses for this command are disabled because they may still change. Please set the flag UnstableResp3. See the README and the release notes for guidance") - } - default: - return false, nil - } + // All search commands (FTSearchCmd, AggregateCmd, FTInfoCmd, FTSpellCheckCmd, FTSynDumpCmd) + // now have stable RESP3 parsing. No commands require the UnstableResp3 flag anymore. + return false, nil } func (c *baseClient) _process(ctx context.Context, cmd Cmder, attempt int) (bool, *pool.Conn, error) { diff --git a/search_commands.go b/search_commands.go index 768df806..c3d14aba 100644 --- a/search_commands.go +++ b/search_commands.go @@ -6,6 +6,7 @@ import ( "maps" "slices" "strconv" + "strings" "github.com/redis/go-redis/v9/internal" "github.com/redis/go-redis/v9/internal/proto" @@ -481,9 +482,12 @@ type FTSynDumpCmd struct { val []FTSynDumpResult } +// FTAggregateResult represents the result of an aggregate operation +// NOTE: For RESP3 Total is not reliable (before Redis 8.8) type FTAggregateResult struct { - Total int - Rows []AggregateRow + Total int + Rows []AggregateRow + Warnings []string } type AggregateRow struct { @@ -613,8 +617,9 @@ type SpellCheckSuggestion struct { } type FTSearchResult struct { - Total int - Docs []Document + Total int + Docs []Document + Warnings []string } type Document struct { @@ -949,15 +954,129 @@ func (cmd *AggregateCmd) String() string { } func (cmd *AggregateCmd) readReply(rd *proto.Reader) (err error) { - data, err := rd.ReadSlice() + readType, err := rd.PeekReplyType() if err != nil { return err } - cmd.val, err = ProcessAggregateResult(data) + + // RESP3 returns a map, RESP2 returns an array + if readType == proto.RespMap { + // Read raw response first for backwards compatibility + cmd.rawVal, err = rd.ReadReply() + if err != nil { + return err + } + // Parse the raw response into structured result + if mapVal, ok := cmd.rawVal.(map[interface{}]interface{}); ok { + cmd.val, err = parseFTAggregateMapRESP3(mapVal) + } else { + return fmt.Errorf("unexpected RESP3 response type: %T", cmd.rawVal) + } + return err + } + + // RESP2 format or error response - use ReadReply to handle errors properly + data, err := rd.ReadReply() if err != nil { return err } - return nil + cmd.rawVal = data // Store raw value for debugging + if dataSlice, ok := data.([]interface{}); ok { + cmd.val, err = ProcessAggregateResult(dataSlice) + return err + } + return fmt.Errorf("unexpected response type: %T", data) +} + +// parseFTAggregateMapRESP3 parses the RESP3 format response from FT.AGGREGATE. +// It takes a map[interface{}]interface{} which is the raw response from ReadReply(). +// RESP3 format: +// +// %5 +// $10 attributes => *0 +// $13 total_results => :N +// $6 format => $6 STRING +// $7 results => *N (array of maps with extra_attributes, values) +// $7 warning => *N (array of strings) +func parseFTAggregateMapRESP3(data map[interface{}]interface{}) (*FTAggregateResult, error) { + result := &FTAggregateResult{ + Rows: make([]AggregateRow, 0), + } + + for k, v := range data { + key, ok := k.(string) + if !ok { + continue + } + + switch key { + case "total_results": + result.Total = internal.ToInteger(v) + case "results": + if resultsData, ok := v.([]interface{}); ok { + rows, err := parseFTAggregateResultsMapRESP3(resultsData) + if err != nil { + return nil, err + } + result.Rows = rows + } + case "warning": + if warningsData, ok := v.([]interface{}); ok { + result.Warnings = make([]string, 0, len(warningsData)) + for _, w := range warningsData { + if ws, ok := w.(string); ok { + result.Warnings = append(result.Warnings, ws) + } + } + } + // Ignore "attributes", "format", and other fields as per the spec + } + } + + return result, nil +} + +// parseFTAggregateResultsMapRESP3 parses the results array from RESP3 FT.AGGREGATE response. +func parseFTAggregateResultsMapRESP3(resultsData []interface{}) ([]AggregateRow, error) { + rows := make([]AggregateRow, 0, len(resultsData)) + for _, item := range resultsData { + if itemMap, ok := item.(map[interface{}]interface{}); ok { + row, err := parseFTAggregateRowMapRESP3(itemMap) + if err != nil { + return nil, err + } + rows = append(rows, row) + } + } + return rows, nil +} + +// parseFTAggregateRowMapRESP3 parses a single row from RESP3 FT.AGGREGATE response. +func parseFTAggregateRowMapRESP3(itemMap map[interface{}]interface{}) (AggregateRow, error) { + row := AggregateRow{ + Fields: make(map[string]interface{}), + } + + for k, v := range itemMap { + key, ok := k.(string) + if !ok { + continue + } + + switch key { + case "extra_attributes": + if extraAttrs, ok := v.(map[interface{}]interface{}); ok { + for ek, ev := range extraAttrs { + if ekStr, ok := ek.(string); ok { + row.Fields[ekStr] = ev + } + } + } + // Ignore "values" and other fields as per the spec + } + } + + return row, nil } func (cmd *AggregateCmd) Clone() Cmder { @@ -978,6 +1097,10 @@ func (cmd *AggregateCmd) Clone() Cmder { } } } + if cmd.val.Warnings != nil { + val.Warnings = make([]string, len(cmd.val.Warnings)) + copy(val.Warnings, cmd.val.Warnings) + } } return &AggregateCmd{ baseCmd: cmd.cloneBaseCmd(), @@ -1564,134 +1687,322 @@ func (c cmdable) FTExplainCli(ctx context.Context, key, path string) error { return fmt.Errorf("FTExplainCli is not implemented") } +// parseFTAttributeFromMap parses an FTAttribute from a RESP3 map format +func parseFTAttributeFromMap(attrMap map[interface{}]interface{}) FTAttribute { + att := FTAttribute{} + for k, v := range attrMap { + key := internal.ToLower(internal.ToString(k)) + switch key { + case "attribute": + att.Attribute = internal.ToString(v) + case "identifier": + att.Identifier = internal.ToString(v) + case "type": + att.Type = internal.ToString(v) + case "weight": + att.Weight = internal.ToFloat(v) + case "phonetic": + att.PhoneticMatcher = internal.ToString(v) + case "algorithm": + att.Algorithm = internal.ToString(v) + case "data_type": + att.DataType = internal.ToString(v) + case "dim": + att.Dim = internal.ToInteger(v) + case "distance_metric": + att.DistanceMetric = internal.ToString(v) + case "m": + att.M = internal.ToInteger(v) + case "ef_construction": + att.EFConstruction = internal.ToInteger(v) + case "flags": + // flags is an array of strings like ["SORTABLE", "NOSTEM"] + if flags, ok := v.([]interface{}); ok { + for _, flag := range flags { + flagStr := internal.ToLower(internal.ToString(flag)) + switch flagStr { + case "nostem": + att.NoStem = true + case "sortable": + att.Sortable = true + case "noindex": + att.NoIndex = true + case "unf": + att.UNF = true + case "case_sensitive": + att.CaseSensitive = true + case "withsuffixtrie": + att.WithSuffixtrie = true + } + } + } + } + } + return att +} + +// getMapStringKey extracts a string value from a map with interface{} keys +func getMapStringKey(m map[interface{}]interface{}, key string) interface{} { + if v, ok := m[key]; ok { + return v + } + return nil +} + +// parseIndexErrorsRESP3 parses Index Errors from RESP3 map format +func parseIndexErrorsRESP3(m map[interface{}]interface{}) IndexErrors { + return IndexErrors{ + IndexingFailures: internal.ToInteger(getMapStringKey(m, "indexing failures")), + LastIndexingError: internal.ToString(getMapStringKey(m, "last indexing error")), + LastIndexingErrorKey: internal.ToString(getMapStringKey(m, "last indexing error key")), + } +} + +// parseCursorStatsRESP3 parses cursor_stats from RESP3 map format +func parseCursorStatsRESP3(m map[interface{}]interface{}) CursorStats { + return CursorStats{ + GlobalIdle: internal.ToInteger(getMapStringKey(m, "global_idle")), + GlobalTotal: internal.ToInteger(getMapStringKey(m, "global_total")), + IndexCapacity: internal.ToInteger(getMapStringKey(m, "index_capacity")), + IndexTotal: internal.ToInteger(getMapStringKey(m, "index_total")), + } +} + +// parseGCStatsRESP3 parses gc_stats from RESP3 map format +func parseGCStatsRESP3(m map[interface{}]interface{}) GCStats { + // Handle average_cycle_time_ms which can be a float64 (including NaN) or string + avgCycleTime := "" + if v := getMapStringKey(m, "average_cycle_time_ms"); v != nil { + switch val := v.(type) { + case string: + // Normalize to lowercase for consistency with RESP2 + avgCycleTime = strings.ToLower(val) + case float64: + avgCycleTime = internal.FormatFloat(val) + } + } + + return GCStats{ + BytesCollected: ftInfoNumInt(getMapStringKey(m, "bytes_collected")), + TotalMsRun: ftInfoNumInt(getMapStringKey(m, "total_ms_run")), + TotalCycles: ftInfoNumInt(getMapStringKey(m, "total_cycles")), + AverageCycleTimeMs: avgCycleTime, + LastRunTimeMs: ftInfoNumInt(getMapStringKey(m, "last_run_time_ms")), + GCNumericTreesMissed: ftInfoNumInt(getMapStringKey(m, "gc_numeric_trees_missed")), + GCBlocksDenied: ftInfoNumInt(getMapStringKey(m, "gc_blocks_denied")), + } +} + +// parseIndexDefinitionRESP3 parses index_definition from RESP3 map format +func parseIndexDefinitionRESP3(m map[interface{}]interface{}) IndexDefinition { + def := IndexDefinition{ + KeyType: internal.ToString(getMapStringKey(m, "key_type")), + DefaultScore: internal.ToFloat(getMapStringKey(m, "default_score")), + } + if prefixes, ok := getMapStringKey(m, "prefixes").([]interface{}); ok { + def.Prefixes = internal.ToStringSlice(prefixes) + } + return def +} + +// parseDialectStatsRESP3 parses dialect_stats from RESP3 map format +func parseDialectStatsRESP3(m map[interface{}]interface{}) map[string]int { + result := make(map[string]int) + for k, v := range m { + if kStr, ok := k.(string); ok { + result[kStr] = internal.ToInteger(v) + } + } + return result +} + +// ftInfoNumString stringifies a value that RediSearch emits via REPLY_KVNUM +// (RedisModule_ReplyWithDouble): a bulk string in RESP2 but a native double +// in RESP3. Used for FTInfoResult fields whose public type is string. +// Special float values (NaN, +Inf, -Inf) are normalized to lowercase to match +// the RESP2 wire format. +func ftInfoNumString(val interface{}) string { + switch v := val.(type) { + case string: + return v + case float64: + return internal.FormatFloat(v) + case float32: + return internal.FormatFloat(float64(v)) + case int64: + return strconv.FormatInt(v, 10) + case int: + return strconv.Itoa(v) + default: + return "" + } +} + +// ftInfoNumInt converts a value that RediSearch emits via REPLY_KVNUM to int. +// In RESP2 the value is a bulk string; in RESP3 it is a native double, even +// for logically-integer fields (counters, byte sizes). This helper exists so +// the internal.ToInteger helper can remain strict about float-to-int coercion +// while still letting the RediSearch parsers read those values correctly. +func ftInfoNumInt(val interface{}) int { + switch v := val.(type) { + case float64: + return int(v) + case float32: + return int(v) + default: + return internal.ToInteger(v) + } +} + func parseFTInfo(data map[string]interface{}) (FTInfoResult, error) { var ftInfo FTInfoResult - // Manually parse each field from the map + + // Parse Index Errors - handle both RESP2 (array) and RESP3 (map) formats if indexErrors, ok := data["Index Errors"].([]interface{}); ok { + // RESP2 format: array with key-value pairs ftInfo.IndexErrors = IndexErrors{ IndexingFailures: internal.ToInteger(indexErrors[1]), LastIndexingError: internal.ToString(indexErrors[3]), LastIndexingErrorKey: internal.ToString(indexErrors[5]), } + } else if indexErrors, ok := data["Index Errors"].(map[interface{}]interface{}); ok { + // RESP3 format: map + ftInfo.IndexErrors = parseIndexErrorsRESP3(indexErrors) } if attributes, ok := data["attributes"].([]interface{}); ok { for _, attr := range attributes { - if attrMap, ok := attr.([]interface{}); ok { - att := FTAttribute{} - attrLen := len(attrMap) + att := FTAttribute{} + // Handle RESP2 format: attribute is []interface{} + if attrSlice, ok := attr.([]interface{}); ok { + attrLen := len(attrSlice) for i := 0; i < attrLen; i++ { - if internal.ToLower(internal.ToString(attrMap[i])) == "attribute" && i+1 < attrLen { - att.Attribute = internal.ToString(attrMap[i+1]) + if internal.ToLower(internal.ToString(attrSlice[i])) == "attribute" && i+1 < attrLen { + att.Attribute = internal.ToString(attrSlice[i+1]) i++ continue } - if internal.ToLower(internal.ToString(attrMap[i])) == "identifier" && i+1 < attrLen { - att.Identifier = internal.ToString(attrMap[i+1]) + if internal.ToLower(internal.ToString(attrSlice[i])) == "identifier" && i+1 < attrLen { + att.Identifier = internal.ToString(attrSlice[i+1]) i++ continue } - if internal.ToLower(internal.ToString(attrMap[i])) == "type" && i+1 < attrLen { - att.Type = internal.ToString(attrMap[i+1]) + if internal.ToLower(internal.ToString(attrSlice[i])) == "type" && i+1 < attrLen { + att.Type = internal.ToString(attrSlice[i+1]) i++ continue } - if internal.ToLower(internal.ToString(attrMap[i])) == "weight" && i+1 < attrLen { - att.Weight = internal.ToFloat(attrMap[i+1]) + if internal.ToLower(internal.ToString(attrSlice[i])) == "weight" && i+1 < attrLen { + att.Weight = internal.ToFloat(attrSlice[i+1]) i++ continue } - if internal.ToLower(internal.ToString(attrMap[i])) == "nostem" { + if internal.ToLower(internal.ToString(attrSlice[i])) == "nostem" { att.NoStem = true continue } - if internal.ToLower(internal.ToString(attrMap[i])) == "sortable" { + if internal.ToLower(internal.ToString(attrSlice[i])) == "sortable" { att.Sortable = true continue } - if internal.ToLower(internal.ToString(attrMap[i])) == "noindex" { + if internal.ToLower(internal.ToString(attrSlice[i])) == "noindex" { att.NoIndex = true continue } - if internal.ToLower(internal.ToString(attrMap[i])) == "unf" { + if internal.ToLower(internal.ToString(attrSlice[i])) == "unf" { att.UNF = true continue } - if internal.ToLower(internal.ToString(attrMap[i])) == "phonetic" && i+1 < attrLen { - att.PhoneticMatcher = internal.ToString(attrMap[i+1]) + if internal.ToLower(internal.ToString(attrSlice[i])) == "phonetic" && i+1 < attrLen { + att.PhoneticMatcher = internal.ToString(attrSlice[i+1]) continue } - if internal.ToLower(internal.ToString(attrMap[i])) == "case_sensitive" { + if internal.ToLower(internal.ToString(attrSlice[i])) == "case_sensitive" { att.CaseSensitive = true continue } - if internal.ToLower(internal.ToString(attrMap[i])) == "withsuffixtrie" { + if internal.ToLower(internal.ToString(attrSlice[i])) == "withsuffixtrie" { att.WithSuffixtrie = true continue } // vector specific attributes - if internal.ToLower(internal.ToString(attrMap[i])) == "algorithm" && i+1 < attrLen { - att.Algorithm = internal.ToString(attrMap[i+1]) + if internal.ToLower(internal.ToString(attrSlice[i])) == "algorithm" && i+1 < attrLen { + att.Algorithm = internal.ToString(attrSlice[i+1]) i++ continue } - if internal.ToLower(internal.ToString(attrMap[i])) == "data_type" && i+1 < attrLen { - att.DataType = internal.ToString(attrMap[i+1]) + if internal.ToLower(internal.ToString(attrSlice[i])) == "data_type" && i+1 < attrLen { + att.DataType = internal.ToString(attrSlice[i+1]) i++ continue } - if internal.ToLower(internal.ToString(attrMap[i])) == "dim" && i+1 < attrLen { - att.Dim = internal.ToInteger(attrMap[i+1]) + if internal.ToLower(internal.ToString(attrSlice[i])) == "dim" && i+1 < attrLen { + att.Dim = internal.ToInteger(attrSlice[i+1]) i++ continue } - if internal.ToLower(internal.ToString(attrMap[i])) == "distance_metric" && i+1 < attrLen { - att.DistanceMetric = internal.ToString(attrMap[i+1]) + if internal.ToLower(internal.ToString(attrSlice[i])) == "distance_metric" && i+1 < attrLen { + att.DistanceMetric = internal.ToString(attrSlice[i+1]) i++ continue } - if internal.ToLower(internal.ToString(attrMap[i])) == "m" && i+1 < attrLen { - att.M = internal.ToInteger(attrMap[i+1]) + if internal.ToLower(internal.ToString(attrSlice[i])) == "m" && i+1 < attrLen { + att.M = internal.ToInteger(attrSlice[i+1]) i++ continue } - if internal.ToLower(internal.ToString(attrMap[i])) == "ef_construction" && i+1 < attrLen { - att.EFConstruction = internal.ToInteger(attrMap[i+1]) + if internal.ToLower(internal.ToString(attrSlice[i])) == "ef_construction" && i+1 < attrLen { + att.EFConstruction = internal.ToInteger(attrSlice[i+1]) i++ continue } - } ftInfo.Attributes = append(ftInfo.Attributes, att) + } else if attrMap, ok := attr.(map[interface{}]interface{}); ok { + // Handle RESP3 format: attribute is map[interface{}]interface{} + att = parseFTAttributeFromMap(attrMap) + ftInfo.Attributes = append(ftInfo.Attributes, att) } } } - ftInfo.BytesPerRecordAvg = internal.ToString(data["bytes_per_record_avg"]) + ftInfo.BytesPerRecordAvg = ftInfoNumString(data["bytes_per_record_avg"]) ftInfo.Cleaning = internal.ToInteger(data["cleaning"]) + // Parse cursor_stats - handle both RESP2 (array) and RESP3 (map) formats if cursorStats, ok := data["cursor_stats"].([]interface{}); ok { + // RESP2 format ftInfo.CursorStats = CursorStats{ GlobalIdle: internal.ToInteger(cursorStats[1]), GlobalTotal: internal.ToInteger(cursorStats[3]), IndexCapacity: internal.ToInteger(cursorStats[5]), IndexTotal: internal.ToInteger(cursorStats[7]), } + } else if cursorStats, ok := data["cursor_stats"].(map[interface{}]interface{}); ok { + // RESP3 format + ftInfo.CursorStats = parseCursorStatsRESP3(cursorStats) } + // Parse dialect_stats - handle both RESP2 (array) and RESP3 (map) formats if dialectStats, ok := data["dialect_stats"].([]interface{}); ok { + // RESP2 format ftInfo.DialectStats = make(map[string]int) for i := 0; i < len(dialectStats); i += 2 { ftInfo.DialectStats[internal.ToString(dialectStats[i])] = internal.ToInteger(dialectStats[i+1]) } + } else if dialectStats, ok := data["dialect_stats"].(map[interface{}]interface{}); ok { + // RESP3 format + ftInfo.DialectStats = parseDialectStatsRESP3(dialectStats) } ftInfo.DocTableSizeMB = internal.ToFloat(data["doc_table_size_mb"]) + // Parse field statistics - handle both RESP2 and RESP3 formats if fieldStats, ok := data["field statistics"].([]interface{}); ok { for _, stat := range fieldStats { if statMap, ok := stat.([]interface{}); ok { + // RESP2 format ftInfo.FieldStatistics = append(ftInfo.FieldStatistics, FieldStatistic{ Identifier: internal.ToString(statMap[1]), Attribute: internal.ToString(statMap[3]), @@ -1701,11 +2012,23 @@ func parseFTInfo(data map[string]interface{}) (FTInfoResult, error) { LastIndexingErrorKey: internal.ToString(statMap[5].([]interface{})[5]), }, }) + } else if statMap, ok := stat.(map[interface{}]interface{}); ok { + // RESP3 format + fs := FieldStatistic{ + Identifier: internal.ToString(getMapStringKey(statMap, "identifier")), + Attribute: internal.ToString(getMapStringKey(statMap, "attribute")), + } + if indexErrors, ok := getMapStringKey(statMap, "Index Errors").(map[interface{}]interface{}); ok { + fs.IndexErrors = parseIndexErrorsRESP3(indexErrors) + } + ftInfo.FieldStatistics = append(ftInfo.FieldStatistics, fs) } } } + // Parse gc_stats - handle both RESP2 (array) and RESP3 (map) formats if gcStats, ok := data["gc_stats"].([]interface{}); ok { + // RESP2 format ftInfo.GCStats = GCStats{} for i := 0; i < len(gcStats); i += 2 { if internal.ToLower(internal.ToString(gcStats[i])) == "bytes_collected" { @@ -1737,21 +2060,31 @@ func parseFTInfo(data map[string]interface{}) (FTInfoResult, error) { continue } } + } else if gcStats, ok := data["gc_stats"].(map[interface{}]interface{}); ok { + // RESP3 format + ftInfo.GCStats = parseGCStatsRESP3(gcStats) } ftInfo.GeoshapesSzMB = internal.ToFloat(data["geoshapes_sz_mb"]) ftInfo.HashIndexingFailures = internal.ToInteger(data["hash_indexing_failures"]) + // Parse index_definition - handle both RESP2 (array) and RESP3 (map) formats if indexDef, ok := data["index_definition"].([]interface{}); ok { + // RESP2 format ftInfo.IndexDefinition = IndexDefinition{ KeyType: internal.ToString(indexDef[1]), Prefixes: internal.ToStringSlice(indexDef[3]), DefaultScore: internal.ToFloat(indexDef[5]), } + } else if indexDef, ok := data["index_definition"].(map[interface{}]interface{}); ok { + // RESP3 format + ftInfo.IndexDefinition = parseIndexDefinitionRESP3(indexDef) } ftInfo.IndexName = internal.ToString(data["index_name"]) - ftInfo.IndexOptions = internal.ToStringSlice(data["index_options"].([]interface{})) + if indexOptions, ok := data["index_options"].([]interface{}); ok { + ftInfo.IndexOptions = internal.ToStringSlice(indexOptions) + } ftInfo.Indexing = internal.ToInteger(data["indexing"]) ftInfo.InvertedSzMB = internal.ToFloat(data["inverted_sz_mb"]) ftInfo.KeyTableSizeMB = internal.ToFloat(data["key_table_size_mb"]) @@ -1760,16 +2093,16 @@ func parseFTInfo(data map[string]interface{}) (FTInfoResult, error) { ftInfo.NumRecords = internal.ToInteger(data["num_records"]) ftInfo.NumTerms = internal.ToInteger(data["num_terms"]) ftInfo.NumberOfUses = internal.ToInteger(data["number_of_uses"]) - ftInfo.OffsetBitsPerRecordAvg = internal.ToString(data["offset_bits_per_record_avg"]) + ftInfo.OffsetBitsPerRecordAvg = ftInfoNumString(data["offset_bits_per_record_avg"]) ftInfo.OffsetVectorsSzMB = internal.ToFloat(data["offset_vectors_sz_mb"]) - ftInfo.OffsetsPerTermAvg = internal.ToString(data["offsets_per_term_avg"]) + ftInfo.OffsetsPerTermAvg = ftInfoNumString(data["offsets_per_term_avg"]) ftInfo.PercentIndexed = internal.ToFloat(data["percent_indexed"]) - ftInfo.RecordsPerDocAvg = internal.ToString(data["records_per_doc_avg"]) + ftInfo.RecordsPerDocAvg = ftInfoNumString(data["records_per_doc_avg"]) ftInfo.SortableValuesSizeMB = internal.ToFloat(data["sortable_values_size_mb"]) ftInfo.TagOverheadSzMB = internal.ToFloat(data["tag_overhead_sz_mb"]) ftInfo.TextOverheadSzMB = internal.ToFloat(data["text_overhead_sz_mb"]) ftInfo.TotalIndexMemorySzMB = internal.ToFloat(data["total_index_memory_sz_mb"]) - ftInfo.TotalIndexingTime = internal.ToInteger(data["total_indexing_time"]) + ftInfo.TotalIndexingTime = ftInfoNumInt(data["total_indexing_time"]) ftInfo.TotalInvertedIndexBlocks = internal.ToInteger(data["total_inverted_index_blocks"]) ftInfo.VectorIndexSzMB = internal.ToFloat(data["vector_index_sz_mb"]) @@ -1815,6 +2148,37 @@ func (cmd *FTInfoCmd) RawResult() (interface{}, error) { return cmd.rawVal, cmd.err } func (cmd *FTInfoCmd) readReply(rd *proto.Reader) (err error) { + readType, err := rd.PeekReplyType() + if err != nil { + return err + } + + // RESP3 returns a map, RESP2 returns an array + if readType == proto.RespMap { + // Read raw response first for backwards compatibility + cmd.rawVal, err = rd.ReadReply() + if err != nil { + return err + } + + // Convert map[interface{}]interface{} to map[string]interface{} + rawMap, ok := cmd.rawVal.(map[interface{}]interface{}) + if !ok { + return fmt.Errorf("unexpected RESP3 response type: %T", cmd.rawVal) + } + + data := make(map[string]interface{}, len(rawMap)) + for k, v := range rawMap { + if kStr, ok := k.(string); ok { + data[kStr] = v + } + } + + cmd.val, err = parseFTInfo(data) + return err + } + + // RESP2 format - read as map n, err := rd.ReadMapLen() if err != nil { return err @@ -1841,11 +2205,7 @@ func (cmd *FTInfoCmd) readReply(rd *proto.Reader) (err error) { data[k] = v } cmd.val, err = parseFTInfo(data) - if err != nil { - return err - } - - return nil + return err } func (cmd *FTInfoCmd) Clone() Cmder { @@ -1991,15 +2351,117 @@ func (cmd *FTSpellCheckCmd) RawResult() (interface{}, error) { } func (cmd *FTSpellCheckCmd) readReply(rd *proto.Reader) (err error) { + readType, err := rd.PeekReplyType() + if err != nil { + return err + } + + // RESP3 returns a map, RESP2 returns an array + if readType == proto.RespMap { + // Read raw response first for backwards compatibility + cmd.rawVal, err = rd.ReadReply() + if err != nil { + return err + } + + // Parse the raw response into structured result + rawMap, ok := cmd.rawVal.(map[interface{}]interface{}) + if !ok { + return fmt.Errorf("unexpected RESP3 response type: %T", cmd.rawVal) + } + + cmd.val, err = parseFTSpellCheckRESP3(rawMap) + return err + } + + // RESP2 format data, err := rd.ReadSlice() if err != nil { return err } cmd.val, err = parseFTSpellCheck(data) - if err != nil { - return err + return err +} + +// parseFTSpellCheckRESP3 parses the RESP3 format response from FT.SPELLCHECK. +// RESP3 format: +// +// map{ +// "results": map{ +// "misspelled_term": [ +// map{"suggestion": score}, +// ... +// ], +// ... +// } +// } +func parseFTSpellCheckRESP3(data map[interface{}]interface{}) ([]SpellCheckResult, error) { + results := make([]SpellCheckResult, 0) + + resultsData, ok := data["results"] + if !ok { + return results, nil } - return nil + + resultsMap, ok := resultsData.(map[interface{}]interface{}) + if !ok { + return nil, fmt.Errorf("invalid results format: expected map, got %T", resultsData) + } + + for termKey, suggestionsData := range resultsMap { + term, ok := termKey.(string) + if !ok { + continue + } + + suggestionsArray, ok := suggestionsData.([]interface{}) + if !ok { + continue + } + + suggestions := make([]SpellCheckSuggestion, 0, len(suggestionsArray)) + for _, suggestionData := range suggestionsArray { + suggestionMap, ok := suggestionData.(map[interface{}]interface{}) + if !ok { + continue + } + + for suggKey, scoreVal := range suggestionMap { + suggestion, ok := suggKey.(string) + if !ok { + continue + } + + var score float64 + switch v := scoreVal.(type) { + case float64: + score = v + case int64: + score = float64(v) + case string: + var err error + score, err = strconv.ParseFloat(v, 64) + if err != nil { + continue + } + default: + continue + } + + suggestions = append(suggestions, SpellCheckSuggestion{ + Score: score, + Suggestion: suggestion, + }) + } + } + + results = append(results, SpellCheckResult{ + Term: term, + Suggestions: suggestions, + }) + } + + return results, nil } func parseFTSpellCheck(data []interface{}) ([]SpellCheckResult, error) { @@ -2205,15 +2667,145 @@ func (cmd *FTSearchCmd) RawResult() (interface{}, error) { } func (cmd *FTSearchCmd) readReply(rd *proto.Reader) (err error) { - data, err := rd.ReadSlice() + readType, err := rd.PeekReplyType() if err != nil { return err } - cmd.val, err = parseFTSearch(data, cmd.options.NoContent, cmd.options.WithScores, cmd.options.WithPayloads, cmd.options.WithSortKeys) + + // RESP3 returns a map, RESP2 returns an array + if readType == proto.RespMap { + // Read raw response first for backwards compatibility + cmd.rawVal, err = rd.ReadReply() + if err != nil { + return err + } + // Parse the raw response into structured result + if mapVal, ok := cmd.rawVal.(map[interface{}]interface{}); ok { + cmd.val, err = parseFTSearchMapRESP3(mapVal) + } else { + return fmt.Errorf("unexpected RESP3 response type: %T", cmd.rawVal) + } + return err + } + + // RESP2 format or error response - use ReadReply to handle errors properly + data, err := rd.ReadReply() if err != nil { return err } - return nil + if dataSlice, ok := data.([]interface{}); ok { + cmd.val, err = parseFTSearch(dataSlice, cmd.options.NoContent, cmd.options.WithScores, cmd.options.WithPayloads, cmd.options.WithSortKeys) + return err + } + return fmt.Errorf("unexpected response type: %T", data) +} + +// parseFTSearchMapRESP3 parses the RESP3 format response from FT.SEARCH. +// It takes a map[interface{}]interface{} which is the raw response from ReadReply(). +// RESP3 format: +// +// %5 +// $10 attributes => *0 +// $13 total_results => :N +// $6 format => $6 STRING +// $7 results => *N (array of maps with id, score, extra_attributes, values) +// $7 warning => *N (array of strings) +func parseFTSearchMapRESP3(data map[interface{}]interface{}) (FTSearchResult, error) { + var result FTSearchResult + result.Docs = make([]Document, 0) + + for k, v := range data { + key, ok := k.(string) + if !ok { + continue + } + + switch key { + case "total_results": + result.Total = internal.ToInteger(v) + case "results": + if resultsData, ok := v.([]interface{}); ok { + docs, err := parseFTSearchResultsMapRESP3(resultsData) + if err != nil { + return FTSearchResult{}, err + } + result.Docs = docs + } + case "warning": + if warningsData, ok := v.([]interface{}); ok { + result.Warnings = make([]string, 0, len(warningsData)) + for _, w := range warningsData { + if ws, ok := w.(string); ok { + result.Warnings = append(result.Warnings, ws) + } + } + } + // Ignore "attributes", "format", and other fields as per the spec + } + } + + return result, nil +} + +// parseFTSearchResultsMapRESP3 parses the results array from RESP3 FT.SEARCH response. +func parseFTSearchResultsMapRESP3(resultsData []interface{}) ([]Document, error) { + docs := make([]Document, 0, len(resultsData)) + for _, item := range resultsData { + if itemMap, ok := item.(map[interface{}]interface{}); ok { + doc, err := parseFTSearchDocumentMapRESP3(itemMap) + if err != nil { + return nil, err + } + docs = append(docs, doc) + } + } + return docs, nil +} + +// parseFTSearchDocumentMapRESP3 parses a single document from RESP3 FT.SEARCH response. +func parseFTSearchDocumentMapRESP3(itemMap map[interface{}]interface{}) (Document, error) { + doc := Document{ + Fields: make(map[string]string), + } + + for k, v := range itemMap { + key, ok := k.(string) + if !ok { + continue + } + + switch key { + case "id": + if id, ok := v.(string); ok { + doc.ID = id + } + case "score": + if score, ok := v.(float64); ok { + doc.Score = &score + } + case "payload": + if payload, ok := v.(string); ok { + doc.Payload = &payload + } + case "sortkey": + if sortKey, ok := v.(string); ok { + doc.SortKey = &sortKey + } + case "extra_attributes": + if extraAttrs, ok := v.(map[interface{}]interface{}); ok { + for ek, ev := range extraAttrs { + if ekStr, ok := ek.(string); ok { + if evStr, ok := ev.(string); ok { + doc.Fields[ekStr] = evStr + } + } + } + } + // Ignore "values" and other fields as per the spec + } + } + + return doc, nil } func (cmd *FTSearchCmd) Clone() Cmder { @@ -2237,6 +2829,10 @@ func (cmd *FTSearchCmd) Clone() Cmder { } } } + if cmd.val.Warnings != nil { + val.Warnings = make([]string, len(cmd.val.Warnings)) + copy(val.Warnings, cmd.val.Warnings) + } var options *FTSearchOptions if cmd.options != nil { options = &FTSearchOptions{ @@ -2886,6 +3482,30 @@ func (cmd *FTSynDumpCmd) RawResult() (interface{}, error) { } func (cmd *FTSynDumpCmd) readReply(rd *proto.Reader) error { + readType, err := rd.PeekReplyType() + if err != nil { + return err + } + + // RESP3 returns a map, RESP2 returns an array + if readType == proto.RespMap { + // Read raw response first for backwards compatibility + cmd.rawVal, err = rd.ReadReply() + if err != nil { + return err + } + + // Parse the raw response into structured result + rawMap, ok := cmd.rawVal.(map[interface{}]interface{}) + if !ok { + return fmt.Errorf("unexpected RESP3 response type: %T", cmd.rawVal) + } + + cmd.val, err = parseFTSynDumpRESP3(rawMap) + return err + } + + // RESP2 format termSynonymPairs, err := rd.ReadSlice() if err != nil { return err @@ -2922,6 +3542,44 @@ func (cmd *FTSynDumpCmd) readReply(rd *proto.Reader) error { return nil } +// parseFTSynDumpRESP3 parses the RESP3 format response from FT.SYNDUMP. +// RESP3 format: +// +// map{ +// "term1": ["synonym_group_id1", ...], +// "term2": ["synonym_group_id2", ...], +// ... +// } +func parseFTSynDumpRESP3(data map[interface{}]interface{}) ([]FTSynDumpResult, error) { + results := make([]FTSynDumpResult, 0, len(data)) + + for termKey, synonymsData := range data { + term, ok := termKey.(string) + if !ok { + continue + } + + synonymsArray, ok := synonymsData.([]interface{}) + if !ok { + continue + } + + synonymList := make([]string, 0, len(synonymsArray)) + for _, syn := range synonymsArray { + if synonym, ok := syn.(string); ok { + synonymList = append(synonymList, synonym) + } + } + + results = append(results, FTSynDumpResult{ + Term: term, + Synonyms: synonymList, + }) + } + + return results, nil +} + func (cmd *FTSynDumpCmd) Clone() Cmder { var val []FTSynDumpResult if cmd.val != nil { diff --git a/search_commands_parse_test.go b/search_commands_parse_test.go index 3a1abab1..d028ed0a 100644 --- a/search_commands_parse_test.go +++ b/search_commands_parse_test.go @@ -1,6 +1,7 @@ package redis import ( + "math" "testing" ) @@ -368,3 +369,1333 @@ func TestParseFTInfo(t *testing.T) { t.Logf("Successfully parsed FT.INFO response with %d attributes", len(result.Attributes)) } + +// toInterfaceMap converts map[string]interface{} to map[interface{}]interface{} +// This is needed because RESP3 returns map[interface{}]interface{} but test data +// is easier to write as map[string]interface{} +func toInterfaceMap(m map[string]interface{}) map[interface{}]interface{} { + result := make(map[interface{}]interface{}, len(m)) + for k, v := range m { + switch val := v.(type) { + case map[string]interface{}: + result[k] = toInterfaceMap(val) + case []interface{}: + result[k] = toInterfaceSlice(val) + default: + result[k] = v + } + } + return result +} + +// toInterfaceSlice converts []interface{} elements recursively +func toInterfaceSlice(s []interface{}) []interface{} { + result := make([]interface{}, len(s)) + for i, v := range s { + switch val := v.(type) { + case map[string]interface{}: + result[i] = toInterfaceMap(val) + case []interface{}: + result[i] = toInterfaceSlice(val) + default: + result[i] = v + } + } + return result +} + +// TestParseFTSearchRESP3 tests the RESP3 parsing for FT.SEARCH results +func TestParseFTSearchRESP3(t *testing.T) { + tests := []struct { + name string + input map[string]interface{} + expected FTSearchResult + }{ + { + name: "empty result", + input: map[string]interface{}{ + "attributes": []interface{}{}, + "total_results": int64(0), + "format": "STRING", + "results": []interface{}{}, + "warning": []interface{}{}, + }, + expected: FTSearchResult{ + Total: 0, + Docs: []Document{}, + Warnings: []string{}, + }, + }, + { + name: "simple query result", + input: map[string]interface{}{ + "attributes": []interface{}{}, + "total_results": int64(2), + "format": "STRING", + "results": []interface{}{ + map[string]interface{}{ + "id": "usr:david", + "extra_attributes": map[string]interface{}{ + "first_name": "David", + "last_name": "Maier", + }, + "values": []interface{}{}, + }, + map[string]interface{}{ + "id": "usr:shaya", + "extra_attributes": map[string]interface{}{ + "first_name": "Shaya", + "last_name": "Potter", + }, + "values": []interface{}{}, + }, + }, + "warning": []interface{}{}, + }, + expected: FTSearchResult{ + Total: 2, + Docs: []Document{ + { + ID: "usr:david", + Fields: map[string]string{"first_name": "David", "last_name": "Maier"}, + }, + { + ID: "usr:shaya", + Fields: map[string]string{"first_name": "Shaya", "last_name": "Potter"}, + }, + }, + Warnings: []string{}, + }, + }, + { + name: "result with scores", + input: map[string]interface{}{ + "attributes": []interface{}{}, + "total_results": int64(1), + "format": "STRING", + "results": []interface{}{ + map[string]interface{}{ + "id": "bicycle:hash:1", + "score": float64(1.5), + "extra_attributes": map[string]interface{}{ + "price": "1200", + "brand": "Bicyk", + }, + "values": []interface{}{}, + }, + }, + "warning": []interface{}{}, + }, + expected: FTSearchResult{ + Total: 1, + Docs: []Document{ + { + ID: "bicycle:hash:1", + Score: ptrFloat64(1.5), + Fields: map[string]string{"price": "1200", "brand": "Bicyk"}, + }, + }, + Warnings: []string{}, + }, + }, + { + name: "result with warnings", + input: map[string]interface{}{ + "attributes": []interface{}{}, + "total_results": int64(1), + "format": "STRING", + "results": []interface{}{ + map[string]interface{}{ + "id": "doc:1", + "extra_attributes": map[string]interface{}{ + "field1": "value1", + }, + "values": []interface{}{}, + }, + }, + "warning": []interface{}{"Timeout limit was reached"}, + }, + expected: FTSearchResult{ + Total: 1, + Docs: []Document{ + { + ID: "doc:1", + Fields: map[string]string{"field1": "value1"}, + }, + }, + Warnings: []string{"Timeout limit was reached"}, + }, + }, + { + name: "NOCONTENT result", + input: map[string]interface{}{ + "attributes": []interface{}{}, + "total_results": int64(10), + "format": "STRING", + "results": []interface{}{ + map[string]interface{}{ + "id": "bicycle:hash:2", + "values": []interface{}{}, + }, + }, + "warning": []interface{}{}, + }, + expected: FTSearchResult{ + Total: 10, + Docs: []Document{ + { + ID: "bicycle:hash:2", + Fields: map[string]string{}, + }, + }, + Warnings: []string{}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := parseFTSearchMapRESP3(toInterfaceMap(tt.input)) + if err != nil { + t.Fatalf("parseFTSearchMapRESP3() error = %v", err) + } + + if result.Total != tt.expected.Total { + t.Errorf("Total = %v, want %v", result.Total, tt.expected.Total) + } + + if len(result.Docs) != len(tt.expected.Docs) { + t.Errorf("len(Docs) = %v, want %v", len(result.Docs), len(tt.expected.Docs)) + } + + for i, doc := range result.Docs { + if doc.ID != tt.expected.Docs[i].ID { + t.Errorf("Docs[%d].ID = %v, want %v", i, doc.ID, tt.expected.Docs[i].ID) + } + if tt.expected.Docs[i].Score != nil { + if doc.Score == nil || *doc.Score != *tt.expected.Docs[i].Score { + t.Errorf("Docs[%d].Score = %v, want %v", i, doc.Score, tt.expected.Docs[i].Score) + } + } + for k, v := range tt.expected.Docs[i].Fields { + if doc.Fields[k] != v { + t.Errorf("Docs[%d].Fields[%s] = %v, want %v", i, k, doc.Fields[k], v) + } + } + } + + if len(result.Warnings) != len(tt.expected.Warnings) { + t.Errorf("len(Warnings) = %v, want %v", len(result.Warnings), len(tt.expected.Warnings)) + } + for i, w := range result.Warnings { + if w != tt.expected.Warnings[i] { + t.Errorf("Warnings[%d] = %v, want %v", i, w, tt.expected.Warnings[i]) + } + } + }) + } +} + +// TestParseFTAggregateRESP3 tests the RESP3 parsing for FT.AGGREGATE results +func TestParseFTAggregateRESP3(t *testing.T) { + tests := []struct { + name string + input map[string]interface{} + expected FTAggregateResult + }{ + { + name: "empty result", + input: map[string]interface{}{ + "attributes": []interface{}{}, + "total_results": int64(0), + "format": "STRING", + "results": []interface{}{}, + "warning": []interface{}{}, + }, + expected: FTAggregateResult{ + Total: 0, + Rows: []AggregateRow{}, + Warnings: []string{}, + }, + }, + { + name: "aggregation with groupby", + input: map[string]interface{}{ + "attributes": []interface{}{}, + "total_results": int64(3), + "format": "STRING", + "results": []interface{}{ + map[string]interface{}{ + "extra_attributes": map[string]interface{}{ + "condition": "refurbished", + "num_affordable": "158", + }, + "values": []interface{}{}, + }, + map[string]interface{}{ + "extra_attributes": map[string]interface{}{ + "condition": "used", + "num_affordable": "156", + }, + "values": []interface{}{}, + }, + map[string]interface{}{ + "extra_attributes": map[string]interface{}{ + "condition": "new", + "num_affordable": "158", + }, + "values": []interface{}{}, + }, + }, + "warning": []interface{}{}, + }, + expected: FTAggregateResult{ + Total: 3, + Rows: []AggregateRow{ + {Fields: map[string]interface{}{"condition": "refurbished", "num_affordable": "158"}}, + {Fields: map[string]interface{}{"condition": "used", "num_affordable": "156"}}, + {Fields: map[string]interface{}{"condition": "new", "num_affordable": "158"}}, + }, + Warnings: []string{}, + }, + }, + { + name: "aggregation with warnings", + input: map[string]interface{}{ + "attributes": []interface{}{}, + "total_results": int64(1), + "format": "STRING", + "results": []interface{}{ + map[string]interface{}{ + "extra_attributes": map[string]interface{}{ + "__key": "bicycle:0", + "price": "270", + "discounted": "243", + }, + "values": []interface{}{}, + }, + }, + "warning": []interface{}{"Timeout limit was reached"}, + }, + expected: FTAggregateResult{ + Total: 1, + Rows: []AggregateRow{ + {Fields: map[string]interface{}{"__key": "bicycle:0", "price": "270", "discounted": "243"}}, + }, + Warnings: []string{"Timeout limit was reached"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := parseFTAggregateMapRESP3(toInterfaceMap(tt.input)) + if err != nil { + t.Fatalf("parseFTAggregateMapRESP3() error = %v", err) + } + + if result.Total != tt.expected.Total { + t.Errorf("Total = %v, want %v", result.Total, tt.expected.Total) + } + + if len(result.Rows) != len(tt.expected.Rows) { + t.Errorf("len(Rows) = %v, want %v", len(result.Rows), len(tt.expected.Rows)) + } + + for i, row := range result.Rows { + for k, v := range tt.expected.Rows[i].Fields { + if row.Fields[k] != v { + t.Errorf("Rows[%d].Fields[%s] = %v, want %v", i, k, row.Fields[k], v) + } + } + } + + if len(result.Warnings) != len(tt.expected.Warnings) { + t.Errorf("len(Warnings) = %v, want %v", len(result.Warnings), len(tt.expected.Warnings)) + } + for i, w := range result.Warnings { + if w != tt.expected.Warnings[i] { + t.Errorf("Warnings[%d] = %v, want %v", i, w, tt.expected.Warnings[i]) + } + } + }) + } +} + +// Helper function to create a pointer to a float64 +func ptrFloat64(f float64) *float64 { + return &f +} + +// TestFTSearchRESP2AndRESP3Equivalence validates that RESP2 and RESP3 parsing +// produce semantically equivalent results as per the spec requirement R.1.1 +func TestFTSearchRESP2AndRESP3Equivalence(t *testing.T) { + tests := []struct { + name string + resp2Data []interface{} + resp3Data map[string]interface{} + noContent bool + withScores bool + }{ + { + name: "simple hash query", + resp2Data: []interface{}{ + int64(2), + "usr:david", + []interface{}{"first_name", "David", "last_name", "Maier"}, + "usr:shaya", + []interface{}{"first_name", "Shaya", "last_name", "Potter"}, + }, + resp3Data: map[string]interface{}{ + "attributes": []interface{}{}, + "total_results": int64(2), + "format": "STRING", + "results": []interface{}{ + map[string]interface{}{ + "id": "usr:david", + "extra_attributes": map[string]interface{}{ + "first_name": "David", + "last_name": "Maier", + }, + "values": []interface{}{}, + }, + map[string]interface{}{ + "id": "usr:shaya", + "extra_attributes": map[string]interface{}{ + "first_name": "Shaya", + "last_name": "Potter", + }, + "values": []interface{}{}, + }, + }, + "warning": []interface{}{}, + }, + noContent: false, + withScores: false, + }, + { + name: "query with scores", + resp2Data: []interface{}{ + int64(1), + "bicycle:hash:1", + "1.5", + []interface{}{"price", "1200", "brand", "Bicyk"}, + }, + resp3Data: map[string]interface{}{ + "attributes": []interface{}{}, + "total_results": int64(1), + "format": "STRING", + "results": []interface{}{ + map[string]interface{}{ + "id": "bicycle:hash:1", + "score": float64(1.5), + "extra_attributes": map[string]interface{}{ + "price": "1200", + "brand": "Bicyk", + }, + "values": []interface{}{}, + }, + }, + "warning": []interface{}{}, + }, + noContent: false, + withScores: true, + }, + { + name: "JSON document query", + resp2Data: []interface{}{ + int64(1), + "bicycle:1", + []interface{}{"$", `{"brand":"Bicyk","model":"Hillcraft","price":1200}`}, + }, + resp3Data: map[string]interface{}{ + "attributes": []interface{}{}, + "total_results": int64(1), + "format": "STRING", + "results": []interface{}{ + map[string]interface{}{ + "id": "bicycle:1", + "extra_attributes": map[string]interface{}{ + "$": `{"brand":"Bicyk","model":"Hillcraft","price":1200}`, + }, + "values": []interface{}{}, + }, + }, + "warning": []interface{}{}, + }, + noContent: false, + withScores: false, + }, + { + name: "NOCONTENT query", + resp2Data: []interface{}{ + int64(10), + "bicycle:hash:2", + }, + resp3Data: map[string]interface{}{ + "attributes": []interface{}{}, + "total_results": int64(10), + "format": "STRING", + "results": []interface{}{ + map[string]interface{}{ + "id": "bicycle:hash:2", + "values": []interface{}{}, + }, + }, + "warning": []interface{}{}, + }, + noContent: true, + withScores: false, + }, + { + name: "empty result", + resp2Data: []interface{}{ + int64(0), + }, + resp3Data: map[string]interface{}{ + "attributes": []interface{}{}, + "total_results": int64(0), + "format": "STRING", + "results": []interface{}{}, + "warning": []interface{}{}, + }, + noContent: false, + withScores: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Parse RESP2 + resp2Result, err := parseFTSearch(tt.resp2Data, tt.noContent, tt.withScores, false, false) + if err != nil { + t.Fatalf("parseFTSearch() error = %v", err) + } + + // Parse RESP3 + resp3Result, err := parseFTSearchMapRESP3(toInterfaceMap(tt.resp3Data)) + if err != nil { + t.Fatalf("parseFTSearchMapRESP3() error = %v", err) + } + + // Compare Total + if resp2Result.Total != resp3Result.Total { + t.Errorf("Total mismatch: RESP2=%d, RESP3=%d", resp2Result.Total, resp3Result.Total) + } + + // Compare number of documents + if len(resp2Result.Docs) != len(resp3Result.Docs) { + t.Errorf("Docs count mismatch: RESP2=%d, RESP3=%d", len(resp2Result.Docs), len(resp3Result.Docs)) + return + } + + // Compare each document + for i, resp2Doc := range resp2Result.Docs { + resp3Doc := resp3Result.Docs[i] + + if resp2Doc.ID != resp3Doc.ID { + t.Errorf("Docs[%d].ID mismatch: RESP2=%s, RESP3=%s", i, resp2Doc.ID, resp3Doc.ID) + } + + // Compare scores (if present) + if tt.withScores { + if resp2Doc.Score == nil && resp3Doc.Score != nil { + t.Errorf("Docs[%d].Score mismatch: RESP2=nil, RESP3=%v", i, *resp3Doc.Score) + } else if resp2Doc.Score != nil && resp3Doc.Score == nil { + t.Errorf("Docs[%d].Score mismatch: RESP2=%v, RESP3=nil", i, *resp2Doc.Score) + } else if resp2Doc.Score != nil && resp3Doc.Score != nil && *resp2Doc.Score != *resp3Doc.Score { + t.Errorf("Docs[%d].Score mismatch: RESP2=%v, RESP3=%v", i, *resp2Doc.Score, *resp3Doc.Score) + } + } + + // Compare fields + if len(resp2Doc.Fields) != len(resp3Doc.Fields) { + t.Errorf("Docs[%d].Fields count mismatch: RESP2=%d, RESP3=%d", i, len(resp2Doc.Fields), len(resp3Doc.Fields)) + } + + for k, v := range resp2Doc.Fields { + if resp3Doc.Fields[k] != v { + t.Errorf("Docs[%d].Fields[%s] mismatch: RESP2=%s, RESP3=%s", i, k, v, resp3Doc.Fields[k]) + } + } + } + }) + } +} + +// TestFTAggregateRESP2AndRESP3Equivalence validates that RESP2 and RESP3 parsing +// produce semantically equivalent results for FT.AGGREGATE +func TestFTAggregateRESP2AndRESP3Equivalence(t *testing.T) { + tests := []struct { + name string + resp2Data []interface{} + resp3Data map[string]interface{} + }{ + { + name: "aggregation with groupby", + resp2Data: []interface{}{ + int64(3), + []interface{}{"condition", "refurbished", "num_affordable", "158"}, + []interface{}{"condition", "used", "num_affordable", "156"}, + []interface{}{"condition", "new", "num_affordable", "158"}, + }, + resp3Data: map[string]interface{}{ + "attributes": []interface{}{}, + "total_results": int64(3), + "format": "STRING", + "results": []interface{}{ + map[string]interface{}{ + "extra_attributes": map[string]interface{}{ + "condition": "refurbished", + "num_affordable": "158", + }, + "values": []interface{}{}, + }, + map[string]interface{}{ + "extra_attributes": map[string]interface{}{ + "condition": "used", + "num_affordable": "156", + }, + "values": []interface{}{}, + }, + map[string]interface{}{ + "extra_attributes": map[string]interface{}{ + "condition": "new", + "num_affordable": "158", + }, + "values": []interface{}{}, + }, + }, + "warning": []interface{}{}, + }, + }, + { + name: "aggregation with apply", + resp2Data: []interface{}{ + int64(1), + []interface{}{"__key", "bicycle:0", "price", "270", "discounted", "243"}, + }, + resp3Data: map[string]interface{}{ + "attributes": []interface{}{}, + "total_results": int64(1), + "format": "STRING", + "results": []interface{}{ + map[string]interface{}{ + "extra_attributes": map[string]interface{}{ + "__key": "bicycle:0", + "price": "270", + "discounted": "243", + }, + "values": []interface{}{}, + }, + }, + "warning": []interface{}{}, + }, + }, + { + name: "empty result", + resp2Data: []interface{}{ + int64(0), + }, + resp3Data: map[string]interface{}{ + "attributes": []interface{}{}, + "total_results": int64(0), + "format": "STRING", + "results": []interface{}{}, + "warning": []interface{}{}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Parse RESP2 + resp2Result, err := ProcessAggregateResult(tt.resp2Data) + if err != nil { + t.Fatalf("ProcessAggregateResult() error = %v", err) + } + + // Parse RESP3 + resp3Result, err := parseFTAggregateMapRESP3(toInterfaceMap(tt.resp3Data)) + if err != nil { + t.Fatalf("parseFTAggregateMapRESP3() error = %v", err) + } + + // Compare Total + if resp2Result.Total != resp3Result.Total { + t.Errorf("Total mismatch: RESP2=%d, RESP3=%d", resp2Result.Total, resp3Result.Total) + } + + // Compare number of rows + if len(resp2Result.Rows) != len(resp3Result.Rows) { + t.Errorf("Rows count mismatch: RESP2=%d, RESP3=%d", len(resp2Result.Rows), len(resp3Result.Rows)) + return + } + + // Compare each row + for i, resp2Row := range resp2Result.Rows { + resp3Row := resp3Result.Rows[i] + + // Compare fields count + if len(resp2Row.Fields) != len(resp3Row.Fields) { + t.Errorf("Rows[%d].Fields count mismatch: RESP2=%d, RESP3=%d", i, len(resp2Row.Fields), len(resp3Row.Fields)) + } + + // Compare field values + for k, v := range resp2Row.Fields { + resp3Val, ok := resp3Row.Fields[k] + if !ok { + t.Errorf("Rows[%d].Fields[%s] missing in RESP3", i, k) + continue + } + if v != resp3Val { + t.Errorf("Rows[%d].Fields[%s] mismatch: RESP2=%v, RESP3=%v", i, k, v, resp3Val) + } + } + } + }) + } +} + +// TestParseFTSpellCheckRESP3 tests the RESP3 parsing for FT.SPELLCHECK results +func TestParseFTSpellCheckRESP3(t *testing.T) { + tests := []struct { + name string + input map[interface{}]interface{} + expected []SpellCheckResult + }{ + { + name: "empty result", + input: map[interface{}]interface{}{ + "results": map[interface{}]interface{}{}, + }, + expected: []SpellCheckResult{}, + }, + { + name: "single misspelled term with one suggestion", + input: map[interface{}]interface{}{ + "results": map[interface{}]interface{}{ + "impornant": []interface{}{ + map[interface{}]interface{}{"important": float64(0.5)}, + }, + }, + }, + expected: []SpellCheckResult{ + { + Term: "impornant", + Suggestions: []SpellCheckSuggestion{ + {Score: 0.5, Suggestion: "important"}, + }, + }, + }, + }, + { + name: "single misspelled term with multiple suggestions", + input: map[interface{}]interface{}{ + "results": map[interface{}]interface{}{ + "helo": []interface{}{ + map[interface{}]interface{}{"hello": float64(0.8)}, + map[interface{}]interface{}{"help": float64(0.3)}, + }, + }, + }, + expected: []SpellCheckResult{ + { + Term: "helo", + Suggestions: []SpellCheckSuggestion{ + {Score: 0.8, Suggestion: "hello"}, + {Score: 0.3, Suggestion: "help"}, + }, + }, + }, + }, + { + name: "no results key", + input: map[interface{}]interface{}{}, + expected: []SpellCheckResult{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := parseFTSpellCheckRESP3(tt.input) + if err != nil { + t.Fatalf("parseFTSpellCheckRESP3 returned error: %v", err) + } + + if len(result) != len(tt.expected) { + t.Fatalf("expected %d results, got %d", len(tt.expected), len(result)) + } + + // For single result tests, verify the content + if len(tt.expected) == 1 && len(result) == 1 { + if result[0].Term != tt.expected[0].Term { + t.Errorf("expected term %q, got %q", tt.expected[0].Term, result[0].Term) + } + if len(result[0].Suggestions) != len(tt.expected[0].Suggestions) { + t.Errorf("expected %d suggestions, got %d", len(tt.expected[0].Suggestions), len(result[0].Suggestions)) + } + for i, sugg := range tt.expected[0].Suggestions { + if i < len(result[0].Suggestions) { + if result[0].Suggestions[i].Suggestion != sugg.Suggestion { + t.Errorf("suggestion[%d]: expected %q, got %q", i, sugg.Suggestion, result[0].Suggestions[i].Suggestion) + } + if result[0].Suggestions[i].Score != sugg.Score { + t.Errorf("suggestion[%d] score: expected %f, got %f", i, sugg.Score, result[0].Suggestions[i].Score) + } + } + } + } + }) + } +} + +// TestParseFTSynDumpRESP3 tests the RESP3 parsing for FT.SYNDUMP results +func TestParseFTSynDumpRESP3(t *testing.T) { + tests := []struct { + name string + input map[interface{}]interface{} + expected []FTSynDumpResult + }{ + { + name: "empty result", + input: map[interface{}]interface{}{}, + expected: []FTSynDumpResult{}, + }, + { + name: "single term with single synonym group", + input: map[interface{}]interface{}{ + "baby": []interface{}{"id1"}, + }, + expected: []FTSynDumpResult{ + {Term: "baby", Synonyms: []string{"id1"}}, + }, + }, + { + name: "single term with multiple synonym groups", + input: map[interface{}]interface{}{ + "child": []interface{}{"id1", "id2"}, + }, + expected: []FTSynDumpResult{ + {Term: "child", Synonyms: []string{"id1", "id2"}}, + }, + }, + { + name: "multiple terms", + input: map[interface{}]interface{}{ + "baby": []interface{}{"id1"}, + "child": []interface{}{"id1"}, + "offspring": []interface{}{"id1"}, + }, + expected: []FTSynDumpResult{ + {Term: "baby", Synonyms: []string{"id1"}}, + {Term: "child", Synonyms: []string{"id1"}}, + {Term: "offspring", Synonyms: []string{"id1"}}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := parseFTSynDumpRESP3(tt.input) + if err != nil { + t.Fatalf("parseFTSynDumpRESP3 returned error: %v", err) + } + + if len(result) != len(tt.expected) { + t.Fatalf("expected %d results, got %d", len(tt.expected), len(result)) + } + + // Create a map for easier lookup since map iteration order is not guaranteed + resultMap := make(map[string][]string) + for _, r := range result { + resultMap[r.Term] = r.Synonyms + } + + for _, exp := range tt.expected { + synonyms, ok := resultMap[exp.Term] + if !ok { + t.Errorf("expected term %q not found in result", exp.Term) + continue + } + if len(synonyms) != len(exp.Synonyms) { + t.Errorf("term %q: expected %d synonyms, got %d", exp.Term, len(exp.Synonyms), len(synonyms)) + continue + } + for i, syn := range exp.Synonyms { + if synonyms[i] != syn { + t.Errorf("term %q synonym[%d]: expected %q, got %q", exp.Term, i, syn, synonyms[i]) + } + } + } + }) + } +} + +// TestFTInfoRESP3Parsing tests that FTInfoCmd correctly parses RESP3 format +func TestFTInfoRESP3Parsing(t *testing.T) { + // Test data simulating RESP3 response (map[interface{}]interface{}) + input := map[interface{}]interface{}{ + "index_name": "test_idx", + "index_options": []interface{}{}, + "index_definition": map[interface{}]interface{}{ + "key_type": "HASH", + "prefixes": []interface{}{"doc:"}, + "default_score": float64(1), + }, + "attributes": []interface{}{ + map[interface{}]interface{}{ + "identifier": "txt", + "attribute": "txt", + "type": "TEXT", + "WEIGHT": float64(1), + "flags": []interface{}{"SORTABLE"}, + }, + }, + "num_docs": int64(100), + "max_doc_id": int64(100), + "num_terms": int64(500), + "num_records": int64(1000), + "inverted_sz_mb": float64(0.5), + "total_inverted_index_blocks": int64(50), + "offset_vectors_sz_mb": float64(0.1), + "doc_table_size_mb": float64(0.2), + "sortable_values_size_mb": float64(0.05), + "key_table_size_mb": float64(0.01), + "records_per_doc_avg": float64(10), + "bytes_per_record_avg": float64(50), + "offsets_per_term_avg": float64(2), + "offset_bits_per_record_avg": float64(8), + "hash_indexing_failures": int64(0), + "total_indexing_time": float64(123), + "indexing": int64(0), + "percent_indexed": float64(1), + "number_of_uses": int64(10), + "cleaning": int64(0), + // RESP3 emits gc_stats as a map of doubles (REPLY_KVNUM in RediSearch). + "gc_stats": map[interface{}]interface{}{ + "bytes_collected": float64(1024), + "total_ms_run": float64(50), + "total_cycles": float64(3), + "average_cycle_time_ms": float64(16.5), + "last_run_time_ms": float64(20), + "gc_numeric_trees_missed": float64(0), + "gc_blocks_denied": float64(2), + }, + } + + // Convert to map[string]interface{} as parseFTInfo expects + data := make(map[string]interface{}, len(input)) + for k, v := range input { + if kStr, ok := k.(string); ok { + data[kStr] = v + } + } + + result, err := parseFTInfo(data) + if err != nil { + t.Fatalf("parseFTInfo returned error: %v", err) + } + + if result.IndexName != "test_idx" { + t.Errorf("expected IndexName 'test_idx', got %q", result.IndexName) + } + + if result.NumDocs != 100 { + t.Errorf("expected NumDocs 100, got %d", result.NumDocs) + } + + if len(result.Attributes) != 1 { + t.Fatalf("expected 1 attribute, got %d", len(result.Attributes)) + } + + if result.Attributes[0].Attribute != "txt" { + t.Errorf("expected attribute 'txt', got %q", result.Attributes[0].Attribute) + } + + // RediSearch emits the *_avg fields via REPLY_KVNUM (doubles in RESP3). + // The Go struct types them as string, so they must be stringified during parse. + if result.RecordsPerDocAvg != "10" { + t.Errorf("RecordsPerDocAvg = %q, want %q", result.RecordsPerDocAvg, "10") + } + if result.BytesPerRecordAvg != "50" { + t.Errorf("BytesPerRecordAvg = %q, want %q", result.BytesPerRecordAvg, "50") + } + if result.OffsetsPerTermAvg != "2" { + t.Errorf("OffsetsPerTermAvg = %q, want %q", result.OffsetsPerTermAvg, "2") + } + if result.OffsetBitsPerRecordAvg != "8" { + t.Errorf("OffsetBitsPerRecordAvg = %q, want %q", result.OffsetBitsPerRecordAvg, "8") + } + + // total_indexing_time is also a REPLY_KVNUM (double) but typed as int. + if result.TotalIndexingTime != 123 { + t.Errorf("TotalIndexingTime = %d, want 123", result.TotalIndexingTime) + } + + // gc_stats numeric fields are typed as int but emitted as doubles in RESP3. + if result.GCStats.BytesCollected != 1024 { + t.Errorf("GCStats.BytesCollected = %d, want 1024", result.GCStats.BytesCollected) + } + if result.GCStats.TotalMsRun != 50 { + t.Errorf("GCStats.TotalMsRun = %d, want 50", result.GCStats.TotalMsRun) + } + if result.GCStats.TotalCycles != 3 { + t.Errorf("GCStats.TotalCycles = %d, want 3", result.GCStats.TotalCycles) + } + if result.GCStats.LastRunTimeMs != 20 { + t.Errorf("GCStats.LastRunTimeMs = %d, want 20", result.GCStats.LastRunTimeMs) + } + if result.GCStats.GCBlocksDenied != 2 { + t.Errorf("GCStats.GCBlocksDenied = %d, want 2", result.GCStats.GCBlocksDenied) + } + if result.GCStats.AverageCycleTimeMs != "16.5" { + t.Errorf("GCStats.AverageCycleTimeMs = %q, want %q", result.GCStats.AverageCycleTimeMs, "16.5") + } +} + +// TestFTInfoRESP3NaN verifies that RediSearch's NaN doubles (emitted for empty +// indexes) survive parsing as the lowercase "nan" sentinel, matching RESP2. +func TestFTInfoRESP3NaN(t *testing.T) { + data := map[string]interface{}{ + "index_name": "empty_idx", + "records_per_doc_avg": math.NaN(), + "bytes_per_record_avg": math.NaN(), + "offsets_per_term_avg": math.NaN(), + "offset_bits_per_record_avg": math.NaN(), + } + + result, err := parseFTInfo(data) + if err != nil { + t.Fatalf("parseFTInfo returned error: %v", err) + } + + for _, tc := range []struct { + name, got string + }{ + {"RecordsPerDocAvg", result.RecordsPerDocAvg}, + {"BytesPerRecordAvg", result.BytesPerRecordAvg}, + {"OffsetsPerTermAvg", result.OffsetsPerTermAvg}, + {"OffsetBitsPerRecordAvg", result.OffsetBitsPerRecordAvg}, + } { + if tc.got != "nan" { + t.Errorf("%s = %q, want %q", tc.name, tc.got, "nan") + } + } +} + +// TestFTSpellCheckRESP2AndRESP3Equivalence validates that RESP2 and RESP3 parsing +// produce semantically equivalent results for FT.SPELLCHECK +func TestFTSpellCheckRESP2AndRESP3Equivalence(t *testing.T) { + tests := []struct { + name string + resp2Data []interface{} + resp3Data map[interface{}]interface{} + }{ + { + name: "single misspelled term with suggestions", + // RESP2: [["TERM", "impornant", [["0.5", "important"]]]] + resp2Data: []interface{}{ + []interface{}{ + "TERM", + "impornant", + []interface{}{ + []interface{}{"0.5", "important"}, + }, + }, + }, + // RESP3: {"results": {"impornant": [{"important": 0.5}]}} + resp3Data: map[interface{}]interface{}{ + "results": map[interface{}]interface{}{ + "impornant": []interface{}{ + map[interface{}]interface{}{"important": float64(0.5)}, + }, + }, + }, + }, + { + name: "multiple suggestions", + // RESP2: [["TERM", "helo", [["0.8", "hello"], ["0.3", "help"]]]] + resp2Data: []interface{}{ + []interface{}{ + "TERM", + "helo", + []interface{}{ + []interface{}{"0.8", "hello"}, + []interface{}{"0.3", "help"}, + }, + }, + }, + // RESP3: {"results": {"helo": [{"hello": 0.8}, {"help": 0.3}]}} + resp3Data: map[interface{}]interface{}{ + "results": map[interface{}]interface{}{ + "helo": []interface{}{ + map[interface{}]interface{}{"hello": float64(0.8)}, + map[interface{}]interface{}{"help": float64(0.3)}, + }, + }, + }, + }, + { + name: "empty result", + resp2Data: []interface{}{}, + resp3Data: map[interface{}]interface{}{ + "results": map[interface{}]interface{}{}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Parse RESP2 + resp2Result, err := parseFTSpellCheck(tt.resp2Data) + if err != nil { + t.Fatalf("parseFTSpellCheck (RESP2) returned error: %v", err) + } + + // Parse RESP3 + resp3Result, err := parseFTSpellCheckRESP3(tt.resp3Data) + if err != nil { + t.Fatalf("parseFTSpellCheckRESP3 returned error: %v", err) + } + + // Compare results + if len(resp2Result) != len(resp3Result) { + t.Fatalf("result count mismatch: RESP2=%d, RESP3=%d", len(resp2Result), len(resp3Result)) + } + + // For non-empty results, compare content + if len(resp2Result) > 0 { + // Create maps for comparison since order may differ + resp2Map := make(map[string][]SpellCheckSuggestion) + for _, r := range resp2Result { + resp2Map[r.Term] = r.Suggestions + } + resp3Map := make(map[string][]SpellCheckSuggestion) + for _, r := range resp3Result { + resp3Map[r.Term] = r.Suggestions + } + + for term, resp2Suggs := range resp2Map { + resp3Suggs, ok := resp3Map[term] + if !ok { + t.Errorf("term %q found in RESP2 but not in RESP3", term) + continue + } + if len(resp2Suggs) != len(resp3Suggs) { + t.Errorf("term %q: suggestion count mismatch: RESP2=%d, RESP3=%d", term, len(resp2Suggs), len(resp3Suggs)) + continue + } + // Compare suggestions (order may differ) + resp2SuggMap := make(map[string]float64) + for _, s := range resp2Suggs { + resp2SuggMap[s.Suggestion] = s.Score + } + for _, s := range resp3Suggs { + if score, ok := resp2SuggMap[s.Suggestion]; !ok { + t.Errorf("term %q: suggestion %q found in RESP3 but not in RESP2", term, s.Suggestion) + } else if score != s.Score { + t.Errorf("term %q suggestion %q: score mismatch: RESP2=%f, RESP3=%f", term, s.Suggestion, score, s.Score) + } + } + } + } + }) + } +} + +// TestFTSynDumpRESP2AndRESP3Equivalence validates that RESP2 and RESP3 parsing +// produce semantically equivalent results for FT.SYNDUMP +func TestFTSynDumpRESP2AndRESP3Equivalence(t *testing.T) { + tests := []struct { + name string + resp2Data []interface{} + resp3Data map[interface{}]interface{} + }{ + { + name: "single term with single synonym group", + // RESP2: ["baby", ["id1"]] + resp2Data: []interface{}{ + "baby", []interface{}{"id1"}, + }, + // RESP3: {"baby": ["id1"]} + resp3Data: map[interface{}]interface{}{ + "baby": []interface{}{"id1"}, + }, + }, + { + name: "multiple terms", + // RESP2: ["baby", ["id1"], "child", ["id1"], "offspring", ["id1"]] + resp2Data: []interface{}{ + "baby", []interface{}{"id1"}, + "child", []interface{}{"id1"}, + "offspring", []interface{}{"id1"}, + }, + // RESP3: {"baby": ["id1"], "child": ["id1"], "offspring": ["id1"]} + resp3Data: map[interface{}]interface{}{ + "baby": []interface{}{"id1"}, + "child": []interface{}{"id1"}, + "offspring": []interface{}{"id1"}, + }, + }, + { + name: "empty result", + resp2Data: []interface{}{}, + resp3Data: map[interface{}]interface{}{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Parse RESP2 manually (simulating what readReply does) + var resp2Result []FTSynDumpResult + for i := 0; i < len(tt.resp2Data); i += 2 { + term, ok := tt.resp2Data[i].(string) + if !ok { + t.Fatalf("invalid RESP2 term format") + } + synonyms, ok := tt.resp2Data[i+1].([]interface{}) + if !ok { + t.Fatalf("invalid RESP2 synonyms format") + } + synonymList := make([]string, len(synonyms)) + for j, syn := range synonyms { + synonymList[j] = syn.(string) + } + resp2Result = append(resp2Result, FTSynDumpResult{ + Term: term, + Synonyms: synonymList, + }) + } + + // Parse RESP3 + resp3Result, err := parseFTSynDumpRESP3(tt.resp3Data) + if err != nil { + t.Fatalf("parseFTSynDumpRESP3 returned error: %v", err) + } + + // Compare results + if len(resp2Result) != len(resp3Result) { + t.Fatalf("result count mismatch: RESP2=%d, RESP3=%d", len(resp2Result), len(resp3Result)) + } + + // Create maps for comparison since order may differ + resp2Map := make(map[string][]string) + for _, r := range resp2Result { + resp2Map[r.Term] = r.Synonyms + } + resp3Map := make(map[string][]string) + for _, r := range resp3Result { + resp3Map[r.Term] = r.Synonyms + } + + for term, resp2Syns := range resp2Map { + resp3Syns, ok := resp3Map[term] + if !ok { + t.Errorf("term %q found in RESP2 but not in RESP3", term) + continue + } + if len(resp2Syns) != len(resp3Syns) { + t.Errorf("term %q: synonym count mismatch: RESP2=%d, RESP3=%d", term, len(resp2Syns), len(resp3Syns)) + continue + } + for i, syn := range resp2Syns { + if resp3Syns[i] != syn { + t.Errorf("term %q synonym[%d]: RESP2=%q, RESP3=%q", term, i, syn, resp3Syns[i]) + } + } + } + }) + } +} + +// TestFTInfoRESP2AndRESP3Equivalence validates that RESP2 and RESP3 parsing +// produce semantically equivalent results for FT.INFO +func TestFTInfoRESP2AndRESP3Equivalence(t *testing.T) { + // RESP2 format: attributes are arrays like ["identifier", "txt", "attribute", "txt", "type", "TEXT", ...] + resp2Data := map[string]interface{}{ + "index_name": "test_idx", + "index_options": []interface{}{}, + "attributes": []interface{}{ + []interface{}{ + "identifier", "txt", + "attribute", "txt", + "type", "TEXT", + "WEIGHT", float64(1), + "SORTABLE", + "NOSTEM", + }, + }, + "num_docs": int64(100), + "num_terms": int64(500), + "num_records": int64(1000), + } + + // RESP3 format: attributes are maps + resp3Data := map[string]interface{}{ + "index_name": "test_idx", + "index_options": []interface{}{}, + "attributes": []interface{}{ + map[interface{}]interface{}{ + "identifier": "txt", + "attribute": "txt", + "type": "TEXT", + "WEIGHT": float64(1), + "flags": []interface{}{"SORTABLE", "NOSTEM"}, + }, + }, + "num_docs": int64(100), + "num_terms": int64(500), + "num_records": int64(1000), + } + + // Parse RESP2 + resp2Result, err := parseFTInfo(resp2Data) + if err != nil { + t.Fatalf("parseFTInfo (RESP2) returned error: %v", err) + } + + // Parse RESP3 + resp3Result, err := parseFTInfo(resp3Data) + if err != nil { + t.Fatalf("parseFTInfo (RESP3) returned error: %v", err) + } + + // Compare results + if resp2Result.IndexName != resp3Result.IndexName { + t.Errorf("IndexName mismatch: RESP2=%q, RESP3=%q", resp2Result.IndexName, resp3Result.IndexName) + } + + if resp2Result.NumDocs != resp3Result.NumDocs { + t.Errorf("NumDocs mismatch: RESP2=%d, RESP3=%d", resp2Result.NumDocs, resp3Result.NumDocs) + } + + if resp2Result.NumTerms != resp3Result.NumTerms { + t.Errorf("NumTerms mismatch: RESP2=%d, RESP3=%d", resp2Result.NumTerms, resp3Result.NumTerms) + } + + if len(resp2Result.Attributes) != len(resp3Result.Attributes) { + t.Fatalf("Attributes count mismatch: RESP2=%d, RESP3=%d", len(resp2Result.Attributes), len(resp3Result.Attributes)) + } + + if len(resp2Result.Attributes) > 0 { + attr2 := resp2Result.Attributes[0] + attr3 := resp3Result.Attributes[0] + + if attr2.Attribute != attr3.Attribute { + t.Errorf("Attribute name mismatch: RESP2=%q, RESP3=%q", attr2.Attribute, attr3.Attribute) + } + if attr2.Identifier != attr3.Identifier { + t.Errorf("Identifier mismatch: RESP2=%q, RESP3=%q", attr2.Identifier, attr3.Identifier) + } + if attr2.Type != attr3.Type { + t.Errorf("Type mismatch: RESP2=%q, RESP3=%q", attr2.Type, attr3.Type) + } + if attr2.Sortable != attr3.Sortable { + t.Errorf("Sortable mismatch: RESP2=%v, RESP3=%v", attr2.Sortable, attr3.Sortable) + } + if attr2.NoStem != attr3.NoStem { + t.Errorf("NoStem mismatch: RESP2=%v, RESP3=%v", attr2.NoStem, attr3.NoStem) + } + } +} diff --git a/search_resp_test.go b/search_resp_test.go new file mode 100644 index 00000000..531a3157 --- /dev/null +++ b/search_resp_test.go @@ -0,0 +1,372 @@ +package redis_test + +import ( + "context" + "testing" + + "github.com/redis/go-redis/v9" +) + +// TestSearchCommandsRESP2AndRESP3Equivalence tests that search commands +// return equivalent results for both RESP2 and RESP3 protocols. +func TestSearchCommandsRESP2AndRESP3Equivalence(t *testing.T) { + ctx := context.Background() + + // RESP2 client + client2 := redis.NewClient(&redis.Options{ + Addr: "localhost:6379", + Protocol: 2, + }) + defer client2.Close() + + // RESP3 client + client3 := redis.NewClient(&redis.Options{ + Addr: "localhost:6379", + Protocol: 3, + }) + defer client3.Close() + + // Check connection + if err := client2.Ping(ctx).Err(); err != nil { + t.Skipf("Redis not available: %v", err) + } + + // Clean up before test + client2.FTDropIndex(ctx, "test-idx") + client2.Del(ctx, "doc1", "doc2", "doc3") + + t.Run("FTInfo", func(t *testing.T) { + // Create index + _, err := client2.FTCreate(ctx, "test-idx", + &redis.FTCreateOptions{}, + &redis.FieldSchema{FieldName: "title", FieldType: redis.SearchFieldTypeText, Sortable: true}, + &redis.FieldSchema{FieldName: "score", FieldType: redis.SearchFieldTypeNumeric, Sortable: true}, + ).Result() + if err != nil { + t.Fatalf("FTCreate failed: %v", err) + } + defer client2.FTDropIndex(ctx, "test-idx") + + // Add some documents + client2.HSet(ctx, "doc1", "title", "hello world", "score", 100) + client2.HSet(ctx, "doc2", "title", "foo bar", "score", 200) + + // Wait for indexing + waitForIndexing(t, client2, "test-idx", 2) + + // Get FTInfo from both protocols + info2, err := client2.FTInfo(ctx, "test-idx").Result() + if err != nil { + t.Fatalf("FTInfo RESP2 failed: %v", err) + } + + info3, err := client3.FTInfo(ctx, "test-idx").Result() + if err != nil { + t.Fatalf("FTInfo RESP3 failed: %v", err) + } + + // Compare key fields + if info2.IndexName != info3.IndexName { + t.Errorf("IndexName mismatch: RESP2=%q, RESP3=%q", info2.IndexName, info3.IndexName) + } + if info2.NumDocs != info3.NumDocs { + t.Errorf("NumDocs mismatch: RESP2=%d, RESP3=%d", info2.NumDocs, info3.NumDocs) + } + if len(info2.Attributes) != len(info3.Attributes) { + t.Errorf("Attributes length mismatch: RESP2=%d, RESP3=%d", len(info2.Attributes), len(info3.Attributes)) + } + + // Check IndexDefinition + if info2.IndexDefinition.KeyType != info3.IndexDefinition.KeyType { + t.Errorf("IndexDefinition.KeyType mismatch: RESP2=%q, RESP3=%q", + info2.IndexDefinition.KeyType, info3.IndexDefinition.KeyType) + } + if info2.IndexDefinition.DefaultScore != info3.IndexDefinition.DefaultScore { + t.Errorf("IndexDefinition.DefaultScore mismatch: RESP2=%v, RESP3=%v", + info2.IndexDefinition.DefaultScore, info3.IndexDefinition.DefaultScore) + } + + // Check CursorStats + if info2.CursorStats != info3.CursorStats { + t.Errorf("CursorStats mismatch: RESP2=%+v, RESP3=%+v", info2.CursorStats, info3.CursorStats) + } + + // Check IndexErrors + if info2.IndexErrors != info3.IndexErrors { + t.Errorf("IndexErrors mismatch: RESP2=%+v, RESP3=%+v", info2.IndexErrors, info3.IndexErrors) + } + + // Check DialectStats + if len(info2.DialectStats) != len(info3.DialectStats) { + t.Errorf("DialectStats length mismatch: RESP2=%d, RESP3=%d", len(info2.DialectStats), len(info3.DialectStats)) + } + for k, v := range info2.DialectStats { + if info3.DialectStats[k] != v { + t.Errorf("DialectStats[%s] mismatch: RESP2=%d, RESP3=%d", k, v, info3.DialectStats[k]) + } + } + + // Check GCStats + if info2.GCStats != info3.GCStats { + t.Errorf("GCStats mismatch: RESP2=%+v, RESP3=%+v", info2.GCStats, info3.GCStats) + } + + // The four *_avg fields are emitted by RediSearch via REPLY_KVNUM + // (RedisModule_ReplyWithDouble): bulk strings in RESP2, native doubles + // in RESP3. The FTInfoResult fields are typed as string, so the RESP3 + // path must stringify the double; if it does not the field is "" and + // the data is silently lost. Assert both that the RESP3 value is + // non-empty and that it matches the RESP2 value. + avgFields := []struct { + name string + resp2Val string + resp3Val string + }{ + {"BytesPerRecordAvg", info2.BytesPerRecordAvg, info3.BytesPerRecordAvg}, + {"RecordsPerDocAvg", info2.RecordsPerDocAvg, info3.RecordsPerDocAvg}, + {"OffsetsPerTermAvg", info2.OffsetsPerTermAvg, info3.OffsetsPerTermAvg}, + {"OffsetBitsPerRecordAvg", info2.OffsetBitsPerRecordAvg, info3.OffsetBitsPerRecordAvg}, + } + for _, f := range avgFields { + if f.resp3Val == "" { + t.Errorf("%s is empty under RESP3 (RESP2=%q) - silent data loss", f.name, f.resp2Val) + } + if f.resp2Val != f.resp3Val { + t.Errorf("%s mismatch: RESP2=%q, RESP3=%q", f.name, f.resp2Val, f.resp3Val) + } + } + + // total_indexing_time is also emitted via REPLY_KVNUM (double) but the + // FTInfoResult field is typed as int. Without explicit float-to-int + // handling in the RESP3 parser, this field is silently 0. + if info2.TotalIndexingTime != info3.TotalIndexingTime { + t.Errorf("TotalIndexingTime mismatch: RESP2=%d, RESP3=%d", + info2.TotalIndexingTime, info3.TotalIndexingTime) + } + + // All gc_stats numeric fields are emitted via REPLY_KVNUM in RediSearch + // (see src/fork_gc/fork_gc.c statsCb) but typed as int in GCStats. + // Compare each individually so a regression surfaces with a precise message. + gcFields := []struct { + name string + resp2, resp3 int + }{ + {"BytesCollected", info2.GCStats.BytesCollected, info3.GCStats.BytesCollected}, + {"TotalMsRun", info2.GCStats.TotalMsRun, info3.GCStats.TotalMsRun}, + {"TotalCycles", info2.GCStats.TotalCycles, info3.GCStats.TotalCycles}, + {"LastRunTimeMs", info2.GCStats.LastRunTimeMs, info3.GCStats.LastRunTimeMs}, + {"GCNumericTreesMissed", info2.GCStats.GCNumericTreesMissed, info3.GCStats.GCNumericTreesMissed}, + {"GCBlocksDenied", info2.GCStats.GCBlocksDenied, info3.GCStats.GCBlocksDenied}, + } + for _, f := range gcFields { + if f.resp2 != f.resp3 { + t.Errorf("GCStats.%s mismatch: RESP2=%d, RESP3=%d", f.name, f.resp2, f.resp3) + } + } + }) + + t.Run("FTSearch", func(t *testing.T) { + // Create index + _, err := client2.FTCreate(ctx, "test-idx", + &redis.FTCreateOptions{}, + &redis.FieldSchema{FieldName: "title", FieldType: redis.SearchFieldTypeText}, + &redis.FieldSchema{FieldName: "score", FieldType: redis.SearchFieldTypeNumeric}, + ).Result() + if err != nil { + t.Fatalf("FTCreate failed: %v", err) + } + defer client2.FTDropIndex(ctx, "test-idx") + + // Add documents + client2.HSet(ctx, "doc1", "title", "hello world", "score", 100) + client2.HSet(ctx, "doc2", "title", "hello redis", "score", 200) + client2.HSet(ctx, "doc3", "title", "goodbye world", "score", 300) + + // Wait for indexing + waitForIndexing(t, client2, "test-idx", 3) + + // Search from both protocols + result2, err := client2.FTSearch(ctx, "test-idx", "hello").Result() + if err != nil { + t.Fatalf("FTSearch RESP2 failed: %v", err) + } + + result3, err := client3.FTSearch(ctx, "test-idx", "hello").Result() + if err != nil { + t.Fatalf("FTSearch RESP3 failed: %v", err) + } + + // Compare results + if result2.Total != result3.Total { + t.Errorf("Total mismatch: RESP2=%d, RESP3=%d", result2.Total, result3.Total) + } + if len(result2.Docs) != len(result3.Docs) { + t.Errorf("Docs length mismatch: RESP2=%d, RESP3=%d", len(result2.Docs), len(result3.Docs)) + } + + t.Logf("RESP2 result: Total=%d, Docs=%d", result2.Total, len(result2.Docs)) + t.Logf("RESP3 result: Total=%d, Docs=%d", result3.Total, len(result3.Docs)) + + for i, doc := range result2.Docs { + t.Logf("RESP2 Doc[%d]: ID=%s, Fields=%v", i, doc.ID, doc.Fields) + } + for i, doc := range result3.Docs { + t.Logf("RESP3 Doc[%d]: ID=%s, Fields=%v", i, doc.ID, doc.Fields) + } + }) + + t.Run("FTAggregate", func(t *testing.T) { + // Create index + _, err := client2.FTCreate(ctx, "test-idx", + &redis.FTCreateOptions{}, + &redis.FieldSchema{FieldName: "title", FieldType: redis.SearchFieldTypeText}, + &redis.FieldSchema{FieldName: "score", FieldType: redis.SearchFieldTypeNumeric, Sortable: true}, + ).Result() + if err != nil { + t.Fatalf("FTCreate failed: %v", err) + } + defer client2.FTDropIndex(ctx, "test-idx") + + // Add documents + client2.HSet(ctx, "doc1", "title", "hello world", "score", 100) + client2.HSet(ctx, "doc2", "title", "hello redis", "score", 200) + + // Wait for indexing + waitForIndexing(t, client2, "test-idx", 2) + + // Aggregate from both protocols + options := &redis.FTAggregateOptions{ + Load: []redis.FTAggregateLoad{{Field: "@score"}}, + } + + // Test that RawVal() is populated + cmd2 := client2.FTAggregateWithArgs(ctx, "test-idx", "*", options) + if cmd2.RawVal() == nil { + t.Error("RESP2 RawVal() should not be nil") + } + cmd3 := client3.FTAggregateWithArgs(ctx, "test-idx", "*", options) + if cmd3.RawVal() == nil { + t.Error("RESP3 RawVal() should not be nil") + } + + result2, err := cmd2.Result() + if err != nil { + t.Fatalf("FTAggregate RESP2 failed: %v", err) + } + + result3, err := cmd3.Result() + if err != nil { + t.Fatalf("FTAggregate RESP3 failed: %v", err) + } + + // Compare results + // Note: FT.AGGREGATE total_results is unreliable from the server in RESP3 + // It should only match when both are 0 (no results) + if result2.Total == 0 && result3.Total != 0 { + t.Errorf("Total mismatch when RESP2 is 0: RESP2=%d, RESP3=%d", result2.Total, result3.Total) + } + if len(result2.Rows) != len(result3.Rows) { + t.Errorf("Rows length mismatch: RESP2=%d, RESP3=%d", len(result2.Rows), len(result3.Rows)) + } + + t.Logf("RESP2 result: Total=%d, Rows=%d", result2.Total, len(result2.Rows)) + t.Logf("RESP3 result: Total=%d, Rows=%d (note: total_results is unreliable in RESP3)", result3.Total, len(result3.Rows)) + + for i, row := range result2.Rows { + t.Logf("RESP2 Row[%d]: Fields=%v", i, row.Fields) + } + for i, row := range result3.Rows { + t.Logf("RESP3 Row[%d]: Fields=%v", i, row.Fields) + } + }) + + t.Run("FTSpellCheck", func(t *testing.T) { + // Create index + _, err := client2.FTCreate(ctx, "test-idx", + &redis.FTCreateOptions{}, + &redis.FieldSchema{FieldName: "title", FieldType: redis.SearchFieldTypeText}, + ).Result() + if err != nil { + t.Fatalf("FTCreate failed: %v", err) + } + defer client2.FTDropIndex(ctx, "test-idx") + + // Add documents + client2.HSet(ctx, "doc1", "title", "hello world") + client2.HSet(ctx, "doc2", "title", "hello redis") + + // Wait for indexing + waitForIndexing(t, client2, "test-idx", 2) + + // SpellCheck from both protocols + result2, err := client2.FTSpellCheck(ctx, "test-idx", "helo").Result() + if err != nil { + t.Fatalf("FTSpellCheck RESP2 failed: %v", err) + } + + result3, err := client3.FTSpellCheck(ctx, "test-idx", "helo").Result() + if err != nil { + t.Fatalf("FTSpellCheck RESP3 failed: %v", err) + } + + t.Logf("RESP2 result: %+v", result2) + t.Logf("RESP3 result: %+v", result3) + + // Compare results + if len(result2) != len(result3) { + t.Errorf("Results length mismatch: RESP2=%d, RESP3=%d", len(result2), len(result3)) + } + }) + + t.Run("FTSynDump", func(t *testing.T) { + // Create index + _, err := client2.FTCreate(ctx, "test-idx", + &redis.FTCreateOptions{}, + &redis.FieldSchema{FieldName: "title", FieldType: redis.SearchFieldTypeText}, + ).Result() + if err != nil { + t.Fatalf("FTCreate failed: %v", err) + } + defer client2.FTDropIndex(ctx, "test-idx") + + // Add synonyms + _, err = client2.FTSynUpdate(ctx, "test-idx", "group1", []interface{}{"hello", "hi", "hey"}).Result() + if err != nil { + t.Fatalf("FTSynUpdate failed: %v", err) + } + + // SynDump from both protocols + result2, err := client2.FTSynDump(ctx, "test-idx").Result() + if err != nil { + t.Fatalf("FTSynDump RESP2 failed: %v", err) + } + + result3, err := client3.FTSynDump(ctx, "test-idx").Result() + if err != nil { + t.Fatalf("FTSynDump RESP3 failed: %v", err) + } + + t.Logf("RESP2 result: %+v", result2) + t.Logf("RESP3 result: %+v", result3) + + // Compare results + if len(result2) != len(result3) { + t.Errorf("Results length mismatch: RESP2=%d, RESP3=%d", len(result2), len(result3)) + } + }) +} + +func waitForIndexing(t *testing.T, client *redis.Client, index string, expectedDocs int) { + t.Helper() + ctx := context.Background() + for i := 0; i < 100; i++ { + info, err := client.FTInfo(ctx, index).Result() + if err != nil { + t.Fatalf("FTInfo failed: %v", err) + } + if info.NumDocs >= expectedDocs { + return + } + } + t.Fatalf("Timeout waiting for indexing") +} diff --git a/search_test.go b/search_test.go index c2788a91..afe80f8d 100644 --- a/search_test.go +++ b/search_test.go @@ -4401,7 +4401,7 @@ var _ = Describe("RediSearch commands Resp 3", Label("search"), func() { Expect(client.Close()).NotTo(HaveOccurred()) }) - It("should handle FTAggregate with Unstable RESP3 Search Module and without stability", Label("search", "ftcreate", "ftaggregate"), func() { + It("should handle FTAggregate with RESP3", Label("search", "ftcreate", "ftaggregate"), func() { text1 := &redis.FieldSchema{FieldName: "PrimaryKey", FieldType: redis.SearchFieldTypeText, Sortable: true} num1 := &redis.FieldSchema{FieldName: "CreatedDateTimeUTC", FieldType: redis.SearchFieldTypeNumeric, Sortable: true} val, err := client.FTCreate(ctx, "idx1", &redis.FTCreateOptions{}, text1, num1).Result() @@ -4411,40 +4411,45 @@ var _ = Describe("RediSearch commands Resp 3", Label("search"), func() { client.HSet(ctx, "doc1", "PrimaryKey", "9::362330", "CreatedDateTimeUTC", "637387878524969984") client.HSet(ctx, "doc2", "PrimaryKey", "9::362329", "CreatedDateTimeUTC", "637387875859270016") + WaitForIndexing(client, "idx1") options := &redis.FTAggregateOptions{Apply: []redis.FTAggregateApply{{Field: "@CreatedDateTimeUTC * 10", As: "CreatedDateTimeUTC"}}} - res, err := client.FTAggregateWithArgs(ctx, "idx1", "*", options).RawResult() + + // Test with UnstableResp3 true - both RawResult and Result should work + cmd := client.FTAggregateWithArgs(ctx, "idx1", "*", options) + res, err := cmd.RawResult() + Expect(err).NotTo(HaveOccurred()) results := res.(map[interface{}]interface{})["results"].([]interface{}) Expect(results[0].(map[interface{}]interface{})["extra_attributes"].(map[interface{}]interface{})["CreatedDateTimeUTC"]). To(Or(BeEquivalentTo("6373878785249699840"), BeEquivalentTo("6373878758592700416"))) Expect(results[1].(map[interface{}]interface{})["extra_attributes"].(map[interface{}]interface{})["CreatedDateTimeUTC"]). To(Or(BeEquivalentTo("6373878785249699840"), BeEquivalentTo("6373878758592700416"))) - rawVal := client.FTAggregateWithArgs(ctx, "idx1", "*", options).RawVal() - rawValResults := rawVal.(map[interface{}]interface{})["results"].([]interface{}) + // Test Result() also works with UnstableResp3 true + result, err := client.FTAggregateWithArgs(ctx, "idx1", "*", options).Result() Expect(err).NotTo(HaveOccurred()) - Expect(rawValResults[0]).To(Or(BeEquivalentTo(results[0]), BeEquivalentTo(results[1]))) - Expect(rawValResults[1]).To(Or(BeEquivalentTo(results[0]), BeEquivalentTo(results[1]))) + Expect(result.Total).To(BeEquivalentTo(2)) + Expect(result.Rows).To(HaveLen(2)) + Expect(result.Rows[0].Fields["CreatedDateTimeUTC"]).To(Or(BeEquivalentTo("6373878785249699840"), BeEquivalentTo("6373878758592700416"))) + Expect(result.Rows[1].Fields["CreatedDateTimeUTC"]).To(Or(BeEquivalentTo("6373878785249699840"), BeEquivalentTo("6373878758592700416"))) - // Test with UnstableResp3 false - should return error instead of panic + // Test with UnstableResp3 false - should also work with stable RESP3 parsing options = &redis.FTAggregateOptions{Apply: []redis.FTAggregateApply{{Field: "@CreatedDateTimeUTC * 10", As: "CreatedDateTimeUTC"}}} - rawRes, err := client2.FTAggregateWithArgs(ctx, "idx1", "*", options).RawResult() - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("RESP3 responses for this command are disabled")) - Expect(rawRes).To(BeNil()) - - rawVal = client2.FTAggregateWithArgs(ctx, "idx1", "*", options).RawVal() - Expect(client2.FTAggregateWithArgs(ctx, "idx1", "*", options).Err()).To(HaveOccurred()) - Expect(rawVal).To(BeNil()) - + result2, err := client2.FTAggregateWithArgs(ctx, "idx1", "*", options).Result() + Expect(err).NotTo(HaveOccurred()) + Expect(result2.Total).To(BeEquivalentTo(2)) + Expect(result2.Rows).To(HaveLen(2)) + Expect(result2.Rows[0].Fields["CreatedDateTimeUTC"]).To(Or(BeEquivalentTo("6373878785249699840"), BeEquivalentTo("6373878758592700416"))) + Expect(result2.Rows[1].Fields["CreatedDateTimeUTC"]).To(Or(BeEquivalentTo("6373878785249699840"), BeEquivalentTo("6373878758592700416"))) }) - It("should handle FTInfo with Unstable RESP3 Search Module and without stability", Label("search", "ftcreate", "ftinfo"), func() { + It("should handle FTInfo with RESP3", Label("search", "ftcreate", "ftinfo"), func() { val, err := client.FTCreate(ctx, "idx1", &redis.FTCreateOptions{}, &redis.FieldSchema{FieldName: "txt", FieldType: redis.SearchFieldTypeText, Sortable: true, NoStem: true}).Result() Expect(err).NotTo(HaveOccurred()) Expect(val).To(BeEquivalentTo("OK")) WaitForIndexing(client, "idx1") + // Test with UnstableResp3 true - both RawResult and Result should work resInfo, err := client.FTInfo(ctx, "idx1").RawResult() Expect(err).NotTo(HaveOccurred()) attributes := resInfo.(map[interface{}]interface{})["attributes"].([]interface{}) @@ -4456,18 +4461,22 @@ var _ = Describe("RediSearch commands Resp 3", Label("search"), func() { flags = attributes[0].(map[interface{}]interface{})["flags"].([]interface{}) Expect(flags).To(ConsistOf("SORTABLE", "NOSTEM")) - // Test with UnstableResp3 false - should return error instead of panic - rawResInfo, err := client2.FTInfo(ctx, "idx1").RawResult() - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("RESP3 responses for this command are disabled")) - Expect(rawResInfo).To(BeNil()) + // Test Result() also works with UnstableResp3 true + result, err := client.FTInfo(ctx, "idx1").Result() + Expect(err).NotTo(HaveOccurred()) + Expect(result.IndexName).To(BeEquivalentTo("idx1")) + Expect(result.Attributes).To(HaveLen(1)) + Expect(result.Attributes[0].Attribute).To(BeEquivalentTo("txt")) - rawValInfo := client2.FTInfo(ctx, "idx1").RawVal() - Expect(client2.FTInfo(ctx, "idx1").Err()).To(HaveOccurred()) - Expect(rawValInfo).To(BeNil()) + // Test with UnstableResp3 false - should also work with stable RESP3 parsing + result2, err := client2.FTInfo(ctx, "idx1").Result() + Expect(err).NotTo(HaveOccurred()) + Expect(result2.IndexName).To(BeEquivalentTo("idx1")) + Expect(result2.Attributes).To(HaveLen(1)) + Expect(result2.Attributes[0].Attribute).To(BeEquivalentTo("txt")) }) - It("should handle FTSpellCheck with Unstable RESP3 Search Module and without stability", Label("search", "ftcreate", "ftspellcheck"), func() { + It("should handle FTSpellCheck with RESP3", Label("search", "ftcreate", "ftspellcheck"), func() { text1 := &redis.FieldSchema{FieldName: "f1", FieldType: redis.SearchFieldTypeText} text2 := &redis.FieldSchema{FieldName: "f2", FieldType: redis.SearchFieldTypeText} val, err := client.FTCreate(ctx, "idx1", &redis.FTCreateOptions{}, text1, text2).Result() @@ -4478,6 +4487,7 @@ var _ = Describe("RediSearch commands Resp 3", Label("search"), func() { client.HSet(ctx, "doc1", "f1", "some valid content", "f2", "this is sample text") client.HSet(ctx, "doc2", "f1", "very important", "f2", "lorem ipsum") + // Test with UnstableResp3 true - both RawResult and Result should work resSpellCheck, err := client.FTSpellCheck(ctx, "idx1", "impornant").RawResult() valSpellCheck := client.FTSpellCheck(ctx, "idx1", "impornant").RawVal() Expect(err).NotTo(HaveOccurred()) @@ -4485,24 +4495,34 @@ var _ = Describe("RediSearch commands Resp 3", Label("search"), func() { results := resSpellCheck.(map[interface{}]interface{})["results"].(map[interface{}]interface{}) Expect(results["impornant"].([]interface{})[0].(map[interface{}]interface{})["important"]).To(BeEquivalentTo(0.5)) - // Test with UnstableResp3 false - should return error instead of panic - rawResSpellCheck, err := client2.FTSpellCheck(ctx, "idx1", "impornant").RawResult() - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("RESP3 responses for this command are disabled")) - Expect(rawResSpellCheck).To(BeNil()) + // Test Result() also works with UnstableResp3 true + result, err := client.FTSpellCheck(ctx, "idx1", "impornant").Result() + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].Term).To(BeEquivalentTo("impornant")) + Expect(result[0].Suggestions).To(HaveLen(1)) + Expect(result[0].Suggestions[0].Suggestion).To(BeEquivalentTo("important")) + Expect(result[0].Suggestions[0].Score).To(BeEquivalentTo(0.5)) - rawValSpellCheck := client2.FTSpellCheck(ctx, "idx1", "impornant").RawVal() - Expect(client2.FTSpellCheck(ctx, "idx1", "impornant").Err()).To(HaveOccurred()) - Expect(rawValSpellCheck).To(BeNil()) + // Test with UnstableResp3 false - should also work with stable RESP3 parsing + result2, err := client2.FTSpellCheck(ctx, "idx1", "impornant").Result() + Expect(err).NotTo(HaveOccurred()) + Expect(result2).To(HaveLen(1)) + Expect(result2[0].Term).To(BeEquivalentTo("impornant")) + Expect(result2[0].Suggestions).To(HaveLen(1)) + Expect(result2[0].Suggestions[0].Suggestion).To(BeEquivalentTo("important")) + Expect(result2[0].Suggestions[0].Score).To(BeEquivalentTo(0.5)) }) - It("should handle FTSearch with Unstable RESP3 Search Module and without stability", Label("search", "ftcreate", "ftsearch"), func() { + It("should handle FTSearch with RESP3", Label("search", "ftcreate", "ftsearch"), func() { val, err := client.FTCreate(ctx, "txt", &redis.FTCreateOptions{StopWords: []interface{}{"foo", "bar", "baz"}}, &redis.FieldSchema{FieldName: "txt", FieldType: redis.SearchFieldTypeText}).Result() Expect(err).NotTo(HaveOccurred()) Expect(val).To(BeEquivalentTo("OK")) WaitForIndexing(client, "txt") client.HSet(ctx, "doc1", "txt", "foo baz") client.HSet(ctx, "doc2", "txt", "hello world") + + // Test with UnstableResp3 true - both RawResult and Result should work res1, err := client.FTSearchWithArgs(ctx, "txt", "foo bar", &redis.FTSearchOptions{NoContent: true}).RawResult() val1 := client.FTSearchWithArgs(ctx, "txt", "foo bar", &redis.FTSearchOptions{NoContent: true}).RawVal() Expect(err).NotTo(HaveOccurred()) @@ -4514,17 +4534,29 @@ var _ = Describe("RediSearch commands Resp 3", Label("search"), func() { totalResults2 := res2.(map[interface{}]interface{})["total_results"] Expect(totalResults2).To(BeEquivalentTo(int64(1))) - // Test with UnstableResp3 false - should return error instead of panic - rawRes2, err := client2.FTSearchWithArgs(ctx, "txt", "foo bar hello world", &redis.FTSearchOptions{NoContent: true}).RawResult() - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("RESP3 responses for this command are disabled")) - Expect(rawRes2).To(BeNil()) + // Test Result() also works with UnstableResp3 true + result, err := client.FTSearchWithArgs(ctx, "txt", "foo bar hello world", &redis.FTSearchOptions{NoContent: true}).Result() + Expect(err).NotTo(HaveOccurred()) + Expect(result.Total).To(BeEquivalentTo(1)) + Expect(result.Docs).To(HaveLen(1)) + Expect(result.Docs[0].ID).To(BeEquivalentTo("doc2")) - rawVal2 := client2.FTSearchWithArgs(ctx, "txt", "foo bar hello world", &redis.FTSearchOptions{NoContent: true}).RawVal() - Expect(client2.FTSearchWithArgs(ctx, "txt", "foo bar hello world", &redis.FTSearchOptions{NoContent: true}).Err()).To(HaveOccurred()) - Expect(rawVal2).To(BeNil()) + // Test with UnstableResp3 false - should also work with stable RESP3 parsing + result2, err := client2.FTSearchWithArgs(ctx, "txt", "foo bar hello world", &redis.FTSearchOptions{NoContent: true}).Result() + Expect(err).NotTo(HaveOccurred()) + Expect(result2.Total).To(BeEquivalentTo(1)) + Expect(result2.Docs).To(HaveLen(1)) + Expect(result2.Docs[0].ID).To(BeEquivalentTo("doc2")) + + // Test with content + result3, err := client2.FTSearchWithArgs(ctx, "txt", "hello world", &redis.FTSearchOptions{}).Result() + Expect(err).NotTo(HaveOccurred()) + Expect(result3.Total).To(BeEquivalentTo(1)) + Expect(result3.Docs).To(HaveLen(1)) + Expect(result3.Docs[0].ID).To(BeEquivalentTo("doc2")) + Expect(result3.Docs[0].Fields["txt"]).To(BeEquivalentTo("hello world")) }) - It("should handle FTSynDump with Unstable RESP3 Search Module and without stability", Label("search", "ftsyndump"), func() { + It("should handle FTSynDump with RESP3", Label("search", "ftsyndump"), func() { text1 := &redis.FieldSchema{FieldName: "title", FieldType: redis.SearchFieldTypeText} text2 := &redis.FieldSchema{FieldName: "body", FieldType: redis.SearchFieldTypeText} val, err := client.FTCreate(ctx, "idx1", &redis.FTCreateOptions{OnHash: true}, text1, text2).Result() @@ -4544,21 +4576,40 @@ var _ = Describe("RediSearch commands Resp 3", Label("search"), func() { Expect(err).NotTo(HaveOccurred()) Expect(resSynUpdate).To(BeEquivalentTo("OK")) + // Test with UnstableResp3 true - both RawResult and Result should work resSynDump, err := client.FTSynDump(ctx, "idx1").RawResult() valSynDump := client.FTSynDump(ctx, "idx1").RawVal() Expect(err).NotTo(HaveOccurred()) Expect(valSynDump).To(BeEquivalentTo(resSynDump)) Expect(resSynDump.(map[interface{}]interface{})["baby"]).To(BeEquivalentTo([]interface{}{"id1"})) - // Test with UnstableResp3 false - should return error instead of panic - rawResSynDump, err := client2.FTSynDump(ctx, "idx1").RawResult() - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("RESP3 responses for this command are disabled")) - Expect(rawResSynDump).To(BeNil()) + // Test Result() also works with UnstableResp3 true + result, err := client.FTSynDump(ctx, "idx1").Result() + Expect(err).NotTo(HaveOccurred()) + // Find the "baby" term in results + var foundBaby bool + for _, r := range result { + if r.Term == "baby" { + foundBaby = true + Expect(r.Synonyms).To(ContainElement("id1")) + break + } + } + Expect(foundBaby).To(BeTrue()) - rawValSynDump := client2.FTSynDump(ctx, "idx1").RawVal() - Expect(client2.FTSynDump(ctx, "idx1").Err()).To(HaveOccurred()) - Expect(rawValSynDump).To(BeNil()) + // Test with UnstableResp3 false - should also work with stable RESP3 parsing + result2, err := client2.FTSynDump(ctx, "idx1").Result() + Expect(err).NotTo(HaveOccurred()) + // Find the "baby" term in results + foundBaby = false + for _, r := range result2 { + if r.Term == "baby" { + foundBaby = true + Expect(r.Synonyms).To(ContainElement("id1")) + break + } + } + Expect(foundBaby).To(BeTrue()) }) It("should test not affected Resp 3 Search method - FTExplain", Label("search", "ftexplain"), func() { diff --git a/sentinel.go b/sentinel.go index 785372f2..30077b77 100644 --- a/sentinel.go +++ b/sentinel.go @@ -160,6 +160,9 @@ type FailoverOptions struct { // Only applies to failover cluster clients. Default is 15 seconds. FailingTimeoutSeconds int + // Deprecated: All RediSearch commands now have stable RESP3 parsing and this + // flag is a no-op. It is kept for backwards compatibility and will be removed + // in a future release. UnstableResp3 bool // PushNotificationProcessor is the processor for handling push notifications. diff --git a/tls_cluster_test.go b/tls_cluster_test.go index 7bdd7038..824c44f7 100644 --- a/tls_cluster_test.go +++ b/tls_cluster_test.go @@ -280,4 +280,3 @@ var _ = Describe("TLS Cluster", Label("NonRedisEnterprise"), func() { }) }) }) - diff --git a/tls_standalone_test.go b/tls_standalone_test.go index 39c4df12..fc311655 100644 --- a/tls_standalone_test.go +++ b/tls_standalone_test.go @@ -74,4 +74,3 @@ func TestTLSRedissURL(t *testing.T) { t.Log("✅ TLS rediss:// URL test passed") } - diff --git a/tls_test.go b/tls_test.go index a062a12b..ef62b991 100644 --- a/tls_test.go +++ b/tls_test.go @@ -33,9 +33,9 @@ func loadTLSConfig(certDir string) (*tls.Config, error) { } return &tls.Config{ - RootCAs: caCertPool, - Certificates: []tls.Certificate{cert}, - ServerName: "localhost", + RootCAs: caCertPool, + Certificates: []tls.Certificate{cert}, + ServerName: "localhost", InsecureSkipVerify: true, }, nil } @@ -247,11 +247,11 @@ var _ = Describe("TLS", Label("NonRedisEnterprise"), func() { It("should respect MinVersion setting", func() { ctx := context.Background() tlsConfigWithMinVersion := &tls.Config{ - RootCAs: tlsConfig.RootCAs, - Certificates: tlsConfig.Certificates, - ServerName: "localhost", - InsecureSkipVerify: true, - MinVersion: tls.VersionTLS12, + RootCAs: tlsConfig.RootCAs, + Certificates: tlsConfig.Certificates, + ServerName: "localhost", + InsecureSkipVerify: true, + MinVersion: tls.VersionTLS12, } client := redis.NewClient(&redis.Options{ @@ -268,10 +268,10 @@ var _ = Describe("TLS", Label("NonRedisEnterprise"), func() { It("should work with custom cipher suites", func() { ctx := context.Background() tlsConfigWithCiphers := &tls.Config{ - RootCAs: tlsConfig.RootCAs, - Certificates: tlsConfig.Certificates, - ServerName: "localhost", - InsecureSkipVerify: true, + RootCAs: tlsConfig.RootCAs, + Certificates: tlsConfig.Certificates, + ServerName: "localhost", + InsecureSkipVerify: true, CipherSuites: []uint16{ tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, @@ -290,4 +290,3 @@ var _ = Describe("TLS", Label("NonRedisEnterprise"), func() { }) }) }) - diff --git a/universal.go b/universal.go index d1347249..b623460c 100644 --- a/universal.go +++ b/universal.go @@ -137,6 +137,9 @@ type UniversalOptions struct { // Only applies to cluster clients. Default is 15 seconds. FailingTimeoutSeconds int + // Deprecated: All RediSearch commands now have stable RESP3 parsing and this + // flag is a no-op. It is kept for backwards compatibility and will be removed + // in a future release. UnstableResp3 bool // PushNotificationProcessor is the processor for handling push notifications.