mirror of
https://github.com/redis/go-redis.git
synced 2026-10-28 08:24:54 +08:00
* fix(search): read per-doc RESP2 values in FT.SEARCH On RESP2, FT.SEARCH puts the WITHSCORES, WITHPAYLOADS and WITHSORTKEYS values right after each document ID, even with NOCONTENT, and sends a nil payload for a document without one. parseFTSearch stopped at the ID when NoContent was set and skipped a nil payload without consuming it, so a score, sort key or nil was read as the next document ID: Docs held twice the entries, or the call failed with "invalid document ID format". Read every requested value before the fields. * fix(search): step past nil RESP2 sort keys FT.SEARCH WITHSORTKEYS sends a nil sort key for a document that lacks the SORTBY field, and for every document when there is no SORTBY. parseFTSearch only stepped past string sort keys, so the nil was read as the fields or the next document ID and the call failed with "invalid document ID format". Step past the slot like the payload. * test(search): cover nil RESP2 sort key before a doc * test(search): compare RESP2 and RESP3 payloads
2113 lines
62 KiB
Go
2113 lines
62 KiB
Go
package redis
|
|
|
|
import (
|
|
"context"
|
|
"math"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/redis/go-redis/v9/internal/proto"
|
|
)
|
|
|
|
// TestParseFTInfo tests the parseFTInfo function with a comprehensive FT.INFO response
|
|
// This test uses the actual response structure from Redis with vector fields
|
|
func TestParseFTInfo(t *testing.T) {
|
|
// This is the data structure that would be returned by Redis for FT.INFO
|
|
// Based on the redis-cli output provided by the user
|
|
data := map[string]interface{}{
|
|
"index_name": "rand:42d1f2820b3048b6bc783d4dcdb9094a",
|
|
"index_options": []interface{}{},
|
|
"index_definition": []interface{}{
|
|
"key_type", "HASH",
|
|
"prefixes", []interface{}{"rand:42d1f2820b3048b6bc783d4dcdb9094a"},
|
|
"default_score", "1",
|
|
"indexes_all", false,
|
|
},
|
|
"attributes": []interface{}{
|
|
// prompt field (TEXT)
|
|
[]interface{}{
|
|
"identifier", "prompt",
|
|
"attribute", "prompt",
|
|
"type", "TEXT",
|
|
"WEIGHT", "1",
|
|
},
|
|
// response field (TEXT)
|
|
[]interface{}{
|
|
"identifier", "response",
|
|
"attribute", "response",
|
|
"type", "TEXT",
|
|
"WEIGHT", "1",
|
|
},
|
|
// exact_digest field (TAG)
|
|
[]interface{}{
|
|
"identifier", "exact_digest",
|
|
"attribute", "exact_digest",
|
|
"type", "TAG",
|
|
"SEPARATOR", ",",
|
|
},
|
|
// prompt_vector field (VECTOR)
|
|
[]interface{}{
|
|
"identifier", "prompt_vector",
|
|
"attribute", "prompt_vector",
|
|
"type", "VECTOR",
|
|
"algorithm", "HNSW",
|
|
"data_type", "FLOAT32",
|
|
"dim", int64(1536),
|
|
"distance_metric", "COSINE",
|
|
"M", int64(16),
|
|
"ef_construction", int64(64),
|
|
},
|
|
},
|
|
"num_docs": int64(0),
|
|
"max_doc_id": int64(0),
|
|
"num_terms": int64(0),
|
|
"num_records": int64(0),
|
|
"inverted_sz_mb": "0",
|
|
"vector_index_sz_mb": "0",
|
|
"total_inverted_index_blocks": int64(0),
|
|
"offset_vectors_sz_mb": "0",
|
|
"doc_table_size_mb": "0.01532745361328125",
|
|
"sortable_values_size_mb": "0",
|
|
"key_table_size_mb": "2.288818359375e-5",
|
|
"tag_overhead_sz_mb": "0",
|
|
"text_overhead_sz_mb": "0",
|
|
"total_index_memory_sz_mb": "0.015350341796875",
|
|
"geoshapes_sz_mb": "0",
|
|
"records_per_doc_avg": "nan",
|
|
"bytes_per_record_avg": "nan",
|
|
"offsets_per_term_avg": "nan",
|
|
"offset_bits_per_record_avg": "nan",
|
|
"hash_indexing_failures": int64(0),
|
|
"total_indexing_time": "0",
|
|
"indexing": int64(0),
|
|
"percent_indexed": "1",
|
|
"number_of_uses": int64(2),
|
|
"cleaning": int64(0),
|
|
"gc_stats": []interface{}{
|
|
"bytes_collected", "0",
|
|
"total_ms_run", "0",
|
|
"total_cycles", "0",
|
|
"average_cycle_time_ms", "nan",
|
|
"last_run_time_ms", "0",
|
|
"gc_numeric_trees_missed", "0",
|
|
"gc_blocks_denied", "0",
|
|
},
|
|
"cursor_stats": []interface{}{
|
|
"global_idle", int64(0),
|
|
"global_total", int64(0),
|
|
"index_capacity", int64(128),
|
|
"index_total", int64(0),
|
|
},
|
|
"dialect_stats": []interface{}{
|
|
"dialect_1", int64(0),
|
|
"dialect_2", int64(0),
|
|
"dialect_3", int64(0),
|
|
"dialect_4", int64(0),
|
|
},
|
|
"Index Errors": []interface{}{
|
|
"indexing failures", int64(0),
|
|
"last indexing error", "N/A",
|
|
"last indexing error key", "N/A",
|
|
"background indexing status", "OK",
|
|
},
|
|
"field statistics": []interface{}{
|
|
[]interface{}{
|
|
"identifier", "prompt",
|
|
"attribute", "prompt",
|
|
"Index Errors", []interface{}{
|
|
"indexing failures", int64(0),
|
|
"last indexing error", "N/A",
|
|
"last indexing error key", "N/A",
|
|
},
|
|
},
|
|
[]interface{}{
|
|
"identifier", "response",
|
|
"attribute", "response",
|
|
"Index Errors", []interface{}{
|
|
"indexing failures", int64(0),
|
|
"last indexing error", "N/A",
|
|
"last indexing error key", "N/A",
|
|
},
|
|
},
|
|
[]interface{}{
|
|
"identifier", "exact_digest",
|
|
"attribute", "exact_digest",
|
|
"Index Errors", []interface{}{
|
|
"indexing failures", int64(0),
|
|
"last indexing error", "N/A",
|
|
"last indexing error key", "N/A",
|
|
},
|
|
},
|
|
[]interface{}{
|
|
"identifier", "prompt_vector",
|
|
"attribute", "prompt_vector",
|
|
"Index Errors", []interface{}{
|
|
"indexing failures", int64(0),
|
|
"last indexing error", "N/A",
|
|
"last indexing error key", "N/A",
|
|
},
|
|
"memory", int64(0),
|
|
"marked_deleted", int64(0),
|
|
},
|
|
},
|
|
}
|
|
|
|
// Parse the data
|
|
result, err := parseFTInfo(data)
|
|
if err != nil {
|
|
t.Fatalf("parseFTInfo failed: %v", err)
|
|
}
|
|
|
|
// Validate index name
|
|
if result.IndexName != "rand:42d1f2820b3048b6bc783d4dcdb9094a" {
|
|
t.Errorf("IndexName = %v, want %v", result.IndexName, "rand:42d1f2820b3048b6bc783d4dcdb9094a")
|
|
}
|
|
|
|
// Validate index definition
|
|
if result.IndexDefinition.KeyType != "HASH" {
|
|
t.Errorf("IndexDefinition.KeyType = %v, want HASH", result.IndexDefinition.KeyType)
|
|
}
|
|
if len(result.IndexDefinition.Prefixes) != 1 || result.IndexDefinition.Prefixes[0] != "rand:42d1f2820b3048b6bc783d4dcdb9094a" {
|
|
t.Errorf("IndexDefinition.Prefixes = %v, want [rand:42d1f2820b3048b6bc783d4dcdb9094a]", result.IndexDefinition.Prefixes)
|
|
}
|
|
if result.IndexDefinition.DefaultScore != 1.0 {
|
|
t.Errorf("IndexDefinition.DefaultScore = %v, want 1.0", result.IndexDefinition.DefaultScore)
|
|
}
|
|
|
|
// Validate attributes
|
|
if len(result.Attributes) != 4 {
|
|
t.Fatalf("len(Attributes) = %v, want 4", len(result.Attributes))
|
|
}
|
|
|
|
// Check prompt field (TEXT)
|
|
promptAttr := result.Attributes[0]
|
|
if promptAttr.Identifier != "prompt" {
|
|
t.Errorf("Attributes[0].Identifier = %v, want prompt", promptAttr.Identifier)
|
|
}
|
|
if promptAttr.Attribute != "prompt" {
|
|
t.Errorf("Attributes[0].Attribute = %v, want prompt", promptAttr.Attribute)
|
|
}
|
|
if promptAttr.Type != "TEXT" {
|
|
t.Errorf("Attributes[0].Type = %v, want TEXT", promptAttr.Type)
|
|
}
|
|
if promptAttr.Weight != 1.0 {
|
|
t.Errorf("Attributes[0].Weight = %v, want 1.0", promptAttr.Weight)
|
|
}
|
|
|
|
// Check response field (TEXT)
|
|
responseAttr := result.Attributes[1]
|
|
if responseAttr.Identifier != "response" {
|
|
t.Errorf("Attributes[1].Identifier = %v, want response", responseAttr.Identifier)
|
|
}
|
|
if responseAttr.Attribute != "response" {
|
|
t.Errorf("Attributes[1].Attribute = %v, want response", responseAttr.Attribute)
|
|
}
|
|
if responseAttr.Type != "TEXT" {
|
|
t.Errorf("Attributes[1].Type = %v, want TEXT", responseAttr.Type)
|
|
}
|
|
|
|
// Check exact_digest field (TAG)
|
|
tagAttr := result.Attributes[2]
|
|
if tagAttr.Identifier != "exact_digest" {
|
|
t.Errorf("Attributes[2].Identifier = %v, want exact_digest", tagAttr.Identifier)
|
|
}
|
|
if tagAttr.Attribute != "exact_digest" {
|
|
t.Errorf("Attributes[2].Attribute = %v, want exact_digest", tagAttr.Attribute)
|
|
}
|
|
if tagAttr.Type != "TAG" {
|
|
t.Errorf("Attributes[2].Type = %v, want TAG", tagAttr.Type)
|
|
}
|
|
|
|
// Check prompt_vector field (VECTOR)
|
|
vectorAttr := result.Attributes[3]
|
|
if vectorAttr.Identifier != "prompt_vector" {
|
|
t.Errorf("Attributes[3].Identifier = %v, want prompt_vector", vectorAttr.Identifier)
|
|
}
|
|
if vectorAttr.Attribute != "prompt_vector" {
|
|
t.Errorf("Attributes[3].Attribute = %v, want prompt_vector", vectorAttr.Attribute)
|
|
}
|
|
if vectorAttr.Type != "VECTOR" {
|
|
t.Errorf("Attributes[3].Type = %v, want VECTOR", vectorAttr.Type)
|
|
}
|
|
if vectorAttr.Algorithm != "HNSW" {
|
|
t.Errorf("Attributes[3].Algorithm = %v, want HNSW", vectorAttr.Algorithm)
|
|
}
|
|
if vectorAttr.DataType != "FLOAT32" {
|
|
t.Errorf("Attributes[3].DataType = %v, want FLOAT32", vectorAttr.DataType)
|
|
}
|
|
if vectorAttr.Dim != 1536 {
|
|
t.Errorf("Attributes[3].Dim = %v, want 1536", vectorAttr.Dim)
|
|
}
|
|
if vectorAttr.DistanceMetric != "COSINE" {
|
|
t.Errorf("Attributes[3].DistanceMetric = %v, want COSINE", vectorAttr.DistanceMetric)
|
|
}
|
|
if vectorAttr.M != 16 {
|
|
t.Errorf("Attributes[3].M = %v, want 16", vectorAttr.M)
|
|
}
|
|
if vectorAttr.EFConstruction != 64 {
|
|
t.Errorf("Attributes[3].EFConstruction = %v, want 64", vectorAttr.EFConstruction)
|
|
}
|
|
|
|
// Validate numeric fields
|
|
if result.NumDocs != 0 {
|
|
t.Errorf("NumDocs = %v, want 0", result.NumDocs)
|
|
}
|
|
if result.MaxDocID != 0 {
|
|
t.Errorf("MaxDocID = %v, want 0", result.MaxDocID)
|
|
}
|
|
if result.NumTerms != 0 {
|
|
t.Errorf("NumTerms = %v, want 0", result.NumTerms)
|
|
}
|
|
if result.NumRecords != 0 {
|
|
t.Errorf("NumRecords = %v, want 0", result.NumRecords)
|
|
}
|
|
if result.Indexing != 0 {
|
|
t.Errorf("Indexing = %v, want 0", result.Indexing)
|
|
}
|
|
if result.PercentIndexed != 1.0 {
|
|
t.Errorf("PercentIndexed = %v, want 1.0", result.PercentIndexed)
|
|
}
|
|
if result.HashIndexingFailures != 0 {
|
|
t.Errorf("HashIndexingFailures = %v, want 0", result.HashIndexingFailures)
|
|
}
|
|
if result.Cleaning != 0 {
|
|
t.Errorf("Cleaning = %v, want 0", result.Cleaning)
|
|
}
|
|
if result.NumberOfUses != 2 {
|
|
t.Errorf("NumberOfUses = %v, want 2", result.NumberOfUses)
|
|
}
|
|
|
|
// Validate average stats (should be "nan" for empty index)
|
|
if result.RecordsPerDocAvg != "nan" {
|
|
t.Errorf("RecordsPerDocAvg = %v, want nan", result.RecordsPerDocAvg)
|
|
}
|
|
if result.BytesPerRecordAvg != "nan" {
|
|
t.Errorf("BytesPerRecordAvg = %v, want nan", result.BytesPerRecordAvg)
|
|
}
|
|
if result.OffsetsPerTermAvg != "nan" {
|
|
t.Errorf("OffsetsPerTermAvg = %v, want nan", result.OffsetsPerTermAvg)
|
|
}
|
|
if result.OffsetBitsPerRecordAvg != "nan" {
|
|
t.Errorf("OffsetBitsPerRecordAvg = %v, want nan", result.OffsetBitsPerRecordAvg)
|
|
}
|
|
|
|
// Validate cursor stats
|
|
if result.CursorStats.GlobalIdle != 0 {
|
|
t.Errorf("CursorStats.GlobalIdle = %v, want 0", result.CursorStats.GlobalIdle)
|
|
}
|
|
if result.CursorStats.GlobalTotal != 0 {
|
|
t.Errorf("CursorStats.GlobalTotal = %v, want 0", result.CursorStats.GlobalTotal)
|
|
}
|
|
if result.CursorStats.IndexCapacity != 128 {
|
|
t.Errorf("CursorStats.IndexCapacity = %v, want 128", result.CursorStats.IndexCapacity)
|
|
}
|
|
if result.CursorStats.IndexTotal != 0 {
|
|
t.Errorf("CursorStats.IndexTotal = %v, want 0", result.CursorStats.IndexTotal)
|
|
}
|
|
|
|
// Validate dialect stats
|
|
if result.DialectStats["dialect_1"] != 0 {
|
|
t.Errorf("DialectStats[dialect_1] = %v, want 0", result.DialectStats["dialect_1"])
|
|
}
|
|
if result.DialectStats["dialect_2"] != 0 {
|
|
t.Errorf("DialectStats[dialect_2] = %v, want 0", result.DialectStats["dialect_2"])
|
|
}
|
|
if result.DialectStats["dialect_3"] != 0 {
|
|
t.Errorf("DialectStats[dialect_3] = %v, want 0", result.DialectStats["dialect_3"])
|
|
}
|
|
if result.DialectStats["dialect_4"] != 0 {
|
|
t.Errorf("DialectStats[dialect_4] = %v, want 0", result.DialectStats["dialect_4"])
|
|
}
|
|
|
|
// Validate GC stats
|
|
if result.GCStats.BytesCollected != 0 {
|
|
t.Errorf("GCStats.BytesCollected = %v, want 0", result.GCStats.BytesCollected)
|
|
}
|
|
if result.GCStats.TotalMsRun != 0 {
|
|
t.Errorf("GCStats.TotalMsRun = %v, want 0", result.GCStats.TotalMsRun)
|
|
}
|
|
if result.GCStats.TotalCycles != 0 {
|
|
t.Errorf("GCStats.TotalCycles = %v, want 0", result.GCStats.TotalCycles)
|
|
}
|
|
if result.GCStats.AverageCycleTimeMs != "nan" {
|
|
t.Errorf("GCStats.AverageCycleTimeMs = %v, want nan", result.GCStats.AverageCycleTimeMs)
|
|
}
|
|
|
|
// Validate Index Errors
|
|
if result.IndexErrors.IndexingFailures != 0 {
|
|
t.Errorf("IndexErrors.IndexingFailures = %v, want 0", result.IndexErrors.IndexingFailures)
|
|
}
|
|
if result.IndexErrors.LastIndexingError != "N/A" {
|
|
t.Errorf("IndexErrors.LastIndexingError = %v, want N/A", result.IndexErrors.LastIndexingError)
|
|
}
|
|
if result.IndexErrors.LastIndexingErrorKey != "N/A" {
|
|
t.Errorf("IndexErrors.LastIndexingErrorKey = %v, want N/A", result.IndexErrors.LastIndexingErrorKey)
|
|
}
|
|
|
|
// Validate field statistics
|
|
if len(result.FieldStatistics) != 4 {
|
|
t.Fatalf("len(FieldStatistics) = %v, want 4", len(result.FieldStatistics))
|
|
}
|
|
|
|
expectedIdentifiers := map[string]bool{
|
|
"prompt": true,
|
|
"response": true,
|
|
"exact_digest": true,
|
|
"prompt_vector": true,
|
|
}
|
|
|
|
for _, fieldStat := range result.FieldStatistics {
|
|
if !expectedIdentifiers[fieldStat.Identifier] {
|
|
t.Errorf("Unexpected field statistic identifier: %v", fieldStat.Identifier)
|
|
}
|
|
if fieldStat.IndexErrors.IndexingFailures != 0 {
|
|
t.Errorf("FieldStatistic[%s].IndexErrors.IndexingFailures = %v, want 0", fieldStat.Identifier, fieldStat.IndexErrors.IndexingFailures)
|
|
}
|
|
if fieldStat.IndexErrors.LastIndexingError != "N/A" {
|
|
t.Errorf("FieldStatistic[%s].IndexErrors.LastIndexingError = %v, want N/A", fieldStat.Identifier, fieldStat.IndexErrors.LastIndexingError)
|
|
}
|
|
if fieldStat.IndexErrors.LastIndexingErrorKey != "N/A" {
|
|
t.Errorf("FieldStatistic[%s].IndexErrors.LastIndexingErrorKey = %v, want N/A", fieldStat.Identifier, fieldStat.IndexErrors.LastIndexingErrorKey)
|
|
}
|
|
}
|
|
|
|
t.Logf("Successfully parsed FT.INFO response with %d attributes", len(result.Attributes))
|
|
}
|
|
|
|
// toInterfaceMap converts map[string]interface{} to map[interface{}]interface{}
|
|
// This is needed because RESP3 returns map[interface{}]interface{} but test data
|
|
// is easier to write as map[string]interface{}
|
|
func toInterfaceMap(m map[string]interface{}) map[interface{}]interface{} {
|
|
result := make(map[interface{}]interface{}, len(m))
|
|
for k, v := range m {
|
|
switch val := v.(type) {
|
|
case map[string]interface{}:
|
|
result[k] = toInterfaceMap(val)
|
|
case []interface{}:
|
|
result[k] = toInterfaceSlice(val)
|
|
default:
|
|
result[k] = v
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// toInterfaceSlice converts []interface{} elements recursively
|
|
func toInterfaceSlice(s []interface{}) []interface{} {
|
|
result := make([]interface{}, len(s))
|
|
for i, v := range s {
|
|
switch val := v.(type) {
|
|
case map[string]interface{}:
|
|
result[i] = toInterfaceMap(val)
|
|
case []interface{}:
|
|
result[i] = toInterfaceSlice(val)
|
|
default:
|
|
result[i] = v
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// TestParseFTSearchRESP3 tests the RESP3 parsing for FT.SEARCH results
|
|
func TestParseFTSearchRESP3(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input map[string]interface{}
|
|
expected FTSearchResult
|
|
}{
|
|
{
|
|
name: "empty result",
|
|
input: map[string]interface{}{
|
|
"attributes": []interface{}{},
|
|
"total_results": int64(0),
|
|
"format": "STRING",
|
|
"results": []interface{}{},
|
|
"warning": []interface{}{},
|
|
},
|
|
expected: FTSearchResult{
|
|
Total: 0,
|
|
Docs: []Document{},
|
|
Warnings: []string{},
|
|
},
|
|
},
|
|
{
|
|
name: "simple query result",
|
|
input: map[string]interface{}{
|
|
"attributes": []interface{}{},
|
|
"total_results": int64(2),
|
|
"format": "STRING",
|
|
"results": []interface{}{
|
|
map[string]interface{}{
|
|
"id": "usr:david",
|
|
"extra_attributes": map[string]interface{}{
|
|
"first_name": "David",
|
|
"last_name": "Maier",
|
|
},
|
|
"values": []interface{}{},
|
|
},
|
|
map[string]interface{}{
|
|
"id": "usr:shaya",
|
|
"extra_attributes": map[string]interface{}{
|
|
"first_name": "Shaya",
|
|
"last_name": "Potter",
|
|
},
|
|
"values": []interface{}{},
|
|
},
|
|
},
|
|
"warning": []interface{}{},
|
|
},
|
|
expected: FTSearchResult{
|
|
Total: 2,
|
|
Docs: []Document{
|
|
{
|
|
ID: "usr:david",
|
|
Fields: map[string]string{"first_name": "David", "last_name": "Maier"},
|
|
},
|
|
{
|
|
ID: "usr:shaya",
|
|
Fields: map[string]string{"first_name": "Shaya", "last_name": "Potter"},
|
|
},
|
|
},
|
|
Warnings: []string{},
|
|
},
|
|
},
|
|
{
|
|
name: "result with scores",
|
|
input: map[string]interface{}{
|
|
"attributes": []interface{}{},
|
|
"total_results": int64(1),
|
|
"format": "STRING",
|
|
"results": []interface{}{
|
|
map[string]interface{}{
|
|
"id": "bicycle:hash:1",
|
|
"score": float64(1.5),
|
|
"extra_attributes": map[string]interface{}{
|
|
"price": "1200",
|
|
"brand": "Bicyk",
|
|
},
|
|
"values": []interface{}{},
|
|
},
|
|
},
|
|
"warning": []interface{}{},
|
|
},
|
|
expected: FTSearchResult{
|
|
Total: 1,
|
|
Docs: []Document{
|
|
{
|
|
ID: "bicycle:hash:1",
|
|
Score: ptrFloat64(1.5),
|
|
Fields: map[string]string{"price": "1200", "brand": "Bicyk"},
|
|
},
|
|
},
|
|
Warnings: []string{},
|
|
},
|
|
},
|
|
{
|
|
name: "result with warnings",
|
|
input: map[string]interface{}{
|
|
"attributes": []interface{}{},
|
|
"total_results": int64(1),
|
|
"format": "STRING",
|
|
"results": []interface{}{
|
|
map[string]interface{}{
|
|
"id": "doc:1",
|
|
"extra_attributes": map[string]interface{}{
|
|
"field1": "value1",
|
|
},
|
|
"values": []interface{}{},
|
|
},
|
|
},
|
|
"warning": []interface{}{"Timeout limit was reached"},
|
|
},
|
|
expected: FTSearchResult{
|
|
Total: 1,
|
|
Docs: []Document{
|
|
{
|
|
ID: "doc:1",
|
|
Fields: map[string]string{"field1": "value1"},
|
|
},
|
|
},
|
|
Warnings: []string{"Timeout limit was reached"},
|
|
},
|
|
},
|
|
{
|
|
name: "NOCONTENT result",
|
|
input: map[string]interface{}{
|
|
"attributes": []interface{}{},
|
|
"total_results": int64(10),
|
|
"format": "STRING",
|
|
"results": []interface{}{
|
|
map[string]interface{}{
|
|
"id": "bicycle:hash:2",
|
|
"values": []interface{}{},
|
|
},
|
|
},
|
|
"warning": []interface{}{},
|
|
},
|
|
expected: FTSearchResult{
|
|
Total: 10,
|
|
Docs: []Document{
|
|
{
|
|
ID: "bicycle:hash:2",
|
|
Fields: map[string]string{},
|
|
},
|
|
},
|
|
Warnings: []string{},
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result, err := parseFTSearchMapRESP3(toInterfaceMap(tt.input))
|
|
if err != nil {
|
|
t.Fatalf("parseFTSearchMapRESP3() error = %v", err)
|
|
}
|
|
|
|
if result.Total != tt.expected.Total {
|
|
t.Errorf("Total = %v, want %v", result.Total, tt.expected.Total)
|
|
}
|
|
|
|
if len(result.Docs) != len(tt.expected.Docs) {
|
|
t.Errorf("len(Docs) = %v, want %v", len(result.Docs), len(tt.expected.Docs))
|
|
}
|
|
|
|
for i, doc := range result.Docs {
|
|
if doc.ID != tt.expected.Docs[i].ID {
|
|
t.Errorf("Docs[%d].ID = %v, want %v", i, doc.ID, tt.expected.Docs[i].ID)
|
|
}
|
|
if tt.expected.Docs[i].Score != nil {
|
|
if doc.Score == nil || *doc.Score != *tt.expected.Docs[i].Score {
|
|
t.Errorf("Docs[%d].Score = %v, want %v", i, doc.Score, tt.expected.Docs[i].Score)
|
|
}
|
|
}
|
|
for k, v := range tt.expected.Docs[i].Fields {
|
|
if doc.Fields[k] != v {
|
|
t.Errorf("Docs[%d].Fields[%s] = %v, want %v", i, k, doc.Fields[k], v)
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(result.Warnings) != len(tt.expected.Warnings) {
|
|
t.Errorf("len(Warnings) = %v, want %v", len(result.Warnings), len(tt.expected.Warnings))
|
|
}
|
|
for i, w := range result.Warnings {
|
|
if w != tt.expected.Warnings[i] {
|
|
t.Errorf("Warnings[%d] = %v, want %v", i, w, tt.expected.Warnings[i])
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestParseFTAggregateRESP3 tests the RESP3 parsing for FT.AGGREGATE results
|
|
func TestParseFTAggregateRESP3(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input map[string]interface{}
|
|
expected FTAggregateResult
|
|
}{
|
|
{
|
|
name: "empty result",
|
|
input: map[string]interface{}{
|
|
"attributes": []interface{}{},
|
|
"total_results": int64(0),
|
|
"format": "STRING",
|
|
"results": []interface{}{},
|
|
"warning": []interface{}{},
|
|
},
|
|
expected: FTAggregateResult{
|
|
Total: 0,
|
|
Rows: []AggregateRow{},
|
|
Warnings: []string{},
|
|
},
|
|
},
|
|
{
|
|
name: "aggregation with groupby",
|
|
input: map[string]interface{}{
|
|
"attributes": []interface{}{},
|
|
"total_results": int64(3),
|
|
"format": "STRING",
|
|
"results": []interface{}{
|
|
map[string]interface{}{
|
|
"extra_attributes": map[string]interface{}{
|
|
"condition": "refurbished",
|
|
"num_affordable": "158",
|
|
},
|
|
"values": []interface{}{},
|
|
},
|
|
map[string]interface{}{
|
|
"extra_attributes": map[string]interface{}{
|
|
"condition": "used",
|
|
"num_affordable": "156",
|
|
},
|
|
"values": []interface{}{},
|
|
},
|
|
map[string]interface{}{
|
|
"extra_attributes": map[string]interface{}{
|
|
"condition": "new",
|
|
"num_affordable": "158",
|
|
},
|
|
"values": []interface{}{},
|
|
},
|
|
},
|
|
"warning": []interface{}{},
|
|
},
|
|
expected: FTAggregateResult{
|
|
Total: 3,
|
|
Rows: []AggregateRow{
|
|
{Fields: map[string]interface{}{"condition": "refurbished", "num_affordable": "158"}},
|
|
{Fields: map[string]interface{}{"condition": "used", "num_affordable": "156"}},
|
|
{Fields: map[string]interface{}{"condition": "new", "num_affordable": "158"}},
|
|
},
|
|
Warnings: []string{},
|
|
},
|
|
},
|
|
{
|
|
name: "aggregation with warnings",
|
|
input: map[string]interface{}{
|
|
"attributes": []interface{}{},
|
|
"total_results": int64(1),
|
|
"format": "STRING",
|
|
"results": []interface{}{
|
|
map[string]interface{}{
|
|
"extra_attributes": map[string]interface{}{
|
|
"__key": "bicycle:0",
|
|
"price": "270",
|
|
"discounted": "243",
|
|
},
|
|
"values": []interface{}{},
|
|
},
|
|
},
|
|
"warning": []interface{}{"Timeout limit was reached"},
|
|
},
|
|
expected: FTAggregateResult{
|
|
Total: 1,
|
|
Rows: []AggregateRow{
|
|
{Fields: map[string]interface{}{"__key": "bicycle:0", "price": "270", "discounted": "243"}},
|
|
},
|
|
Warnings: []string{"Timeout limit was reached"},
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result, err := parseFTAggregateMapRESP3(toInterfaceMap(tt.input))
|
|
if err != nil {
|
|
t.Fatalf("parseFTAggregateMapRESP3() error = %v", err)
|
|
}
|
|
|
|
if result.Total != tt.expected.Total {
|
|
t.Errorf("Total = %v, want %v", result.Total, tt.expected.Total)
|
|
}
|
|
|
|
if len(result.Rows) != len(tt.expected.Rows) {
|
|
t.Errorf("len(Rows) = %v, want %v", len(result.Rows), len(tt.expected.Rows))
|
|
}
|
|
|
|
for i, row := range result.Rows {
|
|
for k, v := range tt.expected.Rows[i].Fields {
|
|
if row.Fields[k] != v {
|
|
t.Errorf("Rows[%d].Fields[%s] = %v, want %v", i, k, row.Fields[k], v)
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(result.Warnings) != len(tt.expected.Warnings) {
|
|
t.Errorf("len(Warnings) = %v, want %v", len(result.Warnings), len(tt.expected.Warnings))
|
|
}
|
|
for i, w := range result.Warnings {
|
|
if w != tt.expected.Warnings[i] {
|
|
t.Errorf("Warnings[%d] = %v, want %v", i, w, tt.expected.Warnings[i])
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Helper function to create a pointer to a float64
|
|
func ptrFloat64(f float64) *float64 {
|
|
return &f
|
|
}
|
|
|
|
// TestFTSearchRESP2AndRESP3Equivalence validates that RESP2 and RESP3 parsing
|
|
// produce semantically equivalent results as per the spec requirement R.1.1
|
|
func TestFTSearchRESP2AndRESP3Equivalence(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
resp2Data []interface{}
|
|
resp3Data map[string]interface{}
|
|
noContent bool
|
|
withScores bool
|
|
withPayloads bool
|
|
withSortKeys bool
|
|
}{
|
|
{
|
|
name: "simple hash query",
|
|
resp2Data: []interface{}{
|
|
int64(2),
|
|
"usr:david",
|
|
[]interface{}{"first_name", "David", "last_name", "Maier"},
|
|
"usr:shaya",
|
|
[]interface{}{"first_name", "Shaya", "last_name", "Potter"},
|
|
},
|
|
resp3Data: map[string]interface{}{
|
|
"attributes": []interface{}{},
|
|
"total_results": int64(2),
|
|
"format": "STRING",
|
|
"results": []interface{}{
|
|
map[string]interface{}{
|
|
"id": "usr:david",
|
|
"extra_attributes": map[string]interface{}{
|
|
"first_name": "David",
|
|
"last_name": "Maier",
|
|
},
|
|
"values": []interface{}{},
|
|
},
|
|
map[string]interface{}{
|
|
"id": "usr:shaya",
|
|
"extra_attributes": map[string]interface{}{
|
|
"first_name": "Shaya",
|
|
"last_name": "Potter",
|
|
},
|
|
"values": []interface{}{},
|
|
},
|
|
},
|
|
"warning": []interface{}{},
|
|
},
|
|
noContent: false,
|
|
withScores: false,
|
|
},
|
|
{
|
|
name: "query with scores",
|
|
resp2Data: []interface{}{
|
|
int64(1),
|
|
"bicycle:hash:1",
|
|
"1.5",
|
|
[]interface{}{"price", "1200", "brand", "Bicyk"},
|
|
},
|
|
resp3Data: map[string]interface{}{
|
|
"attributes": []interface{}{},
|
|
"total_results": int64(1),
|
|
"format": "STRING",
|
|
"results": []interface{}{
|
|
map[string]interface{}{
|
|
"id": "bicycle:hash:1",
|
|
"score": float64(1.5),
|
|
"extra_attributes": map[string]interface{}{
|
|
"price": "1200",
|
|
"brand": "Bicyk",
|
|
},
|
|
"values": []interface{}{},
|
|
},
|
|
},
|
|
"warning": []interface{}{},
|
|
},
|
|
noContent: false,
|
|
withScores: true,
|
|
},
|
|
{
|
|
name: "JSON document query",
|
|
resp2Data: []interface{}{
|
|
int64(1),
|
|
"bicycle:1",
|
|
[]interface{}{"$", `{"brand":"Bicyk","model":"Hillcraft","price":1200}`},
|
|
},
|
|
resp3Data: map[string]interface{}{
|
|
"attributes": []interface{}{},
|
|
"total_results": int64(1),
|
|
"format": "STRING",
|
|
"results": []interface{}{
|
|
map[string]interface{}{
|
|
"id": "bicycle:1",
|
|
"extra_attributes": map[string]interface{}{
|
|
"$": `{"brand":"Bicyk","model":"Hillcraft","price":1200}`,
|
|
},
|
|
"values": []interface{}{},
|
|
},
|
|
},
|
|
"warning": []interface{}{},
|
|
},
|
|
noContent: false,
|
|
withScores: false,
|
|
},
|
|
{
|
|
name: "NOCONTENT query",
|
|
resp2Data: []interface{}{
|
|
int64(10),
|
|
"bicycle:hash:2",
|
|
},
|
|
resp3Data: map[string]interface{}{
|
|
"attributes": []interface{}{},
|
|
"total_results": int64(10),
|
|
"format": "STRING",
|
|
"results": []interface{}{
|
|
map[string]interface{}{
|
|
"id": "bicycle:hash:2",
|
|
"values": []interface{}{},
|
|
},
|
|
},
|
|
"warning": []interface{}{},
|
|
},
|
|
noContent: true,
|
|
withScores: false,
|
|
},
|
|
{
|
|
name: "NOCONTENT query with scores",
|
|
resp2Data: []interface{}{
|
|
int64(2),
|
|
"doc:2",
|
|
"0.25",
|
|
"doc:1",
|
|
"0.125",
|
|
},
|
|
resp3Data: map[string]interface{}{
|
|
"attributes": []interface{}{},
|
|
"total_results": int64(2),
|
|
"format": "STRING",
|
|
"results": []interface{}{
|
|
map[string]interface{}{
|
|
"id": "doc:2",
|
|
"score": float64(0.25),
|
|
"values": []interface{}{},
|
|
},
|
|
map[string]interface{}{
|
|
"id": "doc:1",
|
|
"score": float64(0.125),
|
|
"values": []interface{}{},
|
|
},
|
|
},
|
|
"warning": []interface{}{},
|
|
},
|
|
noContent: true,
|
|
withScores: true,
|
|
},
|
|
{
|
|
name: "NOCONTENT query with sort keys",
|
|
resp2Data: []interface{}{
|
|
int64(2),
|
|
"doc:1",
|
|
"#1",
|
|
"doc:2",
|
|
"#2",
|
|
},
|
|
resp3Data: map[string]interface{}{
|
|
"attributes": []interface{}{},
|
|
"total_results": int64(2),
|
|
"format": "STRING",
|
|
"results": []interface{}{
|
|
map[string]interface{}{
|
|
"id": "doc:1",
|
|
"sortkey": "#1",
|
|
"values": []interface{}{},
|
|
},
|
|
map[string]interface{}{
|
|
"id": "doc:2",
|
|
"sortkey": "#2",
|
|
"values": []interface{}{},
|
|
},
|
|
},
|
|
"warning": []interface{}{},
|
|
},
|
|
noContent: true,
|
|
withSortKeys: true,
|
|
},
|
|
{
|
|
name: "query with empty payloads",
|
|
resp2Data: []interface{}{
|
|
int64(2),
|
|
"doc:2",
|
|
nil,
|
|
[]interface{}{"n", "2"},
|
|
"doc:1",
|
|
nil,
|
|
[]interface{}{"n", "1"},
|
|
},
|
|
resp3Data: map[string]interface{}{
|
|
"attributes": []interface{}{},
|
|
"total_results": int64(2),
|
|
"format": "STRING",
|
|
"results": []interface{}{
|
|
map[string]interface{}{
|
|
"id": "doc:2",
|
|
"extra_attributes": map[string]interface{}{"n": "2"},
|
|
"values": []interface{}{},
|
|
},
|
|
map[string]interface{}{
|
|
"id": "doc:1",
|
|
"extra_attributes": map[string]interface{}{"n": "1"},
|
|
"values": []interface{}{},
|
|
},
|
|
},
|
|
"warning": []interface{}{},
|
|
},
|
|
withPayloads: true,
|
|
},
|
|
{
|
|
name: "query with sort keys and a missing sort field",
|
|
resp2Data: []interface{}{
|
|
int64(2),
|
|
"doc:1",
|
|
"#5",
|
|
[]interface{}{"n", "5"},
|
|
"doc:2",
|
|
nil,
|
|
[]interface{}{"txt", "hello"},
|
|
},
|
|
resp3Data: map[string]interface{}{
|
|
"attributes": []interface{}{},
|
|
"total_results": int64(2),
|
|
"format": "STRING",
|
|
"results": []interface{}{
|
|
map[string]interface{}{
|
|
"id": "doc:1",
|
|
"sortkey": "#5",
|
|
"extra_attributes": map[string]interface{}{"n": "5"},
|
|
"values": []interface{}{},
|
|
},
|
|
map[string]interface{}{
|
|
"id": "doc:2",
|
|
"sortkey": nil,
|
|
"extra_attributes": map[string]interface{}{"txt": "hello"},
|
|
"values": []interface{}{},
|
|
},
|
|
},
|
|
"warning": []interface{}{},
|
|
},
|
|
withSortKeys: true,
|
|
},
|
|
{
|
|
name: "NOCONTENT query with a missing sort field before another document",
|
|
resp2Data: []interface{}{
|
|
int64(2),
|
|
"doc:1",
|
|
nil,
|
|
"doc:2",
|
|
"#5",
|
|
},
|
|
resp3Data: map[string]interface{}{
|
|
"attributes": []interface{}{},
|
|
"total_results": int64(2),
|
|
"format": "STRING",
|
|
"results": []interface{}{
|
|
map[string]interface{}{
|
|
"id": "doc:1",
|
|
"sortkey": nil,
|
|
"values": []interface{}{},
|
|
},
|
|
map[string]interface{}{
|
|
"id": "doc:2",
|
|
"sortkey": "#5",
|
|
"values": []interface{}{},
|
|
},
|
|
},
|
|
"warning": []interface{}{},
|
|
},
|
|
noContent: true,
|
|
withSortKeys: true,
|
|
},
|
|
{
|
|
name: "query with empty payload and missing sort field",
|
|
resp2Data: []interface{}{
|
|
int64(2),
|
|
"doc:1",
|
|
nil,
|
|
nil,
|
|
[]interface{}{"txt", "hello"},
|
|
"doc:2",
|
|
"p",
|
|
"#5",
|
|
[]interface{}{"n", "5"},
|
|
},
|
|
resp3Data: map[string]interface{}{
|
|
"attributes": []interface{}{},
|
|
"total_results": int64(2),
|
|
"format": "STRING",
|
|
"results": []interface{}{
|
|
map[string]interface{}{
|
|
"id": "doc:1",
|
|
"sortkey": nil,
|
|
"extra_attributes": map[string]interface{}{"txt": "hello"},
|
|
"values": []interface{}{},
|
|
},
|
|
map[string]interface{}{
|
|
"id": "doc:2",
|
|
"payload": "p",
|
|
"sortkey": "#5",
|
|
"extra_attributes": map[string]interface{}{"n": "5"},
|
|
"values": []interface{}{},
|
|
},
|
|
},
|
|
"warning": []interface{}{},
|
|
},
|
|
withPayloads: true,
|
|
withSortKeys: true,
|
|
},
|
|
{
|
|
name: "empty result",
|
|
resp2Data: []interface{}{
|
|
int64(0),
|
|
},
|
|
resp3Data: map[string]interface{}{
|
|
"attributes": []interface{}{},
|
|
"total_results": int64(0),
|
|
"format": "STRING",
|
|
"results": []interface{}{},
|
|
"warning": []interface{}{},
|
|
},
|
|
noContent: false,
|
|
withScores: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
// Parse RESP2
|
|
resp2Result, err := parseFTSearch(tt.resp2Data, tt.noContent, tt.withScores, tt.withPayloads, tt.withSortKeys)
|
|
if err != nil {
|
|
t.Fatalf("parseFTSearch() error = %v", err)
|
|
}
|
|
|
|
// Parse RESP3
|
|
resp3Result, err := parseFTSearchMapRESP3(toInterfaceMap(tt.resp3Data))
|
|
if err != nil {
|
|
t.Fatalf("parseFTSearchMapRESP3() error = %v", err)
|
|
}
|
|
|
|
// Compare Total
|
|
if resp2Result.Total != resp3Result.Total {
|
|
t.Errorf("Total mismatch: RESP2=%d, RESP3=%d", resp2Result.Total, resp3Result.Total)
|
|
}
|
|
|
|
// Compare number of documents
|
|
if len(resp2Result.Docs) != len(resp3Result.Docs) {
|
|
t.Errorf("Docs count mismatch: RESP2=%d, RESP3=%d", len(resp2Result.Docs), len(resp3Result.Docs))
|
|
return
|
|
}
|
|
|
|
// Compare each document
|
|
for i, resp2Doc := range resp2Result.Docs {
|
|
resp3Doc := resp3Result.Docs[i]
|
|
|
|
if resp2Doc.ID != resp3Doc.ID {
|
|
t.Errorf("Docs[%d].ID mismatch: RESP2=%s, RESP3=%s", i, resp2Doc.ID, resp3Doc.ID)
|
|
}
|
|
|
|
// Compare scores (if present)
|
|
if tt.withScores {
|
|
if resp2Doc.Score == nil && resp3Doc.Score != nil {
|
|
t.Errorf("Docs[%d].Score mismatch: RESP2=nil, RESP3=%v", i, *resp3Doc.Score)
|
|
} else if resp2Doc.Score != nil && resp3Doc.Score == nil {
|
|
t.Errorf("Docs[%d].Score mismatch: RESP2=%v, RESP3=nil", i, *resp2Doc.Score)
|
|
} else if resp2Doc.Score != nil && resp3Doc.Score != nil && *resp2Doc.Score != *resp3Doc.Score {
|
|
t.Errorf("Docs[%d].Score mismatch: RESP2=%v, RESP3=%v", i, *resp2Doc.Score, *resp3Doc.Score)
|
|
}
|
|
}
|
|
|
|
if tt.withSortKeys {
|
|
if (resp2Doc.SortKey == nil) != (resp3Doc.SortKey == nil) ||
|
|
(resp2Doc.SortKey != nil && *resp2Doc.SortKey != *resp3Doc.SortKey) {
|
|
t.Errorf("Docs[%d].SortKey mismatch: RESP2=%v, RESP3=%v", i, resp2Doc.SortKey, resp3Doc.SortKey)
|
|
}
|
|
}
|
|
|
|
if tt.withPayloads {
|
|
if (resp2Doc.Payload == nil) != (resp3Doc.Payload == nil) ||
|
|
(resp2Doc.Payload != nil && *resp2Doc.Payload != *resp3Doc.Payload) {
|
|
t.Errorf("Docs[%d].Payload mismatch: RESP2=%v, RESP3=%v", i, resp2Doc.Payload, resp3Doc.Payload)
|
|
}
|
|
}
|
|
|
|
// Compare fields
|
|
if len(resp2Doc.Fields) != len(resp3Doc.Fields) {
|
|
t.Errorf("Docs[%d].Fields count mismatch: RESP2=%d, RESP3=%d", i, len(resp2Doc.Fields), len(resp3Doc.Fields))
|
|
}
|
|
|
|
for k, v := range resp2Doc.Fields {
|
|
if resp3Doc.Fields[k] != v {
|
|
t.Errorf("Docs[%d].Fields[%s] mismatch: RESP2=%s, RESP3=%s", i, k, v, resp3Doc.Fields[k])
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestFTSearchExplainScoreRESP2AndRESP3 validates that the score is read from
|
|
// the [score, explanation] pair returned with WITHSCORES EXPLAINSCORE
|
|
func TestFTSearchExplainScoreRESP2AndRESP3(t *testing.T) {
|
|
explanation := []interface{}{"Final BM25 : words BM25 0.21 * document score 1.00", []interface{}{"hello: (0.21 = ...)"}}
|
|
|
|
resp2Data := []interface{}{
|
|
int64(2),
|
|
"doc:2",
|
|
[]interface{}{"0.21", explanation},
|
|
[]interface{}{"t", "hello there"},
|
|
"doc:1",
|
|
[]interface{}{"0.16", explanation},
|
|
[]interface{}{"t", "hello world"},
|
|
}
|
|
resp3Data := map[string]interface{}{
|
|
"attributes": []interface{}{},
|
|
"total_results": int64(2),
|
|
"format": "STRING",
|
|
"results": []interface{}{
|
|
map[string]interface{}{
|
|
"id": "doc:2",
|
|
"score": []interface{}{float64(0.21), explanation},
|
|
"extra_attributes": map[string]interface{}{"t": "hello there"},
|
|
"values": []interface{}{},
|
|
},
|
|
map[string]interface{}{
|
|
"id": "doc:1",
|
|
"score": []interface{}{float64(0.16), explanation},
|
|
"extra_attributes": map[string]interface{}{"t": "hello world"},
|
|
"values": []interface{}{},
|
|
},
|
|
},
|
|
"warning": []interface{}{},
|
|
}
|
|
|
|
resp2Result, err := parseFTSearch(resp2Data, false, true, false, false)
|
|
if err != nil {
|
|
t.Fatalf("parseFTSearch() error = %v", err)
|
|
}
|
|
resp3Result, err := parseFTSearchMapRESP3(toInterfaceMap(resp3Data))
|
|
if err != nil {
|
|
t.Fatalf("parseFTSearchMapRESP3() error = %v", err)
|
|
}
|
|
|
|
want := []Document{
|
|
{ID: "doc:2", Score: ptrFloat64(0.21), Fields: map[string]string{"t": "hello there"}},
|
|
{ID: "doc:1", Score: ptrFloat64(0.16), Fields: map[string]string{"t": "hello world"}},
|
|
}
|
|
for name, result := range map[string]FTSearchResult{"RESP2": resp2Result, "RESP3": resp3Result} {
|
|
if len(result.Docs) != len(want) {
|
|
t.Fatalf("%s: Docs count = %d, want %d", name, len(result.Docs), len(want))
|
|
}
|
|
for i, doc := range result.Docs {
|
|
if doc.ID != want[i].ID {
|
|
t.Errorf("%s: Docs[%d].ID = %s, want %s", name, i, doc.ID, want[i].ID)
|
|
}
|
|
if doc.Score == nil || *doc.Score != *want[i].Score {
|
|
t.Errorf("%s: Docs[%d].Score = %v, want %v", name, i, doc.Score, *want[i].Score)
|
|
}
|
|
if doc.Fields["t"] != want[i].Fields["t"] {
|
|
t.Errorf("%s: Docs[%d].Fields[t] = %s, want %s", name, i, doc.Fields["t"], want[i].Fields["t"])
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestFTSearchCmdReadReplyRESP2 validates RESP2 readReply with nil options
|
|
// and an empty RETURN
|
|
func TestFTSearchCmdReadReplyRESP2(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
options *FTSearchOptions
|
|
reply string
|
|
want []Document
|
|
}{
|
|
{
|
|
name: "nil options",
|
|
options: nil,
|
|
reply: "*3\r\n:1\r\n$5\r\ndoc:1\r\n*2\r\n$1\r\nt\r\n$11\r\nhello world\r\n",
|
|
want: []Document{{ID: "doc:1", Fields: map[string]string{"t": "hello world"}}},
|
|
},
|
|
{
|
|
name: "empty RETURN",
|
|
options: &FTSearchOptions{Return: []FTSearchReturn{}},
|
|
reply: "*3\r\n:2\r\n$5\r\ndoc:2\r\n$5\r\ndoc:1\r\n",
|
|
want: []Document{{ID: "doc:2", Fields: map[string]string{}}, {ID: "doc:1", Fields: map[string]string{}}},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
cmd := newFTSearchCmd(context.Background(), tt.options)
|
|
if err := cmd.readReply(proto.NewReader(strings.NewReader(tt.reply))); err != nil {
|
|
t.Fatalf("readReply() error = %v", err)
|
|
}
|
|
if cmd.val.Total != len(tt.want) {
|
|
t.Errorf("Total = %d, want %d", cmd.val.Total, len(tt.want))
|
|
}
|
|
if len(cmd.val.Docs) != len(tt.want) {
|
|
t.Fatalf("Docs count = %d, want %d", len(cmd.val.Docs), len(tt.want))
|
|
}
|
|
for i, doc := range cmd.val.Docs {
|
|
if doc.ID != tt.want[i].ID {
|
|
t.Errorf("Docs[%d].ID = %s, want %s", i, doc.ID, tt.want[i].ID)
|
|
}
|
|
if len(doc.Fields) != len(tt.want[i].Fields) {
|
|
t.Errorf("Docs[%d].Fields = %v, want %v", i, doc.Fields, tt.want[i].Fields)
|
|
}
|
|
for k, v := range tt.want[i].Fields {
|
|
if doc.Fields[k] != v {
|
|
t.Errorf("Docs[%d].Fields[%s] = %s, want %s", i, k, doc.Fields[k], v)
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestFTSearchCmdReadReplyReturnZero validates that an empty RETURN keeps the
|
|
// scores, payloads and sort keys on RESP2 and RESP3
|
|
func TestFTSearchCmdReadReplyReturnZero(t *testing.T) {
|
|
str := func(s string) *string { return &s }
|
|
tests := []struct {
|
|
name string
|
|
options *FTSearchOptions
|
|
resp2 string
|
|
resp3 string
|
|
want []Document
|
|
}{
|
|
{
|
|
name: "scores",
|
|
options: &FTSearchOptions{Return: []FTSearchReturn{}, WithScores: true},
|
|
resp2: "*5\r\n:2\r\n$5\r\ndoc:2\r\n$3\r\n0.5\r\n$5\r\ndoc:1\r\n$4\r\n0.25\r\n",
|
|
resp3: "%2\r\n+total_results\r\n:2\r\n+results\r\n*2\r\n" +
|
|
"%3\r\n+id\r\n$5\r\ndoc:2\r\n+score\r\n,0.5\r\n+values\r\n*0\r\n" +
|
|
"%3\r\n+id\r\n$5\r\ndoc:1\r\n+score\r\n,0.25\r\n+values\r\n*0\r\n",
|
|
want: []Document{
|
|
{ID: "doc:2", Score: ptrFloat64(0.5)},
|
|
{ID: "doc:1", Score: ptrFloat64(0.25)},
|
|
},
|
|
},
|
|
{
|
|
name: "scores with explanation",
|
|
options: &FTSearchOptions{Return: []FTSearchReturn{}, WithScores: true, ExplainScore: true},
|
|
resp2: "*3\r\n:1\r\n$5\r\ndoc:2\r\n*2\r\n$3\r\n0.5\r\n*1\r\n$4\r\nbm25\r\n",
|
|
resp3: "%2\r\n+total_results\r\n:1\r\n+results\r\n*1\r\n" +
|
|
"%3\r\n+id\r\n$5\r\ndoc:2\r\n+score\r\n*2\r\n,0.5\r\n*1\r\n+bm25\r\n+values\r\n*0\r\n",
|
|
want: []Document{{ID: "doc:2", Score: ptrFloat64(0.5)}},
|
|
},
|
|
{
|
|
name: "scores, payloads and sort keys",
|
|
options: &FTSearchOptions{Return: []FTSearchReturn{}, WithScores: true, WithPayloads: true, WithSortKeys: true},
|
|
resp2: "*9\r\n:2\r\n$5\r\ndoc:1\r\n$4\r\n0.25\r\n$2\r\np1\r\n$2\r\n#1\r\n" +
|
|
"$5\r\ndoc:2\r\n$3\r\n0.5\r\n$-1\r\n$-1\r\n",
|
|
resp3: "%2\r\n+total_results\r\n:2\r\n+results\r\n*2\r\n" +
|
|
"%5\r\n+id\r\n$5\r\ndoc:1\r\n+score\r\n,0.25\r\n+payload\r\n$2\r\np1\r\n+sortkey\r\n$2\r\n#1\r\n+values\r\n*0\r\n" +
|
|
"%5\r\n+id\r\n$5\r\ndoc:2\r\n+score\r\n,0.5\r\n+payload\r\n_\r\n+sortkey\r\n_\r\n+values\r\n*0\r\n",
|
|
want: []Document{
|
|
{ID: "doc:1", Score: ptrFloat64(0.25), Payload: str("p1"), SortKey: str("#1")},
|
|
{ID: "doc:2", Score: ptrFloat64(0.5)},
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
for protocol, reply := range map[string]string{"RESP2": tt.resp2, "RESP3": tt.resp3} {
|
|
t.Run(tt.name+"/"+protocol, func(t *testing.T) {
|
|
cmd := newFTSearchCmd(context.Background(), tt.options)
|
|
if err := cmd.readReply(proto.NewReader(strings.NewReader(reply))); err != nil {
|
|
t.Fatalf("readReply() error = %v", err)
|
|
}
|
|
if cmd.val.Total != len(tt.want) {
|
|
t.Errorf("Total = %d, want %d", cmd.val.Total, len(tt.want))
|
|
}
|
|
if len(cmd.val.Docs) != len(tt.want) {
|
|
t.Fatalf("Docs count = %d, want %d", len(cmd.val.Docs), len(tt.want))
|
|
}
|
|
for i, doc := range cmd.val.Docs {
|
|
want := tt.want[i]
|
|
if doc.ID != want.ID {
|
|
t.Errorf("Docs[%d].ID = %s, want %s", i, doc.ID, want.ID)
|
|
}
|
|
if doc.Score == nil || *doc.Score != *want.Score {
|
|
t.Errorf("Docs[%d].Score = %v, want %v", i, doc.Score, *want.Score)
|
|
}
|
|
if (doc.Payload == nil) != (want.Payload == nil) || (doc.Payload != nil && *doc.Payload != *want.Payload) {
|
|
t.Errorf("Docs[%d].Payload = %v, want %v", i, doc.Payload, want.Payload)
|
|
}
|
|
if (doc.SortKey == nil) != (want.SortKey == nil) || (doc.SortKey != nil && *doc.SortKey != *want.SortKey) {
|
|
t.Errorf("Docs[%d].SortKey = %v, want %v", i, doc.SortKey, want.SortKey)
|
|
}
|
|
if len(doc.Fields) != 0 {
|
|
t.Errorf("Docs[%d].Fields = %v, want empty", i, doc.Fields)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestFTAggregateRESP2AndRESP3Equivalence validates that RESP2 and RESP3 parsing
|
|
// produce semantically equivalent results for FT.AGGREGATE
|
|
func TestFTAggregateRESP2AndRESP3Equivalence(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
resp2Data []interface{}
|
|
resp3Data map[string]interface{}
|
|
}{
|
|
{
|
|
name: "aggregation with groupby",
|
|
resp2Data: []interface{}{
|
|
int64(3),
|
|
[]interface{}{"condition", "refurbished", "num_affordable", "158"},
|
|
[]interface{}{"condition", "used", "num_affordable", "156"},
|
|
[]interface{}{"condition", "new", "num_affordable", "158"},
|
|
},
|
|
resp3Data: map[string]interface{}{
|
|
"attributes": []interface{}{},
|
|
"total_results": int64(3),
|
|
"format": "STRING",
|
|
"results": []interface{}{
|
|
map[string]interface{}{
|
|
"extra_attributes": map[string]interface{}{
|
|
"condition": "refurbished",
|
|
"num_affordable": "158",
|
|
},
|
|
"values": []interface{}{},
|
|
},
|
|
map[string]interface{}{
|
|
"extra_attributes": map[string]interface{}{
|
|
"condition": "used",
|
|
"num_affordable": "156",
|
|
},
|
|
"values": []interface{}{},
|
|
},
|
|
map[string]interface{}{
|
|
"extra_attributes": map[string]interface{}{
|
|
"condition": "new",
|
|
"num_affordable": "158",
|
|
},
|
|
"values": []interface{}{},
|
|
},
|
|
},
|
|
"warning": []interface{}{},
|
|
},
|
|
},
|
|
{
|
|
name: "aggregation with apply",
|
|
resp2Data: []interface{}{
|
|
int64(1),
|
|
[]interface{}{"__key", "bicycle:0", "price", "270", "discounted", "243"},
|
|
},
|
|
resp3Data: map[string]interface{}{
|
|
"attributes": []interface{}{},
|
|
"total_results": int64(1),
|
|
"format": "STRING",
|
|
"results": []interface{}{
|
|
map[string]interface{}{
|
|
"extra_attributes": map[string]interface{}{
|
|
"__key": "bicycle:0",
|
|
"price": "270",
|
|
"discounted": "243",
|
|
},
|
|
"values": []interface{}{},
|
|
},
|
|
},
|
|
"warning": []interface{}{},
|
|
},
|
|
},
|
|
{
|
|
name: "empty result",
|
|
resp2Data: []interface{}{
|
|
int64(0),
|
|
},
|
|
resp3Data: map[string]interface{}{
|
|
"attributes": []interface{}{},
|
|
"total_results": int64(0),
|
|
"format": "STRING",
|
|
"results": []interface{}{},
|
|
"warning": []interface{}{},
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
// Parse RESP2
|
|
resp2Result, err := ProcessAggregateResult(tt.resp2Data)
|
|
if err != nil {
|
|
t.Fatalf("ProcessAggregateResult() error = %v", err)
|
|
}
|
|
|
|
// Parse RESP3
|
|
resp3Result, err := parseFTAggregateMapRESP3(toInterfaceMap(tt.resp3Data))
|
|
if err != nil {
|
|
t.Fatalf("parseFTAggregateMapRESP3() error = %v", err)
|
|
}
|
|
|
|
// Compare Total
|
|
if resp2Result.Total != resp3Result.Total {
|
|
t.Errorf("Total mismatch: RESP2=%d, RESP3=%d", resp2Result.Total, resp3Result.Total)
|
|
}
|
|
|
|
// Compare number of rows
|
|
if len(resp2Result.Rows) != len(resp3Result.Rows) {
|
|
t.Errorf("Rows count mismatch: RESP2=%d, RESP3=%d", len(resp2Result.Rows), len(resp3Result.Rows))
|
|
return
|
|
}
|
|
|
|
// Compare each row
|
|
for i, resp2Row := range resp2Result.Rows {
|
|
resp3Row := resp3Result.Rows[i]
|
|
|
|
// Compare fields count
|
|
if len(resp2Row.Fields) != len(resp3Row.Fields) {
|
|
t.Errorf("Rows[%d].Fields count mismatch: RESP2=%d, RESP3=%d", i, len(resp2Row.Fields), len(resp3Row.Fields))
|
|
}
|
|
|
|
// Compare field values
|
|
for k, v := range resp2Row.Fields {
|
|
resp3Val, ok := resp3Row.Fields[k]
|
|
if !ok {
|
|
t.Errorf("Rows[%d].Fields[%s] missing in RESP3", i, k)
|
|
continue
|
|
}
|
|
if v != resp3Val {
|
|
t.Errorf("Rows[%d].Fields[%s] mismatch: RESP2=%v, RESP3=%v", i, k, v, resp3Val)
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestParseFTSpellCheckRESP3 tests the RESP3 parsing for FT.SPELLCHECK results
|
|
func TestParseFTSpellCheckRESP3(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input map[interface{}]interface{}
|
|
expected []SpellCheckResult
|
|
}{
|
|
{
|
|
name: "empty result",
|
|
input: map[interface{}]interface{}{
|
|
"results": map[interface{}]interface{}{},
|
|
},
|
|
expected: []SpellCheckResult{},
|
|
},
|
|
{
|
|
name: "single misspelled term with one suggestion",
|
|
input: map[interface{}]interface{}{
|
|
"results": map[interface{}]interface{}{
|
|
"impornant": []interface{}{
|
|
map[interface{}]interface{}{"important": float64(0.5)},
|
|
},
|
|
},
|
|
},
|
|
expected: []SpellCheckResult{
|
|
{
|
|
Term: "impornant",
|
|
Suggestions: []SpellCheckSuggestion{
|
|
{Score: 0.5, Suggestion: "important"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "single misspelled term with multiple suggestions",
|
|
input: map[interface{}]interface{}{
|
|
"results": map[interface{}]interface{}{
|
|
"helo": []interface{}{
|
|
map[interface{}]interface{}{"hello": float64(0.8)},
|
|
map[interface{}]interface{}{"help": float64(0.3)},
|
|
},
|
|
},
|
|
},
|
|
expected: []SpellCheckResult{
|
|
{
|
|
Term: "helo",
|
|
Suggestions: []SpellCheckSuggestion{
|
|
{Score: 0.8, Suggestion: "hello"},
|
|
{Score: 0.3, Suggestion: "help"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "no results key",
|
|
input: map[interface{}]interface{}{},
|
|
expected: []SpellCheckResult{},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result, err := parseFTSpellCheckRESP3(tt.input)
|
|
if err != nil {
|
|
t.Fatalf("parseFTSpellCheckRESP3 returned error: %v", err)
|
|
}
|
|
|
|
if len(result) != len(tt.expected) {
|
|
t.Fatalf("expected %d results, got %d", len(tt.expected), len(result))
|
|
}
|
|
|
|
// For single result tests, verify the content
|
|
if len(tt.expected) == 1 && len(result) == 1 {
|
|
if result[0].Term != tt.expected[0].Term {
|
|
t.Errorf("expected term %q, got %q", tt.expected[0].Term, result[0].Term)
|
|
}
|
|
if len(result[0].Suggestions) != len(tt.expected[0].Suggestions) {
|
|
t.Errorf("expected %d suggestions, got %d", len(tt.expected[0].Suggestions), len(result[0].Suggestions))
|
|
}
|
|
for i, sugg := range tt.expected[0].Suggestions {
|
|
if i < len(result[0].Suggestions) {
|
|
if result[0].Suggestions[i].Suggestion != sugg.Suggestion {
|
|
t.Errorf("suggestion[%d]: expected %q, got %q", i, sugg.Suggestion, result[0].Suggestions[i].Suggestion)
|
|
}
|
|
if result[0].Suggestions[i].Score != sugg.Score {
|
|
t.Errorf("suggestion[%d] score: expected %f, got %f", i, sugg.Score, result[0].Suggestions[i].Score)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestParseFTSynDumpRESP3 tests the RESP3 parsing for FT.SYNDUMP results
|
|
func TestParseFTSynDumpRESP3(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input map[interface{}]interface{}
|
|
expected []FTSynDumpResult
|
|
}{
|
|
{
|
|
name: "empty result",
|
|
input: map[interface{}]interface{}{},
|
|
expected: []FTSynDumpResult{},
|
|
},
|
|
{
|
|
name: "single term with single synonym group",
|
|
input: map[interface{}]interface{}{
|
|
"baby": []interface{}{"id1"},
|
|
},
|
|
expected: []FTSynDumpResult{
|
|
{Term: "baby", Synonyms: []string{"id1"}},
|
|
},
|
|
},
|
|
{
|
|
name: "single term with multiple synonym groups",
|
|
input: map[interface{}]interface{}{
|
|
"child": []interface{}{"id1", "id2"},
|
|
},
|
|
expected: []FTSynDumpResult{
|
|
{Term: "child", Synonyms: []string{"id1", "id2"}},
|
|
},
|
|
},
|
|
{
|
|
name: "multiple terms",
|
|
input: map[interface{}]interface{}{
|
|
"baby": []interface{}{"id1"},
|
|
"child": []interface{}{"id1"},
|
|
"offspring": []interface{}{"id1"},
|
|
},
|
|
expected: []FTSynDumpResult{
|
|
{Term: "baby", Synonyms: []string{"id1"}},
|
|
{Term: "child", Synonyms: []string{"id1"}},
|
|
{Term: "offspring", Synonyms: []string{"id1"}},
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result, err := parseFTSynDumpRESP3(tt.input)
|
|
if err != nil {
|
|
t.Fatalf("parseFTSynDumpRESP3 returned error: %v", err)
|
|
}
|
|
|
|
if len(result) != len(tt.expected) {
|
|
t.Fatalf("expected %d results, got %d", len(tt.expected), len(result))
|
|
}
|
|
|
|
// Create a map for easier lookup since map iteration order is not guaranteed
|
|
resultMap := make(map[string][]string)
|
|
for _, r := range result {
|
|
resultMap[r.Term] = r.Synonyms
|
|
}
|
|
|
|
for _, exp := range tt.expected {
|
|
synonyms, ok := resultMap[exp.Term]
|
|
if !ok {
|
|
t.Errorf("expected term %q not found in result", exp.Term)
|
|
continue
|
|
}
|
|
if len(synonyms) != len(exp.Synonyms) {
|
|
t.Errorf("term %q: expected %d synonyms, got %d", exp.Term, len(exp.Synonyms), len(synonyms))
|
|
continue
|
|
}
|
|
for i, syn := range exp.Synonyms {
|
|
if synonyms[i] != syn {
|
|
t.Errorf("term %q synonym[%d]: expected %q, got %q", exp.Term, i, syn, synonyms[i])
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestFTInfoRESP3Parsing tests that FTInfoCmd correctly parses RESP3 format
|
|
func TestFTInfoRESP3Parsing(t *testing.T) {
|
|
// Test data simulating RESP3 response (map[interface{}]interface{})
|
|
input := map[interface{}]interface{}{
|
|
"index_name": "test_idx",
|
|
"index_options": []interface{}{},
|
|
"index_definition": map[interface{}]interface{}{
|
|
"key_type": "HASH",
|
|
"prefixes": []interface{}{"doc:"},
|
|
"default_score": float64(1),
|
|
},
|
|
"attributes": []interface{}{
|
|
map[interface{}]interface{}{
|
|
"identifier": "txt",
|
|
"attribute": "txt",
|
|
"type": "TEXT",
|
|
"WEIGHT": float64(1),
|
|
"flags": []interface{}{"SORTABLE"},
|
|
},
|
|
},
|
|
"num_docs": int64(100),
|
|
"max_doc_id": int64(100),
|
|
"num_terms": int64(500),
|
|
"num_records": int64(1000),
|
|
"inverted_sz_mb": float64(0.5),
|
|
"total_inverted_index_blocks": int64(50),
|
|
"offset_vectors_sz_mb": float64(0.1),
|
|
"doc_table_size_mb": float64(0.2),
|
|
"sortable_values_size_mb": float64(0.05),
|
|
"key_table_size_mb": float64(0.01),
|
|
"records_per_doc_avg": float64(10),
|
|
"bytes_per_record_avg": float64(50),
|
|
"offsets_per_term_avg": float64(2),
|
|
"offset_bits_per_record_avg": float64(8),
|
|
"hash_indexing_failures": int64(0),
|
|
"total_indexing_time": float64(123),
|
|
"indexing": int64(0),
|
|
"percent_indexed": float64(1),
|
|
"number_of_uses": int64(10),
|
|
"cleaning": int64(0),
|
|
// RESP3 emits gc_stats as a map of doubles (REPLY_KVNUM in RediSearch).
|
|
"gc_stats": map[interface{}]interface{}{
|
|
"bytes_collected": float64(1024),
|
|
"total_ms_run": float64(50),
|
|
"total_cycles": float64(3),
|
|
"average_cycle_time_ms": float64(16.5),
|
|
"last_run_time_ms": float64(20),
|
|
"gc_numeric_trees_missed": float64(0),
|
|
"gc_blocks_denied": float64(2),
|
|
},
|
|
}
|
|
|
|
// Convert to map[string]interface{} as parseFTInfo expects
|
|
data := make(map[string]interface{}, len(input))
|
|
for k, v := range input {
|
|
if kStr, ok := k.(string); ok {
|
|
data[kStr] = v
|
|
}
|
|
}
|
|
|
|
result, err := parseFTInfo(data)
|
|
if err != nil {
|
|
t.Fatalf("parseFTInfo returned error: %v", err)
|
|
}
|
|
|
|
if result.IndexName != "test_idx" {
|
|
t.Errorf("expected IndexName 'test_idx', got %q", result.IndexName)
|
|
}
|
|
|
|
if result.NumDocs != 100 {
|
|
t.Errorf("expected NumDocs 100, got %d", result.NumDocs)
|
|
}
|
|
|
|
if len(result.Attributes) != 1 {
|
|
t.Fatalf("expected 1 attribute, got %d", len(result.Attributes))
|
|
}
|
|
|
|
if result.Attributes[0].Attribute != "txt" {
|
|
t.Errorf("expected attribute 'txt', got %q", result.Attributes[0].Attribute)
|
|
}
|
|
|
|
// RediSearch emits the *_avg fields via REPLY_KVNUM (doubles in RESP3).
|
|
// The Go struct types them as string, so they must be stringified during parse.
|
|
if result.RecordsPerDocAvg != "10" {
|
|
t.Errorf("RecordsPerDocAvg = %q, want %q", result.RecordsPerDocAvg, "10")
|
|
}
|
|
if result.BytesPerRecordAvg != "50" {
|
|
t.Errorf("BytesPerRecordAvg = %q, want %q", result.BytesPerRecordAvg, "50")
|
|
}
|
|
if result.OffsetsPerTermAvg != "2" {
|
|
t.Errorf("OffsetsPerTermAvg = %q, want %q", result.OffsetsPerTermAvg, "2")
|
|
}
|
|
if result.OffsetBitsPerRecordAvg != "8" {
|
|
t.Errorf("OffsetBitsPerRecordAvg = %q, want %q", result.OffsetBitsPerRecordAvg, "8")
|
|
}
|
|
|
|
// total_indexing_time is also a REPLY_KVNUM (double) but typed as int.
|
|
if result.TotalIndexingTime != 123 {
|
|
t.Errorf("TotalIndexingTime = %d, want 123", result.TotalIndexingTime)
|
|
}
|
|
|
|
// gc_stats numeric fields are typed as int but emitted as doubles in RESP3.
|
|
if result.GCStats.BytesCollected != 1024 {
|
|
t.Errorf("GCStats.BytesCollected = %d, want 1024", result.GCStats.BytesCollected)
|
|
}
|
|
if result.GCStats.TotalMsRun != 50 {
|
|
t.Errorf("GCStats.TotalMsRun = %d, want 50", result.GCStats.TotalMsRun)
|
|
}
|
|
if result.GCStats.TotalCycles != 3 {
|
|
t.Errorf("GCStats.TotalCycles = %d, want 3", result.GCStats.TotalCycles)
|
|
}
|
|
if result.GCStats.LastRunTimeMs != 20 {
|
|
t.Errorf("GCStats.LastRunTimeMs = %d, want 20", result.GCStats.LastRunTimeMs)
|
|
}
|
|
if result.GCStats.GCBlocksDenied != 2 {
|
|
t.Errorf("GCStats.GCBlocksDenied = %d, want 2", result.GCStats.GCBlocksDenied)
|
|
}
|
|
if result.GCStats.AverageCycleTimeMs != "16.5" {
|
|
t.Errorf("GCStats.AverageCycleTimeMs = %q, want %q", result.GCStats.AverageCycleTimeMs, "16.5")
|
|
}
|
|
}
|
|
|
|
// TestFTInfoRESP3NaN verifies that RediSearch's NaN doubles (emitted for empty
|
|
// indexes) survive parsing as the lowercase "nan" sentinel, matching RESP2.
|
|
func TestFTInfoRESP3NaN(t *testing.T) {
|
|
data := map[string]interface{}{
|
|
"index_name": "empty_idx",
|
|
"records_per_doc_avg": math.NaN(),
|
|
"bytes_per_record_avg": math.NaN(),
|
|
"offsets_per_term_avg": math.NaN(),
|
|
"offset_bits_per_record_avg": math.NaN(),
|
|
}
|
|
|
|
result, err := parseFTInfo(data)
|
|
if err != nil {
|
|
t.Fatalf("parseFTInfo returned error: %v", err)
|
|
}
|
|
|
|
for _, tc := range []struct {
|
|
name, got string
|
|
}{
|
|
{"RecordsPerDocAvg", result.RecordsPerDocAvg},
|
|
{"BytesPerRecordAvg", result.BytesPerRecordAvg},
|
|
{"OffsetsPerTermAvg", result.OffsetsPerTermAvg},
|
|
{"OffsetBitsPerRecordAvg", result.OffsetBitsPerRecordAvg},
|
|
} {
|
|
if tc.got != "nan" {
|
|
t.Errorf("%s = %q, want %q", tc.name, tc.got, "nan")
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestFTSpellCheckRESP2AndRESP3Equivalence validates that RESP2 and RESP3 parsing
|
|
// produce semantically equivalent results for FT.SPELLCHECK
|
|
func TestFTSpellCheckRESP2AndRESP3Equivalence(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
resp2Data []interface{}
|
|
resp3Data map[interface{}]interface{}
|
|
}{
|
|
{
|
|
name: "single misspelled term with suggestions",
|
|
// RESP2: [["TERM", "impornant", [["0.5", "important"]]]]
|
|
resp2Data: []interface{}{
|
|
[]interface{}{
|
|
"TERM",
|
|
"impornant",
|
|
[]interface{}{
|
|
[]interface{}{"0.5", "important"},
|
|
},
|
|
},
|
|
},
|
|
// RESP3: {"results": {"impornant": [{"important": 0.5}]}}
|
|
resp3Data: map[interface{}]interface{}{
|
|
"results": map[interface{}]interface{}{
|
|
"impornant": []interface{}{
|
|
map[interface{}]interface{}{"important": float64(0.5)},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "multiple suggestions",
|
|
// RESP2: [["TERM", "helo", [["0.8", "hello"], ["0.3", "help"]]]]
|
|
resp2Data: []interface{}{
|
|
[]interface{}{
|
|
"TERM",
|
|
"helo",
|
|
[]interface{}{
|
|
[]interface{}{"0.8", "hello"},
|
|
[]interface{}{"0.3", "help"},
|
|
},
|
|
},
|
|
},
|
|
// RESP3: {"results": {"helo": [{"hello": 0.8}, {"help": 0.3}]}}
|
|
resp3Data: map[interface{}]interface{}{
|
|
"results": map[interface{}]interface{}{
|
|
"helo": []interface{}{
|
|
map[interface{}]interface{}{"hello": float64(0.8)},
|
|
map[interface{}]interface{}{"help": float64(0.3)},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "empty result",
|
|
resp2Data: []interface{}{},
|
|
resp3Data: map[interface{}]interface{}{
|
|
"results": map[interface{}]interface{}{},
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
// Parse RESP2
|
|
resp2Result, err := parseFTSpellCheck(tt.resp2Data)
|
|
if err != nil {
|
|
t.Fatalf("parseFTSpellCheck (RESP2) returned error: %v", err)
|
|
}
|
|
|
|
// Parse RESP3
|
|
resp3Result, err := parseFTSpellCheckRESP3(tt.resp3Data)
|
|
if err != nil {
|
|
t.Fatalf("parseFTSpellCheckRESP3 returned error: %v", err)
|
|
}
|
|
|
|
// Compare results
|
|
if len(resp2Result) != len(resp3Result) {
|
|
t.Fatalf("result count mismatch: RESP2=%d, RESP3=%d", len(resp2Result), len(resp3Result))
|
|
}
|
|
|
|
// For non-empty results, compare content
|
|
if len(resp2Result) > 0 {
|
|
// Create maps for comparison since order may differ
|
|
resp2Map := make(map[string][]SpellCheckSuggestion)
|
|
for _, r := range resp2Result {
|
|
resp2Map[r.Term] = r.Suggestions
|
|
}
|
|
resp3Map := make(map[string][]SpellCheckSuggestion)
|
|
for _, r := range resp3Result {
|
|
resp3Map[r.Term] = r.Suggestions
|
|
}
|
|
|
|
for term, resp2Suggs := range resp2Map {
|
|
resp3Suggs, ok := resp3Map[term]
|
|
if !ok {
|
|
t.Errorf("term %q found in RESP2 but not in RESP3", term)
|
|
continue
|
|
}
|
|
if len(resp2Suggs) != len(resp3Suggs) {
|
|
t.Errorf("term %q: suggestion count mismatch: RESP2=%d, RESP3=%d", term, len(resp2Suggs), len(resp3Suggs))
|
|
continue
|
|
}
|
|
// Compare suggestions (order may differ)
|
|
resp2SuggMap := make(map[string]float64)
|
|
for _, s := range resp2Suggs {
|
|
resp2SuggMap[s.Suggestion] = s.Score
|
|
}
|
|
for _, s := range resp3Suggs {
|
|
if score, ok := resp2SuggMap[s.Suggestion]; !ok {
|
|
t.Errorf("term %q: suggestion %q found in RESP3 but not in RESP2", term, s.Suggestion)
|
|
} else if score != s.Score {
|
|
t.Errorf("term %q suggestion %q: score mismatch: RESP2=%f, RESP3=%f", term, s.Suggestion, score, s.Score)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestFTSynDumpRESP2AndRESP3Equivalence validates that RESP2 and RESP3 parsing
|
|
// produce semantically equivalent results for FT.SYNDUMP
|
|
func TestFTSynDumpRESP2AndRESP3Equivalence(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
resp2Data []interface{}
|
|
resp3Data map[interface{}]interface{}
|
|
}{
|
|
{
|
|
name: "single term with single synonym group",
|
|
// RESP2: ["baby", ["id1"]]
|
|
resp2Data: []interface{}{
|
|
"baby", []interface{}{"id1"},
|
|
},
|
|
// RESP3: {"baby": ["id1"]}
|
|
resp3Data: map[interface{}]interface{}{
|
|
"baby": []interface{}{"id1"},
|
|
},
|
|
},
|
|
{
|
|
name: "multiple terms",
|
|
// RESP2: ["baby", ["id1"], "child", ["id1"], "offspring", ["id1"]]
|
|
resp2Data: []interface{}{
|
|
"baby", []interface{}{"id1"},
|
|
"child", []interface{}{"id1"},
|
|
"offspring", []interface{}{"id1"},
|
|
},
|
|
// RESP3: {"baby": ["id1"], "child": ["id1"], "offspring": ["id1"]}
|
|
resp3Data: map[interface{}]interface{}{
|
|
"baby": []interface{}{"id1"},
|
|
"child": []interface{}{"id1"},
|
|
"offspring": []interface{}{"id1"},
|
|
},
|
|
},
|
|
{
|
|
name: "empty result",
|
|
resp2Data: []interface{}{},
|
|
resp3Data: map[interface{}]interface{}{},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
// Parse RESP2 manually (simulating what readReply does)
|
|
var resp2Result []FTSynDumpResult
|
|
for i := 0; i < len(tt.resp2Data); i += 2 {
|
|
term, ok := tt.resp2Data[i].(string)
|
|
if !ok {
|
|
t.Fatalf("invalid RESP2 term format")
|
|
}
|
|
synonyms, ok := tt.resp2Data[i+1].([]interface{})
|
|
if !ok {
|
|
t.Fatalf("invalid RESP2 synonyms format")
|
|
}
|
|
synonymList := make([]string, len(synonyms))
|
|
for j, syn := range synonyms {
|
|
synonymList[j] = syn.(string)
|
|
}
|
|
resp2Result = append(resp2Result, FTSynDumpResult{
|
|
Term: term,
|
|
Synonyms: synonymList,
|
|
})
|
|
}
|
|
|
|
// Parse RESP3
|
|
resp3Result, err := parseFTSynDumpRESP3(tt.resp3Data)
|
|
if err != nil {
|
|
t.Fatalf("parseFTSynDumpRESP3 returned error: %v", err)
|
|
}
|
|
|
|
// Compare results
|
|
if len(resp2Result) != len(resp3Result) {
|
|
t.Fatalf("result count mismatch: RESP2=%d, RESP3=%d", len(resp2Result), len(resp3Result))
|
|
}
|
|
|
|
// Create maps for comparison since order may differ
|
|
resp2Map := make(map[string][]string)
|
|
for _, r := range resp2Result {
|
|
resp2Map[r.Term] = r.Synonyms
|
|
}
|
|
resp3Map := make(map[string][]string)
|
|
for _, r := range resp3Result {
|
|
resp3Map[r.Term] = r.Synonyms
|
|
}
|
|
|
|
for term, resp2Syns := range resp2Map {
|
|
resp3Syns, ok := resp3Map[term]
|
|
if !ok {
|
|
t.Errorf("term %q found in RESP2 but not in RESP3", term)
|
|
continue
|
|
}
|
|
if len(resp2Syns) != len(resp3Syns) {
|
|
t.Errorf("term %q: synonym count mismatch: RESP2=%d, RESP3=%d", term, len(resp2Syns), len(resp3Syns))
|
|
continue
|
|
}
|
|
for i, syn := range resp2Syns {
|
|
if resp3Syns[i] != syn {
|
|
t.Errorf("term %q synonym[%d]: RESP2=%q, RESP3=%q", term, i, syn, resp3Syns[i])
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestFTInfoRESP2AndRESP3Equivalence validates that RESP2 and RESP3 parsing
|
|
// produce semantically equivalent results for FT.INFO
|
|
func TestFTInfoRESP2AndRESP3Equivalence(t *testing.T) {
|
|
// RESP2 format: attributes are arrays like ["identifier", "txt", "attribute", "txt", "type", "TEXT", ...]
|
|
resp2Data := map[string]interface{}{
|
|
"index_name": "test_idx",
|
|
"index_options": []interface{}{},
|
|
"attributes": []interface{}{
|
|
[]interface{}{
|
|
"identifier", "txt",
|
|
"attribute", "txt",
|
|
"type", "TEXT",
|
|
"WEIGHT", float64(1),
|
|
"SORTABLE",
|
|
"NOSTEM",
|
|
},
|
|
},
|
|
"num_docs": int64(100),
|
|
"num_terms": int64(500),
|
|
"num_records": int64(1000),
|
|
}
|
|
|
|
// RESP3 format: attributes are maps
|
|
resp3Data := map[string]interface{}{
|
|
"index_name": "test_idx",
|
|
"index_options": []interface{}{},
|
|
"attributes": []interface{}{
|
|
map[interface{}]interface{}{
|
|
"identifier": "txt",
|
|
"attribute": "txt",
|
|
"type": "TEXT",
|
|
"WEIGHT": float64(1),
|
|
"flags": []interface{}{"SORTABLE", "NOSTEM"},
|
|
},
|
|
},
|
|
"num_docs": int64(100),
|
|
"num_terms": int64(500),
|
|
"num_records": int64(1000),
|
|
}
|
|
|
|
// Parse RESP2
|
|
resp2Result, err := parseFTInfo(resp2Data)
|
|
if err != nil {
|
|
t.Fatalf("parseFTInfo (RESP2) returned error: %v", err)
|
|
}
|
|
|
|
// Parse RESP3
|
|
resp3Result, err := parseFTInfo(resp3Data)
|
|
if err != nil {
|
|
t.Fatalf("parseFTInfo (RESP3) returned error: %v", err)
|
|
}
|
|
|
|
// Compare results
|
|
if resp2Result.IndexName != resp3Result.IndexName {
|
|
t.Errorf("IndexName mismatch: RESP2=%q, RESP3=%q", resp2Result.IndexName, resp3Result.IndexName)
|
|
}
|
|
|
|
if resp2Result.NumDocs != resp3Result.NumDocs {
|
|
t.Errorf("NumDocs mismatch: RESP2=%d, RESP3=%d", resp2Result.NumDocs, resp3Result.NumDocs)
|
|
}
|
|
|
|
if resp2Result.NumTerms != resp3Result.NumTerms {
|
|
t.Errorf("NumTerms mismatch: RESP2=%d, RESP3=%d", resp2Result.NumTerms, resp3Result.NumTerms)
|
|
}
|
|
|
|
if len(resp2Result.Attributes) != len(resp3Result.Attributes) {
|
|
t.Fatalf("Attributes count mismatch: RESP2=%d, RESP3=%d", len(resp2Result.Attributes), len(resp3Result.Attributes))
|
|
}
|
|
|
|
if len(resp2Result.Attributes) > 0 {
|
|
attr2 := resp2Result.Attributes[0]
|
|
attr3 := resp3Result.Attributes[0]
|
|
|
|
if attr2.Attribute != attr3.Attribute {
|
|
t.Errorf("Attribute name mismatch: RESP2=%q, RESP3=%q", attr2.Attribute, attr3.Attribute)
|
|
}
|
|
if attr2.Identifier != attr3.Identifier {
|
|
t.Errorf("Identifier mismatch: RESP2=%q, RESP3=%q", attr2.Identifier, attr3.Identifier)
|
|
}
|
|
if attr2.Type != attr3.Type {
|
|
t.Errorf("Type mismatch: RESP2=%q, RESP3=%q", attr2.Type, attr3.Type)
|
|
}
|
|
if attr2.Sortable != attr3.Sortable {
|
|
t.Errorf("Sortable mismatch: RESP2=%v, RESP3=%v", attr2.Sortable, attr3.Sortable)
|
|
}
|
|
if attr2.NoStem != attr3.NoStem {
|
|
t.Errorf("NoStem mismatch: RESP2=%v, RESP3=%v", attr2.NoStem, attr3.NoStem)
|
|
}
|
|
}
|
|
}
|