Skip to content

Commit cedfdef

Browse files
committed
init
1 parent 7dd76cf commit cedfdef

File tree

6 files changed

+369
-1
lines changed

6 files changed

+369
-1
lines changed

.github/workflows/test.yml

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
name: test
2+
3+
on: [push]
4+
5+
permissions: read-all
6+
7+
jobs:
8+
build:
9+
name: test
10+
runs-on: ubuntu-latest
11+
timeout-minutes: 5
12+
13+
services:
14+
redis:
15+
image: redis:latest
16+
options: >-
17+
--health-cmd "redis-cli ping"
18+
--health-interval 10s
19+
--health-timeout 5s
20+
--health-retries 5
21+
ports:
22+
- 6379:6379
23+
env:
24+
REDIS_PASSWORD: testpassword
25+
26+
steps:
27+
- name: code
28+
uses: actions/checkout@v4
29+
30+
- name: go
31+
uses: actions/setup-go@v5
32+
with:
33+
go-version: ^1.24
34+
35+
- name: test
36+
env:
37+
REDIS_ADDR: localhost:6379
38+
REDIS_PASSWORD: testpassword
39+
run: |
40+
go get -v -t -d ./...
41+
go test -short -cover -coverprofile=coverage.out -covermode=atomic ./...
42+
43+
- name: codecov.io coverage
44+
uses: codecov/[email protected]
45+
with:
46+
token: ${{ secrets.CODECOV_TOKEN }}
47+
files: coverage.out

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
# redis-suggest
1+
Go Redis `FT.SUGADD`, `FT.SUGGET`, `FT.SUGLEN`, `FT.SUGLEN`

go.mod

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
module github.com/ndx-technologies/go-redis-suggest
2+
3+
go 1.24.0
4+
5+
require github.com/redis/go-redis/v9 v9.7.1
6+
7+
require (
8+
github.com/cespare/xxhash/v2 v2.2.0 // indirect
9+
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
10+
)

go.sum

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
2+
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
3+
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
4+
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
5+
github.com/redis/go-redis/v9 v9.7.1 h1:4LhKRCIduqXqtvCUlaq9c8bdHOkICjDMrr1+Zb3osAc=
6+
github.com/redis/go-redis/v9 v9.7.1/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw=

suggestion.go

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
package redissug
2+
3+
import (
4+
"context"
5+
6+
"github.com/redis/go-redis/v9"
7+
)
8+
9+
// RedisSuggestionClient that provides SUG commands.
10+
type RedisSuggestionClient struct {
11+
DB *redis.Client
12+
}
13+
14+
type Suggestion struct {
15+
Text string
16+
Payload string
17+
}
18+
19+
// SugAdd returns current size of suggestion dictionary
20+
func (s RedisSuggestionClient) SugAdd(ctx context.Context, key string, suggestion string, score float64, incr bool, payload string) (int, error) {
21+
args := []any{"FT.SUGADD", key, suggestion, score}
22+
23+
if incr {
24+
args = append(args, "INCR")
25+
}
26+
27+
if payload != "" {
28+
args = append(args, "PAYLOAD", payload)
29+
}
30+
31+
return s.DB.Do(ctx, args...).Int()
32+
}
33+
34+
type SugGetOptions struct {
35+
Fuzzy bool
36+
WithPayloads bool
37+
}
38+
39+
// SugGet returns suggestions sorted from highest score to lowest score.
40+
// Scores may not be the same as the scores used in SugAdd, which is why they are not exposed.
41+
func (s RedisSuggestionClient) SugGet(ctx context.Context, key string, prefix string, max int, opts SugGetOptions) ([]Suggestion, error) {
42+
args := []any{"FT.SUGGET", key, prefix}
43+
44+
if opts.Fuzzy {
45+
args = append(args, "FUZZY")
46+
}
47+
if opts.WithPayloads {
48+
args = append(args, "WITHPAYLOADS")
49+
}
50+
if max > 0 {
51+
args = append(args, "MAX", max)
52+
}
53+
54+
result, err := s.DB.Do(ctx, args...).Slice()
55+
if err != nil {
56+
return nil, err
57+
}
58+
59+
suggestions := make([]Suggestion, 0, max)
60+
61+
for i := 0; i < len(result); {
62+
var s Suggestion
63+
64+
s.Text, _ = result[i].(string)
65+
i++
66+
67+
if opts.WithPayloads {
68+
s.Payload, _ = result[i].(string)
69+
i++
70+
}
71+
72+
suggestions = append(suggestions, s)
73+
}
74+
75+
return suggestions, nil
76+
}
77+
78+
// SugDel deletes a suggestion from a suggestion index
79+
func (s RedisSuggestionClient) SugDel(ctx context.Context, key string, suggestion string) error {
80+
v, err := s.DB.Do(ctx, "FT.SUGDEL", key, suggestion).Int()
81+
if err != nil {
82+
return err
83+
}
84+
if v == 0 {
85+
return redis.Nil
86+
}
87+
return nil
88+
}
89+
90+
// SugLen returns size of an auto-complete suggestion dictionary
91+
func (s RedisSuggestionClient) SugLen(ctx context.Context, key string) (int, error) {
92+
return s.DB.Do(ctx, "FT.SUGLEN", key).Int()
93+
}
94+
95+
func (s RedisSuggestionClient) DelAll(ctx context.Context, keys ...string) error {
96+
_, err := s.DB.Del(ctx, keys...).Result()
97+
return err
98+
}

