Skip to content

Commit 4c6b146

Browse files
feat: add Scan method for incremental key retrieval in gredis (#3451)
1 parent 6e2d238 commit 4c6b146

File tree

4 files changed

+143
-1
lines changed

4 files changed

+143
-1
lines changed

contrib/nosql/redis/redis_group_generic.go

+27
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,33 @@ func (r GroupGeneric) Keys(ctx context.Context, pattern string) ([]string, error
170170
return v.Strings(), err
171171
}
172172

173+
// Scan executes a single iteration of the SCAN command, returning a subset of keys matching the pattern along with the next cursor position.
174+
// This method provides more efficient and safer way to iterate over large datasets compared to KEYS command.
175+
//
176+
// Users are responsible for controlling the iteration by managing the cursor.
177+
//
178+
// The `count` optional parameter advises Redis on the number of keys to return. While it's not a strict limit, it guides the operation's granularity.
179+
//
180+
// https://redis.io/commands/scan/
181+
func (r GroupGeneric) Scan(ctx context.Context, cursor uint64, option ...gredis.ScanOption) (uint64, []string, error) {
182+
var usedOption interface{}
183+
if len(option) > 0 {
184+
usedOption = option[0].ToUsedOption()
185+
}
186+
187+
v, err := r.Operation.Do(ctx, "Scan", mustMergeOptionToArgs(
188+
[]interface{}{cursor}, usedOption,
189+
)...)
190+
if err != nil {
191+
return 0, nil, err
192+
}
193+
194+
nextCursor := gconv.Uint64(v.Slice()[0])
195+
keys := gconv.SliceStr(v.Slice()[1])
196+
197+
return nextCursor, keys, nil
198+
}
199+
173200
// FlushDB delete all the keys of the currently selected DB. This command never fails.
174201
//
175202
// https://redis.io/commands/flushdb/

contrib/nosql/redis/redis_z_group_generic_test.go

+83
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,89 @@ func Test_GroupGeneric_Keys(t *testing.T) {
257257
})
258258
}
259259

