Files
go-redis/search_commands_unit_test.go
Raphael Fakhri 7852b431b5 fix(search): emit SORTABLE after field options (#4199)
FT.CREATE stops reading type-specific field options at SORTABLE, UNF
and NOINDEX. A field with Sortable and Weight, Separator,
CaseSensitive, PhoneticMatcher, WithSuffixtrie, IndexEmpty or
IndexMissing was rejected by the server. Emit SORTABLE, UNF and NOINDEX
after the other options.
2026-10-26 11:15:36 +02:00

771 lines
23 KiB
Go

package redis
import (
"context"
"fmt"
"reflect"
"strings"
"testing"
)
// indexOfArg returns the index of the first argument equal to want, or -1.
func indexOfArg(args []interface{}, want interface{}) int {
for i, arg := range args {
if reflect.DeepEqual(arg, want) {
return i
}
}
return -1
}
type legacyHybridVector struct {
value []any
}
func (v legacyHybridVector) Value() []any {
return v.value
}
func TestHybridVectorBlob(t *testing.T) {
tests := []struct {
name string
vector Vector
want interface{}
wantErr string
}{
{
name: "fp32",
vector: &VectorFP32{Val: []byte{1, 2, 3, 4}},
want: []byte{1, 2, 3, 4},
},
{
name: "float16",
vector: &VectorFloat16{Val: []byte{1, 2, 3, 4}},
want: []byte{1, 2, 3, 4},
},
{
name: "bfloat16",
vector: &VectorBFloat16{Val: []byte{1, 2, 3, 4}},
want: []byte{1, 2, 3, 4},
},
{
name: "float64",
vector: &VectorFloat64{Val: []byte{1, 2, 3, 4}},
want: []byte{1, 2, 3, 4},
},
{
name: "int8",
vector: &VectorInt8{Val: []byte{1, 2, 3, 4}},
want: []byte{1, 2, 3, 4},
},
{
name: "uint8",
vector: &VectorUint8{Val: []byte{1, 2, 3, 4}},
want: []byte{1, 2, 3, 4},
},
{
name: "custom legacy vector",
vector: legacyHybridVector{value: []any{"CUSTOM", []byte{1, 2, 3, 4}}},
want: []byte{1, 2, 3, 4},
},
{
name: "nil",
vector: nil,
wantErr: "vector data is required",
},
{
name: "empty fp32",
vector: &VectorFP32{},
wantErr: "vector blob is required",
},
{
name: "values",
vector: &VectorValues{Val: []float64{1, 2}},
wantErr: "unsupported vector type *redis.VectorValues",
},
{
name: "ref",
vector: &VectorRef{Name: "vec1"},
wantErr: "unsupported vector type *redis.VectorRef",
},
{
name: "invalid custom legacy vector",
vector: legacyHybridVector{value: []any{"CUSTOM"}},
wantErr: "vector Value must contain a blob at index 1",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := hybridVectorBlob(tt.vector)
if tt.wantErr != "" {
if err == nil {
t.Fatalf("expected error %q, got nil", tt.wantErr)
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("expected error containing %q, got %q", tt.wantErr, err.Error())
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !reflect.DeepEqual(got, tt.want) {
t.Fatalf("got %v, want %v", got, tt.want)
}
})
}
}
func TestFTHybridWithArgsRejectsUnsupportedVectors(t *testing.T) {
tests := []struct {
name string
vector Vector
wantErr string
}{
{
name: "values",
vector: &VectorValues{Val: []float64{1, 2}},
wantErr: "unsupported vector type *redis.VectorValues",
},
{
name: "ref",
vector: &VectorRef{Name: "vec1"},
wantErr: "unsupported vector type *redis.VectorRef",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
m := &mockCmdable{}
c := m.asCmdable()
cmd := c.FTHybridWithArgs(context.Background(), "idx", &FTHybridOptions{
SearchExpressions: []FTHybridSearchExpression{{Query: "*"}},
VectorExpressions: []FTHybridVectorExpression{{
VectorField: "embedding",
VectorData: tt.vector,
}},
})
if cmd.Err() == nil {
t.Fatalf("expected error containing %q, got nil", tt.wantErr)
}
if !strings.Contains(cmd.Err().Error(), tt.wantErr) {
t.Fatalf("expected error containing %q, got %q", tt.wantErr, cmd.Err().Error())
}
if m.lastCmd != nil {
t.Fatalf("expected command not to be executed")
}
})
}
}
func TestFTHybridWithArgsAcceptsVectorFP32(t *testing.T) {
tests := []struct {
name string
vector Vector
}{
{name: "fp32", vector: &VectorFP32{Val: []byte{1, 2, 3, 4}}},
{name: "float16", vector: &VectorFloat16{Val: []byte{1, 2, 3, 4}}},
{name: "bfloat16", vector: &VectorBFloat16{Val: []byte{1, 2, 3, 4}}},
{name: "float64", vector: &VectorFloat64{Val: []byte{1, 2, 3, 4}}},
{name: "int8", vector: &VectorInt8{Val: []byte{1, 2, 3, 4}}},
{name: "uint8", vector: &VectorUint8{Val: []byte{1, 2, 3, 4}}},
{name: "custom legacy vector", vector: legacyHybridVector{value: []any{"CUSTOM", []byte{1, 2, 3, 4}}}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
m := &mockCmdable{}
c := m.asCmdable()
cmd := c.FTHybridWithArgs(context.Background(), "idx", &FTHybridOptions{
SearchExpressions: []FTHybridSearchExpression{{Query: "*"}},
VectorExpressions: []FTHybridVectorExpression{{
VectorField: "embedding",
VectorData: tt.vector,
}},
})
if cmd.Err() != nil {
t.Fatalf("unexpected error: %v", cmd.Err())
}
if m.lastCmd == nil {
t.Fatalf("expected command to be executed")
}
gotCmd, ok := m.lastCmd.(*FTHybridCmd)
if !ok {
t.Fatalf("expected FTHybridCmd, got %T", m.lastCmd)
}
foundBlob := false
for _, arg := range gotCmd.args {
if blob, ok := arg.([]byte); ok && string(blob) == string([]byte{1, 2, 3, 4}) {
foundBlob = true
break
}
}
if !foundBlob {
t.Fatalf("expected raw vector blob in args, got %v", gotCmd.args)
}
})
}
}
// assertVectorParam verifies that the vector blob is referenced via $name right
// after the VSIM field and that it is passed through the PARAMS section.
func assertVectorParam(t *testing.T, args []interface{}, field, name string, blob []byte) {
t.Helper()
vsimIdx := indexOfArg(args, "@"+field)
if vsimIdx < 0 {
t.Fatalf("expected vector field @%s in args, got %v", field, args)
}
ref := "$" + name
if vsimIdx+1 >= len(args) || args[vsimIdx+1] != ref {
t.Fatalf("expected %q right after @%s, got %v", ref, field, args)
}
if idx := indexOfArg(args, ref); idx != vsimIdx+1 {
t.Fatalf("expected inline vector blob to be replaced by %q, got %v", ref, args)
}
paramsIdx := indexOfArg(args, "PARAMS")
if paramsIdx < 0 {
t.Fatalf("expected PARAMS in args, got %v", args)
}
nameIdx := indexOfArg(args, name)
if nameIdx <= paramsIdx {
t.Fatalf("expected param name %q after PARAMS, got %v", name, args)
}
if nameIdx+1 >= len(args) {
t.Fatalf("expected param value after %q, got %v", name, args)
}
if !reflect.DeepEqual(args[nameIdx+1], blob) {
t.Fatalf("expected param %q to carry blob %v, got %v", name, blob, args[nameIdx+1])
}
}
func TestFTHybridWithArgsGeneratesVectorParamName(t *testing.T) {
m := &mockCmdable{}
c := m.asCmdable()
blob := []byte{1, 2, 3, 4}
cmd := c.FTHybridWithArgs(context.Background(), "idx", &FTHybridOptions{
SearchExpressions: []FTHybridSearchExpression{{Query: "*"}},
VectorExpressions: []FTHybridVectorExpression{{
VectorField: "embedding",
VectorData: &VectorFP32{Val: blob},
}},
})
if cmd.Err() != nil {
t.Fatalf("unexpected error: %v", cmd.Err())
}
gotCmd, ok := m.lastCmd.(*FTHybridCmd)
if !ok {
t.Fatalf("expected FTHybridCmd, got %T", m.lastCmd)
}
assertVectorParam(t, gotCmd.args, "embedding", "__vector_param_0", blob)
}
func TestFTHybridWithArgsUsesProvidedVectorParamName(t *testing.T) {
m := &mockCmdable{}
c := m.asCmdable()
blob := []byte{1, 2, 3, 4}
cmd := c.FTHybridWithArgs(context.Background(), "idx", &FTHybridOptions{
SearchExpressions: []FTHybridSearchExpression{{Query: "*"}},
VectorExpressions: []FTHybridVectorExpression{{
VectorField: "embedding",
VectorData: &VectorFP32{Val: blob},
VectorParamName: "myvec",
}},
})
if cmd.Err() != nil {
t.Fatalf("unexpected error: %v", cmd.Err())
}
gotCmd, ok := m.lastCmd.(*FTHybridCmd)
if !ok {
t.Fatalf("expected FTHybridCmd, got %T", m.lastCmd)
}
if idx := indexOfArg(gotCmd.args, "__vector_param_0"); idx >= 0 {
t.Fatalf("did not expect generated param name when one is provided, got %v", gotCmd.args)
}
assertVectorParam(t, gotCmd.args, "embedding", "myvec", blob)
}
func TestFTHybridWithArgsGeneratesUniqueVectorParamNames(t *testing.T) {
m := &mockCmdable{}
c := m.asCmdable()
blob0 := []byte{1, 2, 3, 4}
blob1 := []byte{5, 6, 7, 8}
cmd := c.FTHybridWithArgs(context.Background(), "idx", &FTHybridOptions{
SearchExpressions: []FTHybridSearchExpression{{Query: "*"}},
VectorExpressions: []FTHybridVectorExpression{
{VectorField: "embedding", VectorData: &VectorFP32{Val: blob0}},
{VectorField: "embedding2", VectorData: &VectorFP32{Val: blob1}},
},
// Pre-populate the param that would otherwise be generated first to
// ensure the generator skips names that are already in use.
Params: map[string]interface{}{"__vector_param_0": "reserved"},
})
if cmd.Err() != nil {
t.Fatalf("unexpected error: %v", cmd.Err())
}
gotCmd, ok := m.lastCmd.(*FTHybridCmd)
if !ok {
t.Fatalf("expected FTHybridCmd, got %T", m.lastCmd)
}
assertVectorParam(t, gotCmd.args, "embedding", "__vector_param_1", blob0)
assertVectorParam(t, gotCmd.args, "embedding2", "__vector_param_2", blob1)
}
func TestGenerateVectorParamName(t *testing.T) {
if got := generateVectorParamName(nil); got != "__vector_param_0" {
t.Fatalf("expected __vector_param_0 for nil params, got %q", got)
}
params := map[string]interface{}{}
for i := 0; i < 3; i++ {
name := generateVectorParamName(params)
want := fmt.Sprintf("__vector_param_%d", i)
if name != want {
t.Fatalf("expected %q, got %q", want, name)
}
params[name] = struct{}{}
}
}
func TestFTHybridWithArgsDoesNotMutateParams(t *testing.T) {
m := &mockCmdable{}
c := m.asCmdable()
blob := []byte{1, 2, 3, 4}
options := &FTHybridOptions{
SearchExpressions: []FTHybridSearchExpression{{Query: "*"}},
VectorExpressions: []FTHybridVectorExpression{{
VectorField: "embedding",
VectorData: &VectorFP32{Val: blob},
}},
Params: map[string]interface{}{"existing": "value"},
}
want := map[string]interface{}{"existing": "value"}
// Calling multiple times with the same options must not accumulate generated
// parameters in options.Params nor otherwise mutate it.
for i := 0; i < 3; i++ {
cmd := c.FTHybridWithArgs(context.Background(), "idx", options)
if cmd.Err() != nil {
t.Fatalf("unexpected error on call %d: %v", i, cmd.Err())
}
gotCmd, ok := m.lastCmd.(*FTHybridCmd)
if !ok {
t.Fatalf("expected FTHybridCmd, got %T", m.lastCmd)
}
// The generated param and the user-provided param are both emitted.
assertVectorParam(t, gotCmd.args, "embedding", "__vector_param_0", blob)
if !reflect.DeepEqual(options.Params, want) {
t.Fatalf("options.Params mutated after call %d: got %v, want %v", i, options.Params, want)
}
}
}
func TestFTHybridWithArgsGeneratedNameAvoidsExplicitCollision(t *testing.T) {
m := &mockCmdable{}
c := m.asCmdable()
blob0 := []byte{1, 2, 3, 4}
blob1 := []byte{5, 6, 7, 8}
// The first expression auto-generates a name while the second explicitly uses
// the name the generator would otherwise pick. The generated name must avoid
// the explicit one regardless of ordering.
cmd := c.FTHybridWithArgs(context.Background(), "idx", &FTHybridOptions{
SearchExpressions: []FTHybridSearchExpression{{Query: "*"}},
VectorExpressions: []FTHybridVectorExpression{
{VectorField: "embedding", VectorData: &VectorFP32{Val: blob0}},
{VectorField: "embedding2", VectorData: &VectorFP32{Val: blob1}, VectorParamName: "__vector_param_0"},
},
})
if cmd.Err() != nil {
t.Fatalf("unexpected error: %v", cmd.Err())
}
gotCmd, ok := m.lastCmd.(*FTHybridCmd)
if !ok {
t.Fatalf("expected FTHybridCmd, got %T", m.lastCmd)
}
assertVectorParam(t, gotCmd.args, "embedding2", "__vector_param_0", blob1)
assertVectorParam(t, gotCmd.args, "embedding", "__vector_param_1", blob0)
}
// TestFTHybridWithArgs_ShardKRatio verifies the SHARD_K_RATIO option
// (Redis 8.8+) is emitted on the VSIM clause after the KNN method block and
// before FILTER when a positive ratio is set, and omitted entirely when the
// ratio is left at its zero value (server default of 1.0 applies).
func TestFTHybridWithArgs_ShardKRatio(t *testing.T) {
indexOf := func(args []interface{}, want string) int {
for i, a := range args {
if s, ok := a.(string); ok && s == want {
return i
}
}
return -1
}
t.Run("emitted with KNN", func(t *testing.T) {
m := &mockCmdable{}
c := m.asCmdable()
const ratio = 0.6
cmd := c.FTHybridWithArgs(context.Background(), "idx", &FTHybridOptions{
SearchExpressions: []FTHybridSearchExpression{{Query: "*"}},
VectorExpressions: []FTHybridVectorExpression{{
VectorField: "embedding",
VectorData: &VectorFP32{Val: []byte{1, 2, 3, 4}},
VectorParamName: "vec",
Method: "KNN",
MethodParams: []interface{}{"K", 10, "EF_RUNTIME", 50},
ShardKRatio: ratio,
Filter: "@brand:{trek}",
}},
})
if cmd.Err() != nil {
t.Fatalf("unexpected error: %v", cmd.Err())
}
gotCmd, ok := m.lastCmd.(*FTHybridCmd)
if !ok {
t.Fatalf("expected FTHybridCmd, got %T", m.lastCmd)
}
idxKNN := indexOf(gotCmd.args, "KNN")
idxShard := indexOf(gotCmd.args, "SHARD_K_RATIO")
idxFilter := indexOf(gotCmd.args, "FILTER")
if idxKNN == -1 || idxShard == -1 || idxFilter == -1 {
t.Fatalf("missing token in args: KNN=%d SHARD_K_RATIO=%d FILTER=%d (args=%v)",
idxKNN, idxShard, idxFilter, gotCmd.args)
}
if !(idxKNN < idxShard && idxShard < idxFilter) {
t.Fatalf("expected KNN < SHARD_K_RATIO < FILTER, got %d, %d, %d", idxKNN, idxShard, idxFilter)
}
if got := gotCmd.args[idxShard+1]; got != ratio {
t.Fatalf("SHARD_K_RATIO value: got %v (%T), want %v", got, got, ratio)
}
})
t.Run("omitted when zero", func(t *testing.T) {
m := &mockCmdable{}
c := m.asCmdable()
cmd := c.FTHybridWithArgs(context.Background(), "idx", &FTHybridOptions{
SearchExpressions: []FTHybridSearchExpression{{Query: "*"}},
VectorExpressions: []FTHybridVectorExpression{{
VectorField: "embedding",
VectorData: &VectorFP32{Val: []byte{1, 2, 3, 4}},
Method: "KNN",
MethodParams: []interface{}{"K", 10},
}},
})
if cmd.Err() != nil {
t.Fatalf("unexpected error: %v", cmd.Err())
}
gotCmd := m.lastCmd.(*FTHybridCmd)
if indexOf(gotCmd.args, "SHARD_K_RATIO") != -1 {
t.Fatalf("expected SHARD_K_RATIO to be absent, args=%v", gotCmd.args)
}
})
}
// TestFTCreateHNSWRerank_Args verifies the RERANK attribute (Redis 8.10+,
// disk-backed HNSW) is emitted as an explicit RERANK TRUE/FALSE key-value pair
// counted in the attribute-count token, and omitted when HasRerank is unset.
func TestFTCreateHNSWRerank_Args(t *testing.T) {
tests := []struct {
name string
opts *FTHNSWOptions
want []interface{}
}{
{
name: "rerank_unset",
opts: &FTHNSWOptions{Type: "FLOAT32", Dim: 2, DistanceMetric: "L2"},
want: []interface{}{
"FT.CREATE", "idx", "SCHEMA", "v", "VECTOR", "HNSW", 6,
"TYPE", "FLOAT32", "DIM", 2, "DISTANCE_METRIC", "L2",
},
},
{
name: "rerank_true",
opts: &FTHNSWOptions{Type: "FLOAT32", Dim: 2, DistanceMetric: "L2", Rerank: true, HasRerank: true},
want: []interface{}{
"FT.CREATE", "idx", "SCHEMA", "v", "VECTOR", "HNSW", 8,
"TYPE", "FLOAT32", "DIM", 2, "DISTANCE_METRIC", "L2", "RERANK", "TRUE",
},
},
{
name: "rerank_true_without_has_flag",
opts: &FTHNSWOptions{Type: "FLOAT32", Dim: 2, DistanceMetric: "L2", Rerank: true},
want: []interface{}{
"FT.CREATE", "idx", "SCHEMA", "v", "VECTOR", "HNSW", 8,
"TYPE", "FLOAT32", "DIM", 2, "DISTANCE_METRIC", "L2", "RERANK", "TRUE",
},
},
{
name: "rerank_false_explicit",
opts: &FTHNSWOptions{Type: "FLOAT32", Dim: 2, DistanceMetric: "L2", Rerank: false, HasRerank: true},
want: []interface{}{
"FT.CREATE", "idx", "SCHEMA", "v", "VECTOR", "HNSW", 8,
"TYPE", "FLOAT32", "DIM", 2, "DISTANCE_METRIC", "L2", "RERANK", "FALSE",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
m := &mockCmdable{}
c := m.asCmdable()
cmd := c.FTCreate(context.Background(), "idx", nil, &FieldSchema{
FieldName: "v",
FieldType: SearchFieldTypeVector,
VectorArgs: &FTVectorArgs{HNSWOptions: tt.opts},
})
if cmd.Err() != nil {
t.Fatalf("unexpected error: %v", cmd.Err())
}
if !reflect.DeepEqual(cmd.Args(), tt.want) {
t.Errorf("args mismatch\n got: %#v\nwant: %#v", cmd.Args(), tt.want)
}
})
}
}
// TestFTCreateSortableAfterFieldOptions verifies that SORTABLE, UNF and
// NOINDEX follow the type-specific field options. FT.CREATE reads no
// type-specific option after them, so "TEXT SORTABLE WEIGHT 2" fails.
func TestFTCreateSortableAfterFieldOptions(t *testing.T) {
tests := []struct {
name string
schema *FieldSchema
want []interface{}
}{
{
name: "text",
schema: &FieldSchema{
FieldName: "t", FieldType: SearchFieldTypeText,
NoStem: true, PhoneticMatcher: "dm:en", Weight: 2, WithSuffixtrie: true,
IndexEmpty: true, IndexMissing: true, Sortable: true, UNF: true,
},
want: []interface{}{
"FT.CREATE", "idx", "SCHEMA", "t", "TEXT", "NOSTEM", "PHONETIC", "dm:en",
"WEIGHT", float64(2), "WITHSUFFIXTRIE", "INDEXEMPTY", "INDEXMISSING",
"SORTABLE", "UNF",
},
},
{
name: "tag",
schema: &FieldSchema{
FieldName: "g", FieldType: SearchFieldTypeTag,
Separator: ";", CaseSensitive: true, Sortable: true, NoIndex: true,
},
want: []interface{}{
"FT.CREATE", "idx", "SCHEMA", "g", "TAG", "SEPARATOR", ";", "CASESENSITIVE",
"SORTABLE", "NOINDEX",
},
},
{
name: "numeric",
schema: &FieldSchema{
FieldName: "n", FieldType: SearchFieldTypeNumeric,
IndexMissing: true, Sortable: true,
},
want: []interface{}{
"FT.CREATE", "idx", "SCHEMA", "n", "NUMERIC", "INDEXMISSING", "SORTABLE",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
m := &mockCmdable{}
c := m.asCmdable()
cmd := c.FTCreate(context.Background(), "idx", nil, tt.schema)
if cmd.Err() != nil {
t.Fatalf("unexpected error: %v", cmd.Err())
}
if !reflect.DeepEqual(cmd.Args(), tt.want) {
t.Errorf("args mismatch\n got: %#v\nwant: %#v", cmd.Args(), tt.want)
}
})
}
}
// TestFTCreateHNSWCompression_Args verifies the COMPRESSION and
// TRAINING_THRESHOLD attributes (Redis 8.12+, scalar-quantized HNSW) are
// emitted as key-value pairs counted in the attribute-count token.
// TRAINING_THRESHOLD is emitted for a positive value, or for an explicit zero
// when HasTrainingThreshold is set, and omitted otherwise.
func TestFTCreateHNSWCompression_Args(t *testing.T) {
tests := []struct {
name string
opts *FTHNSWOptions
want []any
}{
{
name: "unset",
opts: &FTHNSWOptions{Type: "FLOAT32", Dim: 2, DistanceMetric: "L2"},
want: []any{
"FT.CREATE", "idx", "SCHEMA", "v", "VECTOR", "HNSW", 6,
"TYPE", "FLOAT32", "DIM", 2, "DISTANCE_METRIC", "L2",
},
},
{
name: "compression_only",
opts: &FTHNSWOptions{Type: "FLOAT32", Dim: 2, DistanceMetric: "L2", Compression: "SQ8"},
want: []any{
"FT.CREATE", "idx", "SCHEMA", "v", "VECTOR", "HNSW", 8,
"TYPE", "FLOAT32", "DIM", 2, "DISTANCE_METRIC", "L2", "COMPRESSION", "SQ8",
},
},
{
name: "compression_and_training_threshold",
opts: &FTHNSWOptions{Type: "FLOAT32", Dim: 2, DistanceMetric: "L2", Compression: "SQ8", TrainingThreshold: 1024},
want: []any{
"FT.CREATE", "idx", "SCHEMA", "v", "VECTOR", "HNSW", 10,
"TYPE", "FLOAT32", "DIM", 2, "DISTANCE_METRIC", "L2", "COMPRESSION", "SQ8", "TRAINING_THRESHOLD", 1024,
},
},
{
name: "training_threshold_explicit_zero",
opts: &FTHNSWOptions{Type: "FLOAT32", Dim: 2, DistanceMetric: "L2", Compression: "SQ8", HasTrainingThreshold: true},
want: []any{
"FT.CREATE", "idx", "SCHEMA", "v", "VECTOR", "HNSW", 10,
"TYPE", "FLOAT32", "DIM", 2, "DISTANCE_METRIC", "L2", "COMPRESSION", "SQ8", "TRAINING_THRESHOLD", 0,
},
},
{
name: "training_threshold_zero_without_has_flag_omitted",
opts: &FTHNSWOptions{Type: "FLOAT32", Dim: 2, DistanceMetric: "L2", Compression: "SQ8", TrainingThreshold: 0},
want: []any{
"FT.CREATE", "idx", "SCHEMA", "v", "VECTOR", "HNSW", 8,
"TYPE", "FLOAT32", "DIM", 2, "DISTANCE_METRIC", "L2", "COMPRESSION", "SQ8",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
m := &mockCmdable{}
c := m.asCmdable()
cmd := c.FTCreate(context.Background(), "idx", nil, &FieldSchema{
FieldName: "v",
FieldType: SearchFieldTypeVector,
VectorArgs: &FTVectorArgs{HNSWOptions: tt.opts},
})
if cmd.Err() != nil {
t.Fatalf("unexpected error: %v", cmd.Err())
}
if !reflect.DeepEqual(cmd.Args(), tt.want) {
t.Errorf("args mismatch\n got: %#v\nwant: %#v", cmd.Args(), tt.want)
}
})
}
}
// TestParseFTInfoHNSWCompression checks that FT.INFO populates
// FTAttribute.Compression and FTAttribute.TrainingThreshold from both the
// RESP2 flat-slice attribute shape and the RESP3 map attribute shape.
func TestParseFTInfoHNSWCompression(t *testing.T) {
t.Run("resp2 slice", func(t *testing.T) {
data := map[string]any{
"attributes": []any{
[]any{
"identifier", "v", "attribute", "v", "type", "VECTOR",
"algorithm", "HNSW", "data_type", "FLOAT32", "dim", int64(2),
"distance_metric", "L2", "M", int64(16), "ef_construction", int64(200),
"compression", "SQ8", "training_threshold", int64(1024),
},
},
}
info, err := parseFTInfo(data)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(info.Attributes) != 1 {
t.Fatalf("expected 1 attribute, got %d", len(info.Attributes))
}
att := info.Attributes[0]
if att.Compression != "SQ8" {
t.Errorf("Compression = %q, want %q", att.Compression, "SQ8")
}
if att.TrainingThreshold != 1024 {
t.Errorf("TrainingThreshold = %d, want %d", att.TrainingThreshold, 1024)
}
})
t.Run("resp3 map", func(t *testing.T) {
att := parseFTAttributeFromMap(map[any]any{
"identifier": "v",
"attribute": "v",
"type": "VECTOR",
"algorithm": "HNSW",
"compression": "SQ8",
"training_threshold": int64(1024),
})
if att.Compression != "SQ8" {
t.Errorf("Compression = %q, want %q", att.Compression, "SQ8")
}
if att.TrainingThreshold != 1024 {
t.Errorf("TrainingThreshold = %d, want %d", att.TrainingThreshold, 1024)
}
})
}
// TestParseFTHybridWarnings checks that FT.HYBRID warnings populate from either
// the "warning" or "warnings" key.
func TestParseFTHybridWarnings(t *testing.T) {
tests := []struct {
name string
data []interface{}
expected []string
}{
{
name: "singular warning key",
data: []interface{}{
"total_results", int64(0),
"results", []interface{}{},
"warning", []interface{}{"Timeout limit was reached"},
},
expected: []string{"Timeout limit was reached"},
},
{
name: "plural warnings key",
data: []interface{}{
"total_results", int64(0),
"results", []interface{}{},
"warnings", []interface{}{"Timeout limit was reached"},
},
expected: []string{"Timeout limit was reached"},
},
{
name: "no warnings",
data: []interface{}{
"total_results", int64(0),
"results", []interface{}{},
},
expected: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, _, err := parseFTHybrid(tt.data, false)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(result.Warnings) != len(tt.expected) {
t.Fatalf("len(Warnings) = %d, want %d (%v)", len(result.Warnings), len(tt.expected), result.Warnings)
}
for i, w := range result.Warnings {
if w != tt.expected[i] {
t.Errorf("Warnings[%d] = %q, want %q", i, w, tt.expected[i])
}
}
})
}
}
func TestDropIndexBuilderDeleteDocs(t *testing.T) {
client := NewClient(&Options{Addr: "localhost:6379"})
defer client.Close()
builder := client.NewDropIndexBuilder(context.Background(), "idx").DeleteDocs()
if builder.options == nil || !builder.options.DeleteDocs {
t.Fatal("expected DeleteDocs to be true")
}
}