suggestion_test.go

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
package redissug_test
2+
3+
import (
4+
"context"
5+
"math/rand"
6+
"os"
7+
"slices"
8+
"strconv"
9+
"testing"
10+
11+
"github.com/redis/go-redis/v9"
12+
13+
redissug "github.com/ndx-technologies/go-redis-suggest"
14+
)
15+
16+
func TestRedisSuggest(t *testing.T) {
17+
if testing.Short() {
18+
t.Skip("network; redis;")
19+
}
20+
21+
addr := os.Getenv("REDIS_ADDR")
22+
if addr == "" {
23+
addr = "localhost:6379"
24+
}
25+
26+
rdb := redis.NewClient(&redis.Options{
27+
Addr: addr,
28+
Password: os.Getenv("REDIS_PASSWORD"),
29+
})
30+
31+
ctx := t.Context()
32+
id := "test-sug:" + strconv.Itoa(rand.Int())
33+
s := redissug.RedisSuggestionClient{DB: rdb}
34+
t.Cleanup(func() { s.DelAll(context.Background(), id) })
35+
36+
t.Run("when adding suggestions", func(t *testing.T) {
37+
size, err := s.SugAdd(ctx, id, "text", 1, false, "payload1")
38+
if err != nil {
39+
t.Error("SugAdd failed:", err)
40+
}
41+
if size != 1 {
42+
t.Error("expected size 1, got", size)
43+
}
44+
45+
size, err = s.SugAdd(ctx, id, "test", 2, false, "payload2")
46+
if err != nil {
47+
t.Error("SugAdd failed:", err)
48+
}
49+
if size != 2 {
50+
t.Error("expected size 2, got", size)
51+
}
52+
53+
size, err = s.SugAdd(ctx, id, "tent", 2, false, "")
54+
if err != nil {
55+
t.Error("SugAdd failed:", err)
56+
}
57+
if size != 3 {
58+
t.Error("expected size 3, got", size)
59+
}
60+
61+
size, err = s.SugAdd(ctx, id, "tent", 1, true, "")
62+
if err != nil {
63+
t.Error("SugAdd failed:", err)
64+
}
65+
if size != 3 {
66+
t.Error("expected size 3, got", size)
67+
}
68+
69+
t.Run("when getting length, then 3", func(t *testing.T) {
70+
count, err := s.SugLen(ctx, id)
71+
if err != nil {
72+
t.Error("SugLen failed:", err)
73+
}
74+
if count != 3 {
75+
t.Error("expected count 3, got", count)
76+
}
77+
})
78+
79+
t.Run("when prefix, then return all matched in highest to lowest score order", func(t *testing.T) {
80+
suggestions, err := s.SugGet(ctx, id, "te", 10, redissug.SugGetOptions{})
81+
if err != nil {
82+
t.Error("SugGet failed:", err)
83+
}
84+
exp := []redissug.Suggestion{{Text: "tent"}, {Text: "test"}, {Text: "text"}}
85+
if !slices.Equal(suggestions, exp) {
86+
t.Error("expected", exp, "got", suggestions)
87+
}
88+
})
89+
90+
t.Run("when exact prefix, then only exact is returned", func(t *testing.T) {
91+
suggestions, err := s.SugGet(ctx, id, "tex", 10, redissug.SugGetOptions{})
92+
if err != nil {
93+
t.Error("SugGet failed:", err)
94+
}
95+
exp := []redissug.Suggestion{{Text: "text"}}
96+
if !slices.Equal(suggestions, exp) {
97+
t.Error("expected", exp, "got", suggestions)
98+
}
99+
})
100+
101+
t.Run("when fuzzy prefix, then multiple returned", func(t *testing.T) {
102+
suggestions, err := s.SugGet(ctx, id, "tex", 10, redissug.SugGetOptions{Fuzzy: true})
103+
if err != nil {
104+
t.Error("SugGet failed:", err)
105+
}
106+
exp := []redissug.Suggestion{{Text: "text"}, {Text: "tent"}, {Text: "test"}}
107+
if !slices.Equal(suggestions, exp) {
108+
t.Error("expected", exp, "got", suggestions)
109+
}
110+
})
111+
112+
t.Run("when getting with payloads, then payloads are returned", func(t *testing.T) {
113+
suggestions, err := s.SugGet(ctx, id, "te", 10, redissug.SugGetOptions{WithPayloads: true})
114+
if err != nil {
115+
t.Error("SugGet failed:", err)
116+
}
117+
exp := []redissug.Suggestion{{Text: "tent"}, {Text: "test", Payload: "payload2"}, {Text: "text", Payload: "payload1"}}
118+
if !slices.Equal(suggestions, exp) {
119+
t.Error("expected", exp, "got", suggestions)
120+
}
121+
})
122+
123+
t.Run("when adding same suggestion with different score and payload", func(t *testing.T) {
124+
size, err := s.SugAdd(ctx, id, "text", 5, false, "new_payload")
125+
if err != nil {
126+
t.Error("SugAdd failed:", err)
127+
}
128+
if size != 3 {
129+
t.Error("expected size 3, got", size)
130+
}
131+
132+
t.Run("then new payload is set", func(t *testing.T) {
133+
suggestions, err := s.SugGet(ctx, id, "tex", 10, redissug.SugGetOptions{WithPayloads: true})
134+
if err != nil {
135+
t.Error("SugGet failed:", err)
136+
}
137+
exp := []redissug.Suggestion{{Text: "text", Payload: "new_payload"}}
138+
if !slices.Equal(suggestions, exp) {
139+
t.Error("expected", exp, "got", suggestions)
140+
}
141+
})
142+
})
143+
})
144+
145+
t.Run("when deleting suggestion", func(t *testing.T) {
146+
if err := s.SugDel(ctx, id, "test"); err != nil {
147+
t.Error("SugDel failed:", err)
148+
}
149+
150+
t.Run("when deleting non existing, then no error and ok", func(t *testing.T) {
151+
if err := s.SugDel(ctx, id, "asdf"); err != redis.Nil {
152+
t.Error("expected redis.Nil, got", err)
153+
}
154+
})
155+
156+
t.Run("when getting suggestions, then no deleted entry", func(t *testing.T) {
157+
suggestions, err := s.SugGet(ctx, id, "te", 10, redissug.SugGetOptions{})
158+
if err != nil {
159+
t.Error("SugGet failed:", err)
160+
}
161+
exp := []redissug.Suggestion{{Text: "text"}, {Text: "tent"}}
162+
if !slices.Equal(suggestions, exp) {
163+
t.Error("expected", exp, "got", suggestions)
164+
}
165+
})
166+
167+
t.Run("then length is decremented", func(t *testing.T) {
168+
count, err := s.SugLen(ctx, id)
169+
if err != nil {
170+
t.Error("SugLen failed:", err)
171+
}
172+
if count != 2 {
173+
t.Error("expected count 2, got", count)
174+
}
175+
})
176+
})
177+
178+
t.Run("when non existing key", func(t *testing.T) {
179+
nonExistentKey := "non-existent:" + strconv.Itoa(rand.Int())
180+
181+
t.Run("when getting suggestions, then redis.Nil", func(t *testing.T) {
182+
suggestions, err := s.SugGet(ctx, nonExistentKey, "te", 10, redissug.SugGetOptions{})
183+
if err != redis.Nil {
184+
t.Error("expected redis.Nil, got", err)
185+
}
186+
if len(suggestions) != 0 {
187+
t.Error("expected empty slice, got", suggestions)
188+
}
189+
})
190+
191+
t.Run("when getting length, then 0", func(t *testing.T) {
192+
count, err := s.SugLen(ctx, nonExistentKey)
193+
if err != nil {
194+
t.Error("SugLen failed:", err)
195+
}
196+
if count != 0 {
197+
t.Error("expected count 0, got", count)
198+
}
199+
})
200+
201+
t.Run("when deleting non existing, then redis nil", func(t *testing.T) {
202+
if err := s.SugDel(ctx, nonExistentKey, "asdf"); err != redis.Nil {
203+
t.Error("expected redis.Nil, got", err)
204+
}
205+
})
206+
})
207+
}

0 commit comments

Comments
 (0)