260+
func Test_GroupGeneric_Scan(t *testing.T) {
261+
gtest.C(t, func(t *gtest.T) {
262+
defer redis.FlushDB(ctx)
263+
264+
err := redis.GroupString().MSet(ctx, map[string]interface{}{
265+
"firstname": "Jack",
266+
"lastname": "Stuntman",
267+
"age": 35,
268+
"nickname": "Jumper",
269+
})
270+
t.AssertNil(err)
271+
272+
performScan := func(cursor uint64, option ...gredis.ScanOption) ([]string, error) {
273+
var allKeys = []string{}
274+
for {
275+
var nextCursor uint64
276+
var keys []string
277+
var err error
278+
279+
if option != nil {
280+
nextCursor, keys, err = redis.Scan(ctx, cursor, option[0])
281+
} else {
282+
nextCursor, keys, err = redis.Scan(ctx, cursor)
283+
}
284+
if err != nil {
285+
return nil, err
286+
}
287+
288+
allKeys = append(allKeys, keys...)
289+
if nextCursor == 0 {
290+
break
291+
}
292+
cursor = nextCursor
293+
}
294+
return allKeys, nil
295+
}
296+
297+
// Test scanning for keys with `*name*` pattern
298+
optWithName := gredis.ScanOption{Match: "*name*", Count: 10}
299+
keysWithName, err := performScan(0, optWithName)
300+
t.AssertNil(err)
301+
t.AssertGE(len(keysWithName), 3)
302+
t.AssertIN(keysWithName, []string{"lastname", "firstname", "nickname"})
303+
304+
// Test scanning with a pattern that matches exactly one key
305+
optWithAge := gredis.ScanOption{Match: "a??", Count: 10}
306+
keysWithAge, err := performScan(0, optWithAge)
307+
t.AssertNil(err)
308+
t.AssertEQ(len(keysWithAge), 1)
309+
t.AssertEQ(keysWithAge, []string{"age"})
310+
311+
// Test scanning for all keys
312+
optWithAll := gredis.ScanOption{Match: "*", Count: 10}
313+
all, err := performScan(0, optWithAll)
314+
t.AssertNil(err)
315+
t.AssertGE(len(all), 4)
316+
t.AssertIN(all, []string{"lastname", "firstname", "age", "nickname"})
317+
318+
// Test empty pattern
319+
optWithEmptyPattern := gredis.ScanOption{Match: ""}
320+
emptyPatternKeys, err := performScan(0, optWithEmptyPattern)
321+
t.AssertNil(err)
322+
t.AssertEQ(len(emptyPatternKeys), 4)
323+
324+
// Test pattern with no matches
325+
optWithNoMatch := gredis.ScanOption{Match: "xyz*", Count: 10}
326+
noMatchKeys, err := performScan(0, optWithNoMatch)
327+
t.AssertNil(err)
328+
t.AssertEQ(len(noMatchKeys), 0)
329+
330+
// Test scanning for keys with invalid count value
331+
optWithInvalidCount := gredis.ScanOption{Count: -1}
332+
_, err = performScan(0, optWithInvalidCount)
333+
t.AssertNQ(err, nil)
334+
335+
// Test scanning for all keys without options
336+
allWithoutOpt, err := performScan(0)
337+
t.AssertNil(err)
338+
t.AssertGE(len(allWithoutOpt), 4)
339+
t.AssertIN(all, []string{"lastname", "firstname", "age", "nickname"})
340+
})
341+
}
342+
260343
func Test_GroupGeneric_FlushDB(t *testing.T) {
261344
gtest.C(t, func(t *gtest.T) {
262345
defer redis.FlushDB(ctx)

database/gredis/gredis_redis_group_generic.go

+32
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ type IGroupGeneric interface {
2727
RandomKey(ctx context.Context) (string, error)
2828
DBSize(ctx context.Context) (int64, error)
2929
Keys(ctx context.Context, pattern string) ([]string, error)
30+
Scan(ctx context.Context, cursor uint64, option ...ScanOption) (uint64, []string, error)
3031
FlushDB(ctx context.Context, option ...FlushOp) error
3132
FlushAll(ctx context.Context, option ...FlushOp) error
3233
Expire(ctx context.Context, key string, seconds int64, option ...ExpireOption) (int64, error)
@@ -60,3 +61,34 @@ type ExpireOption struct {
6061
GT bool // GT -- Set expiry only when the new expiry is greater than current one
6162
LT bool // LT -- Set expiry only when the new expiry is less than current one
6263
}
64+
65+
// ScanOption provides options for function Scan.
66+
type ScanOption struct {
67+
Match string // Match -- Specifies a glob-style pattern for filtering keys.
68+
Count int // Count -- Suggests the number of keys to return per scan.
69+
Type string // Type -- Filters keys by their data type. Valid types are "string", "list", "set", "zset", "hash", and "stream".
70+
}
71+
72+
// doScanOption is the internal representation of ScanOption.
73+
type doScanOption struct {
74+
Match *string
75+
Count *int
76+
Type *string
77+
}
78+
79+
// ToUsedOption converts fields in ScanOption with zero values to nil. Only fields with values are retained.
80+
func (scanOpt *ScanOption) ToUsedOption() doScanOption {
81+
var usedOption doScanOption
82+
83+
if scanOpt.Match != "" {
84+
usedOption.Match = &scanOpt.Match
85+
}
86+
if scanOpt.Count != 0 {
87+
usedOption.Count = &scanOpt.Count
88+
}
89+
if scanOpt.Type != "" {
90+
usedOption.Type = &scanOpt.Type
91+
}
92+
93+
return usedOption
94+
}

util/gvalid/gvalid_register.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ func RegisterRule(rule string, f RuleFunc) {
5252
if customRuleFuncMap[rule] != nil {
5353
intlog.PrintFunc(context.TODO(), func() string {
5454
return fmt.Sprintf(
55-
`rule "%s" is overwrotten by function "%s"`,
55+
`rule "%s" is overwritten by function "%s"`,
5656
rule, runtime.FuncForPC(reflect.ValueOf(f).Pointer()).Name(),
5757
)
5858
})

0 commit comments

Comments
 (0)