mirror of
https://github.com/hanzokv/go.git
synced 2026-09-24 00:31:51 +00:00
* 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]>
1702 lines
50 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|