mirror of
https://github.com/redis/go-redis.git
synced 2026-10-28 08:24:54 +08:00
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.
771 lines
23 KiB
Go
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")
|
|
}
|
|
}
|