Files
go/search_commands_parse_test.go
14a9a85363 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 <[email protected]>
2026-05-21 14:18:35 +03:00

1702 lines
50 KiB
Go

package redis
import (
"math"
"testing"
)
// 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
}{
{
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)
}
}
}