Files
go-redis/search_commands_parse_test.go
Jay Hemnani c3b728f9bb fix(search): read per-doc RESP2 values in FT.SEARCH (#4099)
* fix(search): read per-doc RESP2 values in FT.SEARCH

On RESP2, FT.SEARCH puts the WITHSCORES, WITHPAYLOADS and WITHSORTKEYS
values right after each document ID, even with NOCONTENT, and sends a
nil payload for a document without one. parseFTSearch stopped at the
ID when NoContent was set and skipped a nil payload without consuming
it, so a score, sort key or nil was read as the next document ID: Docs
held twice the entries, or the call failed with "invalid document ID
format". Read every requested value before the fields.

* fix(search): step past nil RESP2 sort keys

FT.SEARCH WITHSORTKEYS sends a nil sort key for a document that lacks
the SORTBY field, and for every document when there is no SORTBY.
parseFTSearch only stepped past string sort keys, so the nil was read
as the fields or the next document ID and the call failed with
"invalid document ID format". Step past the slot like the payload.

* test(search): cover nil RESP2 sort key before a doc

* test(search): compare RESP2 and RESP3 payloads
2026-10-20 00:02:44 +03:00

2113 lines
62 KiB
Go

package redis
import (
"context"
"math"
"strings"
"testing"
"github.com/redis/go-redis/v9/internal/proto"
)
// TestParseFTInfo tests the parseFTInfo function with a comprehensive FT.INFO response
// This test uses the actual response structure from Redis with vector fields
func TestParseFTInfo(t *testing.T) {
// This is the data structure that would be returned by Redis for FT.INFO
// Based on the redis-cli output provided by the user
data := map[string]interface{}{
"index_name": "rand:42d1f2820b3048b6bc783d4dcdb9094a",
"index_options": []interface{}{},
"index_definition": []interface{}{
"key_type", "HASH",
"prefixes", []interface{}{"rand:42d1f2820b3048b6bc783d4dcdb9094a"},
"default_score", "1",
"indexes_all", false,
},
"attributes": []interface{}{
// prompt field (TEXT)
[]interface{}{
"identifier", "prompt",
"attribute", "prompt",
"type", "TEXT",
"WEIGHT", "1",
},
// response field (TEXT)
[]interface{}{
"identifier", "response",
"attribute", "response",
"type", "TEXT",
"WEIGHT", "1",
},
// exact_digest field (TAG)
[]interface{}{
"identifier", "exact_digest",
"attribute", "exact_digest",
"type", "TAG",
"SEPARATOR", ",",
},
// prompt_vector field (VECTOR)
[]interface{}{
"identifier", "prompt_vector",
"attribute", "prompt_vector",
"type", "VECTOR",
"algorithm", "HNSW",
"data_type", "FLOAT32",
"dim", int64(1536),
"distance_metric", "COSINE",
"M", int64(16),
"ef_construction", int64(64),
},
},
"num_docs": int64(0),
"max_doc_id": int64(0),
"num_terms": int64(0),
"num_records": int64(0),
"inverted_sz_mb": "0",
"vector_index_sz_mb": "0",
"total_inverted_index_blocks": int64(0),
"offset_vectors_sz_mb": "0",
"doc_table_size_mb": "0.01532745361328125",
"sortable_values_size_mb": "0",
"key_table_size_mb": "2.288818359375e-5",
"tag_overhead_sz_mb": "0",
"text_overhead_sz_mb": "0",
"total_index_memory_sz_mb": "0.015350341796875",
"geoshapes_sz_mb": "0",
"records_per_doc_avg": "nan",
"bytes_per_record_avg": "nan",
"offsets_per_term_avg": "nan",
"offset_bits_per_record_avg": "nan",
"hash_indexing_failures": int64(0),
"total_indexing_time": "0",
"indexing": int64(0),
"percent_indexed": "1",
"number_of_uses": int64(2),
"cleaning": int64(0),
"gc_stats": []interface{}{
"bytes_collected", "0",
"total_ms_run", "0",
"total_cycles", "0",
"average_cycle_time_ms", "nan",
"last_run_time_ms", "0",
"gc_numeric_trees_missed", "0",
"gc_blocks_denied", "0",
},
"cursor_stats": []interface{}{
"global_idle", int64(0),
"global_total", int64(0),
"index_capacity", int64(128),
"index_total", int64(0),
},
"dialect_stats": []interface{}{
"dialect_1", int64(0),
"dialect_2", int64(0),
"dialect_3", int64(0),
"dialect_4", int64(0),
},
"Index Errors": []interface{}{
"indexing failures", int64(0),
"last indexing error", "N/A",
"last indexing error key", "N/A",
"background indexing status", "OK",
},
"field statistics": []interface{}{
[]interface{}{
"identifier", "prompt",
"attribute", "prompt",
"Index Errors", []interface{}{
"indexing failures", int64(0),
"last indexing error", "N/A",
"last indexing error key", "N/A",
},
},
[]interface{}{
"identifier", "response",
"attribute", "response",
"Index Errors", []interface{}{
"indexing failures", int64(0),
"last indexing error", "N/A",
"last indexing error key", "N/A",
},
},
[]interface{}{
"identifier", "exact_digest",
"attribute", "exact_digest",
"Index Errors", []interface{}{
"indexing failures", int64(0),
"last indexing error", "N/A",
"last indexing error key", "N/A",
},
},
[]interface{}{
"identifier", "prompt_vector",
"attribute", "prompt_vector",
"Index Errors", []interface{}{
"indexing failures", int64(0),
"last indexing error", "N/A",
"last indexing error key", "N/A",
},
"memory", int64(0),
"marked_deleted", int64(0),
},
},
}
// Parse the data
result, err := parseFTInfo(data)
if err != nil {
t.Fatalf("parseFTInfo failed: %v", err)
}
// Validate index name
if result.IndexName != "rand:42d1f2820b3048b6bc783d4dcdb9094a" {
t.Errorf("IndexName = %v, want %v", result.IndexName, "rand:42d1f2820b3048b6bc783d4dcdb9094a")
}
// Validate index definition
if result.IndexDefinition.KeyType != "HASH" {
t.Errorf("IndexDefinition.KeyType = %v, want HASH", result.IndexDefinition.KeyType)
}
if len(result.IndexDefinition.Prefixes) != 1 || result.IndexDefinition.Prefixes[0] != "rand:42d1f2820b3048b6bc783d4dcdb9094a" {
t.Errorf("IndexDefinition.Prefixes = %v, want [rand:42d1f2820b3048b6bc783d4dcdb9094a]", result.IndexDefinition.Prefixes)
}
if result.IndexDefinition.DefaultScore != 1.0 {
t.Errorf("IndexDefinition.DefaultScore = %v, want 1.0", result.IndexDefinition.DefaultScore)
}
// Validate attributes
if len(result.Attributes) != 4 {
t.Fatalf("len(Attributes) = %v, want 4", len(result.Attributes))
}
// Check prompt field (TEXT)
promptAttr := result.Attributes[0]
if promptAttr.Identifier != "prompt" {
t.Errorf("Attributes[0].Identifier = %v, want prompt", promptAttr.Identifier)
}
if promptAttr.Attribute != "prompt" {
t.Errorf("Attributes[0].Attribute = %v, want prompt", promptAttr.Attribute)
}
if promptAttr.Type != "TEXT" {
t.Errorf("Attributes[0].Type = %v, want TEXT", promptAttr.Type)
}
if promptAttr.Weight != 1.0 {
t.Errorf("Attributes[0].Weight = %v, want 1.0", promptAttr.Weight)
}
// Check response field (TEXT)
responseAttr := result.Attributes[1]
if responseAttr.Identifier != "response" {
t.Errorf("Attributes[1].Identifier = %v, want response", responseAttr.Identifier)
}
if responseAttr.Attribute != "response" {
t.Errorf("Attributes[1].Attribute = %v, want response", responseAttr.Attribute)
}
if responseAttr.Type != "TEXT" {
t.Errorf("Attributes[1].Type = %v, want TEXT", responseAttr.Type)
}
// Check exact_digest field (TAG)
tagAttr := result.Attributes[2]
if tagAttr.Identifier != "exact_digest" {
t.Errorf("Attributes[2].Identifier = %v, want exact_digest", tagAttr.Identifier)
}
if tagAttr.Attribute != "exact_digest" {
t.Errorf("Attributes[2].Attribute = %v, want exact_digest", tagAttr.Attribute)
}
if tagAttr.Type != "TAG" {
t.Errorf("Attributes[2].Type = %v, want TAG", tagAttr.Type)
}
// Check prompt_vector field (VECTOR)
vectorAttr := result.Attributes[3]
if vectorAttr.Identifier != "prompt_vector" {
t.Errorf("Attributes[3].Identifier = %v, want prompt_vector", vectorAttr.Identifier)
}
if vectorAttr.Attribute != "prompt_vector" {
t.Errorf("Attributes[3].Attribute = %v, want prompt_vector", vectorAttr.Attribute)
}
if vectorAttr.Type != "VECTOR" {
t.Errorf("Attributes[3].Type = %v, want VECTOR", vectorAttr.Type)
}
if vectorAttr.Algorithm != "HNSW" {
t.Errorf("Attributes[3].Algorithm = %v, want HNSW", vectorAttr.Algorithm)
}
if vectorAttr.DataType != "FLOAT32" {
t.Errorf("Attributes[3].DataType = %v, want FLOAT32", vectorAttr.DataType)
}
if vectorAttr.Dim != 1536 {
t.Errorf("Attributes[3].Dim = %v, want 1536", vectorAttr.Dim)
}
if vectorAttr.DistanceMetric != "COSINE" {
t.Errorf("Attributes[3].DistanceMetric = %v, want COSINE", vectorAttr.DistanceMetric)
}
if vectorAttr.M != 16 {
t.Errorf("Attributes[3].M = %v, want 16", vectorAttr.M)
}
if vectorAttr.EFConstruction != 64 {
t.Errorf("Attributes[3].EFConstruction = %v, want 64", vectorAttr.EFConstruction)
}
// Validate numeric fields
if result.NumDocs != 0 {
t.Errorf("NumDocs = %v, want 0", result.NumDocs)
}
if result.MaxDocID != 0 {
t.Errorf("MaxDocID = %v, want 0", result.MaxDocID)
}
if result.NumTerms != 0 {
t.Errorf("NumTerms = %v, want 0", result.NumTerms)
}
if result.NumRecords != 0 {
t.Errorf("NumRecords = %v, want 0", result.NumRecords)
}
if result.Indexing != 0 {
t.Errorf("Indexing = %v, want 0", result.Indexing)
}
if result.PercentIndexed != 1.0 {
t.Errorf("PercentIndexed = %v, want 1.0", result.PercentIndexed)
}
if result.HashIndexingFailures != 0 {
t.Errorf("HashIndexingFailures = %v, want 0", result.HashIndexingFailures)
}
if result.Cleaning != 0 {
t.Errorf("Cleaning = %v, want 0", result.Cleaning)
}
if result.NumberOfUses != 2 {
t.Errorf("NumberOfUses = %v, want 2", result.NumberOfUses)
}
// Validate average stats (should be "nan" for empty index)
if result.RecordsPerDocAvg != "nan" {
t.Errorf("RecordsPerDocAvg = %v, want nan", result.RecordsPerDocAvg)
}
if result.BytesPerRecordAvg != "nan" {
t.Errorf("BytesPerRecordAvg = %v, want nan", result.BytesPerRecordAvg)
}
if result.OffsetsPerTermAvg != "nan" {
t.Errorf("OffsetsPerTermAvg = %v, want nan", result.OffsetsPerTermAvg)
}
if result.OffsetBitsPerRecordAvg != "nan" {
t.Errorf("OffsetBitsPerRecordAvg = %v, want nan", result.OffsetBitsPerRecordAvg)
}
// Validate cursor stats
if result.CursorStats.GlobalIdle != 0 {
t.Errorf("CursorStats.GlobalIdle = %v, want 0", result.CursorStats.GlobalIdle)
}
if result.CursorStats.GlobalTotal != 0 {
t.Errorf("CursorStats.GlobalTotal = %v, want 0", result.CursorStats.GlobalTotal)
}
if result.CursorStats.IndexCapacity != 128 {
t.Errorf("CursorStats.IndexCapacity = %v, want 128", result.CursorStats.IndexCapacity)
}
if result.CursorStats.IndexTotal != 0 {
t.Errorf("CursorStats.IndexTotal = %v, want 0", result.CursorStats.IndexTotal)
}
// Validate dialect stats
if result.DialectStats["dialect_1"] != 0 {
t.Errorf("DialectStats[dialect_1] = %v, want 0", result.DialectStats["dialect_1"])
}
if result.DialectStats["dialect_2"] != 0 {
t.Errorf("DialectStats[dialect_2] = %v, want 0", result.DialectStats["dialect_2"])
}
if result.DialectStats["dialect_3"] != 0 {
t.Errorf("DialectStats[dialect_3] = %v, want 0", result.DialectStats["dialect_3"])
}
if result.DialectStats["dialect_4"] != 0 {
t.Errorf("DialectStats[dialect_4] = %v, want 0", result.DialectStats["dialect_4"])
}
// Validate GC stats
if result.GCStats.BytesCollected != 0 {
t.Errorf("GCStats.BytesCollected = %v, want 0", result.GCStats.BytesCollected)
}
if result.GCStats.TotalMsRun != 0 {
t.Errorf("GCStats.TotalMsRun = %v, want 0", result.GCStats.TotalMsRun)
}
if result.GCStats.TotalCycles != 0 {
t.Errorf("GCStats.TotalCycles = %v, want 0", result.GCStats.TotalCycles)
}
if result.GCStats.AverageCycleTimeMs != "nan" {
t.Errorf("GCStats.AverageCycleTimeMs = %v, want nan", result.GCStats.AverageCycleTimeMs)
}
// Validate Index Errors
if result.IndexErrors.IndexingFailures != 0 {
t.Errorf("IndexErrors.IndexingFailures = %v, want 0", result.IndexErrors.IndexingFailures)
}
if result.IndexErrors.LastIndexingError != "N/A" {
t.Errorf("IndexErrors.LastIndexingError = %v, want N/A", result.IndexErrors.LastIndexingError)
}
if result.IndexErrors.LastIndexingErrorKey != "N/A" {
t.Errorf("IndexErrors.LastIndexingErrorKey = %v, want N/A", result.IndexErrors.LastIndexingErrorKey)
}
// Validate field statistics
if len(result.FieldStatistics) != 4 {
t.Fatalf("len(FieldStatistics) = %v, want 4", len(result.FieldStatistics))
}
expectedIdentifiers := map[string]bool{
"prompt": true,
"response": true,
"exact_digest": true,
"prompt_vector": true,
}
for _, fieldStat := range result.FieldStatistics {
if !expectedIdentifiers[fieldStat.Identifier] {
t.Errorf("Unexpected field statistic identifier: %v", fieldStat.Identifier)
}
if fieldStat.IndexErrors.IndexingFailures != 0 {
t.Errorf("FieldStatistic[%s].IndexErrors.IndexingFailures = %v, want 0", fieldStat.Identifier, fieldStat.IndexErrors.IndexingFailures)
}
if fieldStat.IndexErrors.LastIndexingError != "N/A" {
t.Errorf("FieldStatistic[%s].IndexErrors.LastIndexingError = %v, want N/A", fieldStat.Identifier, fieldStat.IndexErrors.LastIndexingError)
}
if fieldStat.IndexErrors.LastIndexingErrorKey != "N/A" {
t.Errorf("FieldStatistic[%s].IndexErrors.LastIndexingErrorKey = %v, want N/A", fieldStat.Identifier, fieldStat.IndexErrors.LastIndexingErrorKey)
}
}
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
withPayloads bool
withSortKeys 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: "NOCONTENT query with scores",
resp2Data: []interface{}{
int64(2),
"doc:2",
"0.25",
"doc:1",
"0.125",
},
resp3Data: map[string]interface{}{
"attributes": []interface{}{},
"total_results": int64(2),
"format": "STRING",
"results": []interface{}{
map[string]interface{}{
"id": "doc:2",
"score": float64(0.25),
"values": []interface{}{},
},
map[string]interface{}{
"id": "doc:1",
"score": float64(0.125),
"values": []interface{}{},
},
},
"warning": []interface{}{},
},
noContent: true,
withScores: true,
},
{
name: "NOCONTENT query with sort keys",
resp2Data: []interface{}{
int64(2),
"doc:1",
"#1",
"doc:2",
"#2",
},
resp3Data: map[string]interface{}{
"attributes": []interface{}{},
"total_results": int64(2),
"format": "STRING",
"results": []interface{}{
map[string]interface{}{
"id": "doc:1",
"sortkey": "#1",
"values": []interface{}{},
},
map[string]interface{}{
"id": "doc:2",
"sortkey": "#2",
"values": []interface{}{},
},
},
"warning": []interface{}{},
},
noContent: true,
withSortKeys: true,
},
{
name: "query with empty payloads",
resp2Data: []interface{}{
int64(2),
"doc:2",
nil,
[]interface{}{"n", "2"},
"doc:1",
nil,
[]interface{}{"n", "1"},
},
resp3Data: map[string]interface{}{
"attributes": []interface{}{},
"total_results": int64(2),
"format": "STRING",
"results": []interface{}{
map[string]interface{}{
"id": "doc:2",
"extra_attributes": map[string]interface{}{"n": "2"},
"values": []interface{}{},
},
map[string]interface{}{
"id": "doc:1",
"extra_attributes": map[string]interface{}{"n": "1"},
"values": []interface{}{},
},
},
"warning": []interface{}{},
},
withPayloads: true,
},
{
name: "query with sort keys and a missing sort field",
resp2Data: []interface{}{
int64(2),
"doc:1",
"#5",
[]interface{}{"n", "5"},
"doc:2",
nil,
[]interface{}{"txt", "hello"},
},
resp3Data: map[string]interface{}{
"attributes": []interface{}{},
"total_results": int64(2),
"format": "STRING",
"results": []interface{}{
map[string]interface{}{
"id": "doc:1",
"sortkey": "#5",
"extra_attributes": map[string]interface{}{"n": "5"},
"values": []interface{}{},
},
map[string]interface{}{
"id": "doc:2",
"sortkey": nil,
"extra_attributes": map[string]interface{}{"txt": "hello"},
"values": []interface{}{},
},
},
"warning": []interface{}{},
},
withSortKeys: true,
},
{
name: "NOCONTENT query with a missing sort field before another document",
resp2Data: []interface{}{
int64(2),
"doc:1",
nil,
"doc:2",
"#5",
},
resp3Data: map[string]interface{}{
"attributes": []interface{}{},
"total_results": int64(2),
"format": "STRING",
"results": []interface{}{
map[string]interface{}{
"id": "doc:1",
"sortkey": nil,
"values": []interface{}{},
},
map[string]interface{}{
"id": "doc:2",
"sortkey": "#5",
"values": []interface{}{},
},
},
"warning": []interface{}{},
},
noContent: true,
withSortKeys: true,
},
{
name: "query with empty payload and missing sort field",
resp2Data: []interface{}{
int64(2),
"doc:1",
nil,
nil,
[]interface{}{"txt", "hello"},
"doc:2",
"p",
"#5",
[]interface{}{"n", "5"},
},
resp3Data: map[string]interface{}{
"attributes": []interface{}{},
"total_results": int64(2),
"format": "STRING",
"results": []interface{}{
map[string]interface{}{
"id": "doc:1",
"sortkey": nil,
"extra_attributes": map[string]interface{}{"txt": "hello"},
"values": []interface{}{},
},
map[string]interface{}{
"id": "doc:2",
"payload": "p",
"sortkey": "#5",
"extra_attributes": map[string]interface{}{"n": "5"},
"values": []interface{}{},
},
},
"warning": []interface{}{},
},
withPayloads: true,
withSortKeys: true,
},
{
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, tt.withPayloads, tt.withSortKeys)
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)
}
}
if tt.withSortKeys {
if (resp2Doc.SortKey == nil) != (resp3Doc.SortKey == nil) ||
(resp2Doc.SortKey != nil && *resp2Doc.SortKey != *resp3Doc.SortKey) {
t.Errorf("Docs[%d].SortKey mismatch: RESP2=%v, RESP3=%v", i, resp2Doc.SortKey, resp3Doc.SortKey)
}
}
if tt.withPayloads {
if (resp2Doc.Payload == nil) != (resp3Doc.Payload == nil) ||
(resp2Doc.Payload != nil && *resp2Doc.Payload != *resp3Doc.Payload) {
t.Errorf("Docs[%d].Payload mismatch: RESP2=%v, RESP3=%v", i, resp2Doc.Payload, resp3Doc.Payload)
}
}
// 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])
}
}
}
})
}
}
// TestFTSearchExplainScoreRESP2AndRESP3 validates that the score is read from
// the [score, explanation] pair returned with WITHSCORES EXPLAINSCORE
func TestFTSearchExplainScoreRESP2AndRESP3(t *testing.T) {
explanation := []interface{}{"Final BM25 : words BM25 0.21 * document score 1.00", []interface{}{"hello: (0.21 = ...)"}}
resp2Data := []interface{}{
int64(2),
"doc:2",
[]interface{}{"0.21", explanation},
[]interface{}{"t", "hello there"},
"doc:1",
[]interface{}{"0.16", explanation},
[]interface{}{"t", "hello world"},
}
resp3Data := map[string]interface{}{
"attributes": []interface{}{},
"total_results": int64(2),
"format": "STRING",
"results": []interface{}{
map[string]interface{}{
"id": "doc:2",
"score": []interface{}{float64(0.21), explanation},
"extra_attributes": map[string]interface{}{"t": "hello there"},
"values": []interface{}{},
},
map[string]interface{}{
"id": "doc:1",
"score": []interface{}{float64(0.16), explanation},
"extra_attributes": map[string]interface{}{"t": "hello world"},
"values": []interface{}{},
},
},
"warning": []interface{}{},
}
resp2Result, err := parseFTSearch(resp2Data, false, true, false, false)
if err != nil {
t.Fatalf("parseFTSearch() error = %v", err)
}
resp3Result, err := parseFTSearchMapRESP3(toInterfaceMap(resp3Data))
if err != nil {
t.Fatalf("parseFTSearchMapRESP3() error = %v", err)
}
want := []Document{
{ID: "doc:2", Score: ptrFloat64(0.21), Fields: map[string]string{"t": "hello there"}},
{ID: "doc:1", Score: ptrFloat64(0.16), Fields: map[string]string{"t": "hello world"}},
}
for name, result := range map[string]FTSearchResult{"RESP2": resp2Result, "RESP3": resp3Result} {
if len(result.Docs) != len(want) {
t.Fatalf("%s: Docs count = %d, want %d", name, len(result.Docs), len(want))
}
for i, doc := range result.Docs {
if doc.ID != want[i].ID {
t.Errorf("%s: Docs[%d].ID = %s, want %s", name, i, doc.ID, want[i].ID)
}
if doc.Score == nil || *doc.Score != *want[i].Score {
t.Errorf("%s: Docs[%d].Score = %v, want %v", name, i, doc.Score, *want[i].Score)
}
if doc.Fields["t"] != want[i].Fields["t"] {
t.Errorf("%s: Docs[%d].Fields[t] = %s, want %s", name, i, doc.Fields["t"], want[i].Fields["t"])
}
}
}
}
// TestFTSearchCmdReadReplyRESP2 validates RESP2 readReply with nil options
// and an empty RETURN
func TestFTSearchCmdReadReplyRESP2(t *testing.T) {
tests := []struct {
name string
options *FTSearchOptions
reply string
want []Document
}{
{
name: "nil options",
options: nil,
reply: "*3\r\n:1\r\n$5\r\ndoc:1\r\n*2\r\n$1\r\nt\r\n$11\r\nhello world\r\n",
want: []Document{{ID: "doc:1", Fields: map[string]string{"t": "hello world"}}},
},
{
name: "empty RETURN",
options: &FTSearchOptions{Return: []FTSearchReturn{}},
reply: "*3\r\n:2\r\n$5\r\ndoc:2\r\n$5\r\ndoc:1\r\n",
want: []Document{{ID: "doc:2", Fields: map[string]string{}}, {ID: "doc:1", Fields: map[string]string{}}},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := newFTSearchCmd(context.Background(), tt.options)
if err := cmd.readReply(proto.NewReader(strings.NewReader(tt.reply))); err != nil {
t.Fatalf("readReply() error = %v", err)
}
if cmd.val.Total != len(tt.want) {
t.Errorf("Total = %d, want %d", cmd.val.Total, len(tt.want))
}
if len(cmd.val.Docs) != len(tt.want) {
t.Fatalf("Docs count = %d, want %d", len(cmd.val.Docs), len(tt.want))
}
for i, doc := range cmd.val.Docs {
if doc.ID != tt.want[i].ID {
t.Errorf("Docs[%d].ID = %s, want %s", i, doc.ID, tt.want[i].ID)
}
if len(doc.Fields) != len(tt.want[i].Fields) {
t.Errorf("Docs[%d].Fields = %v, want %v", i, doc.Fields, tt.want[i].Fields)
}
for k, v := range tt.want[i].Fields {
if doc.Fields[k] != v {
t.Errorf("Docs[%d].Fields[%s] = %s, want %s", i, k, doc.Fields[k], v)
}
}
}
})
}
}
// TestFTSearchCmdReadReplyReturnZero validates that an empty RETURN keeps the
// scores, payloads and sort keys on RESP2 and RESP3
func TestFTSearchCmdReadReplyReturnZero(t *testing.T) {
str := func(s string) *string { return &s }
tests := []struct {
name string
options *FTSearchOptions
resp2 string
resp3 string
want []Document
}{
{
name: "scores",
options: &FTSearchOptions{Return: []FTSearchReturn{}, WithScores: true},
resp2: "*5\r\n:2\r\n$5\r\ndoc:2\r\n$3\r\n0.5\r\n$5\r\ndoc:1\r\n$4\r\n0.25\r\n",
resp3: "%2\r\n+total_results\r\n:2\r\n+results\r\n*2\r\n" +
"%3\r\n+id\r\n$5\r\ndoc:2\r\n+score\r\n,0.5\r\n+values\r\n*0\r\n" +
"%3\r\n+id\r\n$5\r\ndoc:1\r\n+score\r\n,0.25\r\n+values\r\n*0\r\n",
want: []Document{
{ID: "doc:2", Score: ptrFloat64(0.5)},
{ID: "doc:1", Score: ptrFloat64(0.25)},
},
},
{
name: "scores with explanation",
options: &FTSearchOptions{Return: []FTSearchReturn{}, WithScores: true, ExplainScore: true},
resp2: "*3\r\n:1\r\n$5\r\ndoc:2\r\n*2\r\n$3\r\n0.5\r\n*1\r\n$4\r\nbm25\r\n",
resp3: "%2\r\n+total_results\r\n:1\r\n+results\r\n*1\r\n" +
"%3\r\n+id\r\n$5\r\ndoc:2\r\n+score\r\n*2\r\n,0.5\r\n*1\r\n+bm25\r\n+values\r\n*0\r\n",
want: []Document{{ID: "doc:2", Score: ptrFloat64(0.5)}},
},
{
name: "scores, payloads and sort keys",
options: &FTSearchOptions{Return: []FTSearchReturn{}, WithScores: true, WithPayloads: true, WithSortKeys: true},
resp2: "*9\r\n:2\r\n$5\r\ndoc:1\r\n$4\r\n0.25\r\n$2\r\np1\r\n$2\r\n#1\r\n" +
"$5\r\ndoc:2\r\n$3\r\n0.5\r\n$-1\r\n$-1\r\n",
resp3: "%2\r\n+total_results\r\n:2\r\n+results\r\n*2\r\n" +
"%5\r\n+id\r\n$5\r\ndoc:1\r\n+score\r\n,0.25\r\n+payload\r\n$2\r\np1\r\n+sortkey\r\n$2\r\n#1\r\n+values\r\n*0\r\n" +
"%5\r\n+id\r\n$5\r\ndoc:2\r\n+score\r\n,0.5\r\n+payload\r\n_\r\n+sortkey\r\n_\r\n+values\r\n*0\r\n",
want: []Document{
{ID: "doc:1", Score: ptrFloat64(0.25), Payload: str("p1"), SortKey: str("#1")},
{ID: "doc:2", Score: ptrFloat64(0.5)},
},
},
}
for _, tt := range tests {
for protocol, reply := range map[string]string{"RESP2": tt.resp2, "RESP3": tt.resp3} {
t.Run(tt.name+"/"+protocol, func(t *testing.T) {
cmd := newFTSearchCmd(context.Background(), tt.options)
if err := cmd.readReply(proto.NewReader(strings.NewReader(reply))); err != nil {
t.Fatalf("readReply() error = %v", err)
}
if cmd.val.Total != len(tt.want) {
t.Errorf("Total = %d, want %d", cmd.val.Total, len(tt.want))
}
if len(cmd.val.Docs) != len(tt.want) {
t.Fatalf("Docs count = %d, want %d", len(cmd.val.Docs), len(tt.want))
}
for i, doc := range cmd.val.Docs {
want := tt.want[i]
if doc.ID != want.ID {
t.Errorf("Docs[%d].ID = %s, want %s", i, doc.ID, want.ID)
}
if doc.Score == nil || *doc.Score != *want.Score {
t.Errorf("Docs[%d].Score = %v, want %v", i, doc.Score, *want.Score)
}
if (doc.Payload == nil) != (want.Payload == nil) || (doc.Payload != nil && *doc.Payload != *want.Payload) {
t.Errorf("Docs[%d].Payload = %v, want %v", i, doc.Payload, want.Payload)
}
if (doc.SortKey == nil) != (want.SortKey == nil) || (doc.SortKey != nil && *doc.SortKey != *want.SortKey) {
t.Errorf("Docs[%d].SortKey = %v, want %v", i, doc.SortKey, want.SortKey)
}
if len(doc.Fields) != 0 {
t.Errorf("Docs[%d].Fields = %v, want empty", i, doc.Fields)
}
}
})
}
}
}
// 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)
}
}
}