Repository: redis/rueidis

Issue:
Provide iterator-based API for xSCAN operations
Hi! It'd be much cleaner for the library user to invoke various SCAN operations if they were provided as iterators. See https://pkg.go.dev/iter.

---

I built a wrapper for HSCAN myself, but it'd be nice if it was part of the library.

```go
type scanEntry struct {
	key   string
	value string
}

// scan iterates all keys of a hash and calls the yields key and value for each entry.
// see https://redis.io/commands/scan
func scan(ctx context.Context, redisKey string, c rueidis.CoreClient) iter.Seq2[scanEntry, error] {
	return func(yield func(scanEntry, error) bool) {
		var se rueidis.ScanEntry
		var err error
		for more := true; more; more = se.Cursor != 0 {
			hscanCmd := c.B().Hscan().Key(redisKey).Cursor(se.Cursor).Count(scanCount).Build()
			se, err = c.Do(ctx, hscanCmd).AsScanEntry()
			if err != nil {
				yield(scanEntry{}, err)
				return
			}
			if len(se.Elements)%2 != 0 {
				// This shouldn't happen
				yield(scanEntry{}, errors.New("invalid Redis reply"))
				return
			}
			for i := 0; i < len(se.Elements); i += 2 {
				k := se.Elements[i]
				v := se.Elements[i+1]
				if !yield(scanEntry{key: k, value: v}, nil) {
					return
				}
			}
		}
	}
}
```

Discussion:
@rueian: Hi @ash2k, thanks for the request.

To serve all kinds of xSCAN, including SCAN without key and HSCAN with NOVALUES, I think we can have this:

```go
func ScanIter(nextFn func(cursor uint64) (ScanEntry, error)) iter.Seq2[[]string, error] {
	return func(yield func([]string, error) bool) {
		cursor := uint64(0)
		for {
			v, err := nextFn(cursor)
			if err != nil {
				yield(nil, err)
				return
			}
			if !yield(v.Elements, nil) {
				return
			}
			if v.Cursor == 0 {
				return
			}
			cursor = v.Cursor
		}
	}
}
```

Note that the returned iterator is `iter.Seq2[[]string, error]` instead of `iter.Seq2[scanEntry, error]` in order to serve the NOVALUES case.@ash2k: I think this needs to be designed from the user's point of view (for convenience).

As a user of the library, I'd like to have something like this:

```go
	for HSCANEntry, err := rueidis.HSCAN(client, redisKey).Do(ctx) {
		
	}
```

Or like this to specify page size:

```go
	for HSCANEntry, err := rueidis.HSCAN(client, redisKey).Count(scanCount).Do(ctx) {
		
	}
```

or like this as a method on the client:

```go
	for HSCANEntry, err := client.HSCAN(redisKey).Count(scanCount).Do(ctx) {
		
	}
```

```go
type HSCANEntry struct {
	key   string
	value string
}
```

Note how each iteration yields an individual key-value, not a slice of strings (which are key,value,key,value,etc). Much more convenient to use for me. I want the library to turn the slice into pairs. Otherwise each user has to do it.@rueian: While the `rueidis.HSCAN` helper looks nice and clean,
1. I am afraid that requires an excessive amount of code to implement method chaining and for other xSCAN commands.
2. I am not sure forcing users to use one `ctx` across each pagination is a good idea. Would it be better to provide an opportunity to renew `ctx`?

Yielding individual key-value pairs is indeed more handy. Maybe we could let the user choose:

```go

func scan(nextFn func(cursor uint64) (ScanEntry, error)) iter.Seq2[[]string, error] {
	return func(yield func([]string, error) bool) {
		cursor := uint64(0)
		for {
			v, err := nextFn(cursor)
			if err != nil {
				yield(nil, err)
				return
			}
			if !yield(v.Elements, nil) {
				return
			}
			if v.Cursor == 0 {
				return
			}
			cursor = v.Cursor
		}
	}
}

func ScanIter(nextFn func(cursor uint64) (ScanEntry, error)) iter.Seq2[string, error] {
	return func(yield func(string, error) bool) {
		for vs, err := range scan(nextFn) {
			if err != nil {
				yield("", err)
				return
			}
			for _, v := range vs {
				if !yield(v, nil) {
					return
				}
			}
		}
	}
}

func ScanPairsIter(nextFn func(cursor uint64) (ScanEntry, error)) iter.Seq2[[2]string, error] {
	return func(yield func([2]string, error) bool) {
		for vs, err := range scan(nextFn) {
			if err != nil {
				yield([2]string{}, err)
				return
			}
			for i := 0; i+1 < len(vs); i += 2 {
				if !yield([2]string(vs[i:i+2]), nil) {
					return
				}
			}
		}
	}
}
```
@rueian: I found that repeatedly checking the `err` for each yield is quite unnecessary. Probably the following API will be more efficient:

```go
func NewScanner(next func(cursor uint64) (ScanEntry, error)) *Scanner {
	return &Scanner{next: next}
}

type Scanner struct {
	next func(cursor uint64) (ScanEntry, error)
	err  error
}

func (s *Scanner) Err() error {
	return s.err
}

func (s *Scanner) scan() iter.Seq[[]string] {
	return func(yield func([]string) bool) {
		var e ScanEntry
		for e, s.err = s.next(0); s.err == nil && yield(e.Elements) && e.Cursor != 0; {
			e, s.err = s.next(e.Cursor)
		}
	}
}

func (s *Scanner) Iter() iter.Seq[string] {
	return func(yield func(string) bool) {
		for vs := range s.scan() {
			for i := 0; i < len(vs) && yield(vs[i]); i++ {
			}
		}
	}
}

func (s *Scanner) Iter2() iter.Seq2[string, string] {
	return func(yield func(string, string) bool) {
		for vs := range s.scan() {
			for i := 0; i+1 < len(vs) && yield(vs[i], vs[i+1]); i += 2 {
			}
		}
	}
}
```@ash2k: I like it! 👍 @dalaoqi: @rueian Can I help with this?@rueian: Hi @dalaoqi, any updates?


Solution patch:
diff --git a/helper.go b/helper.go
index 3e21f91..4289a0b 100644
--- a/helper.go
+++ b/helper.go
@@ -3,6 +3,7 @@ package rueidis
 import (
 	"context"
 	"errors"
+	"iter"
 	"time"
 
 	intl "github.com/redis/rueidis/internal/cmds"
@@ -279,3 +280,43 @@ func arrayToKV(m map[string]RedisMessage, arr []RedisMessage, keys []string) map
 // ErrMSetNXNotSet is used in the MSetNX helper when the underlying MSETNX response is 0.
 // Ref: https://redis.io/commands/msetnx/
 var ErrMSetNXNotSet = errors.New("MSETNX: no key was set")
+
+type Scanner struct {
+	next func(cursor uint64) (ScanEntry, error)
+	err  error
+}
+
+func NewScanner(next func(cursor uint64) (ScanEntry, error)) *Scanner {
+	return &Scanner{next: next}
+}
+
+func (s *Scanner) scan() iter.Seq[[]string] {
+	return func(yield func([]string) bool) {
+		var e ScanEntry
+		for e, s.err = s.next(0); s.err == nil && yield(e.Elements) && e.Cursor != 0; {
+			e, s.err = s.next(e.Cursor)
+		}
+	}
+}
+
+func (s *Scanner) Iter() iter.Seq[string] {
+	return func(yield func(string) bool) {
+		for vs := range s.scan() {
+			for i := 0; i < len(vs) && yield(vs[i]); i++ {
+			}
+		}
+	}
+}
+
+func (s *Scanner) Iter2() iter.Seq2[string, string] {
+	return func(yield func(string, string) bool) {
+		for vs := range s.scan() {
+			for i := 0; i+1 < len(vs) && yield(vs[i], vs[i+1]); i += 2 {
+			}
+		}
+	}
+}
+
+func (s *Scanner) Err() error {
+	return s.err
+}



Test patch:
diff --git a/helper_test.go b/helper_test.go
index 200d1ee..69695e0 100644
--- a/helper_test.go
+++ b/helper_test.go
@@ -2,6 +2,7 @@ package rueidis
 
 import (
 	"context"
+	"errors"
 	"reflect"
 	"strconv"
 	"testing"
@@ -1461,3 +1462,165 @@ func TestDecodeSliceOfJSON(t *testing.T) {
 		}
 	})
 }
+
+func TestScannerIter(t *testing.T) {
+	tests := []struct {
+		name     string
+		entries  []ScanEntry
+		err      error
+		expected []string
+		wantErr  bool
+	}{
+		{
+			name: "single page",
+			entries: []ScanEntry{
+				{Elements: []string{"key1", "key2", "key3"}, Cursor: 0},
+			},
+			expected: []string{"key1", "key2", "key3"},
+		},
+		{
+			name: "multiple pages",
+			entries: []ScanEntry{
+				{Elements: []string{"key1", "key2"}, Cursor: 10},
+				{Elements: []string{"key3", "key4"}, Cursor: 0},
+			},
+			expected: []string{"key1", "key2", "key3", "key4"},
+		},
+		{
+			name: "empty result",
+			entries: []ScanEntry{
+				{Elements: []string{}, Cursor: 0},
+			},
+			expected: []string{},
+		},
+		{
+			name:    "error case",
+			err:     errors.New("scan error"),
+			wantErr: true,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			callCount := 0
+			scanner := NewScanner(func(cursor uint64) (ScanEntry, error) {
+				if tt.err != nil {
+					return ScanEntry{}, tt.err
+				}
+				if callCount >= len(tt.entries) {
+					return ScanEntry{}, errors.New("unexpected call")
+				}
+				entry := tt.entries[callCount]
+				callCount++
+				return entry, nil
+			})
+
+			var result []string
+			for element := range scanner.Iter() {
+				result = append(result, element)
+			}
+
+			if tt.wantErr {
+				if scanner.Err() == nil {
+					t.Error("expected error but got none")
+				}
+			} else {
+				if scanner.Err() != nil {
+					t.Errorf("unexpected error: %v", scanner.Err())
+				}
+				if (len(result) != 0 || len(tt.expected) != 0) && !reflect.DeepEqual(result, tt.expected) {
+					t.Errorf("got %v, want %v", result, tt.expected)
+				}
+			}
+		})
+	}
+}
+
+func TestScannerIter2(t *testing.T) {
+	tests := []struct {
+		name         string
+		entries      []ScanEntry
+		err          error
+		expectedKeys []string
+		expectedVals []string
+		wantErr      bool
+	}{
+		{
+			name: "single page pairs",
+			entries: []ScanEntry{
+				{Elements: []string{"field1", "value1", "field2", "value2"}, Cursor: 0},
+			},
+			expectedKeys: []string{"field1", "field2"},
+			expectedVals: []string{"value1", "value2"},
+		},
+		{
+			name: "multiple pages pairs",
+			entries: []ScanEntry{
+				{Elements: []string{"field1", "value1"}, Cursor: 10},
+				{Elements: []string{"field2", "value2", "field3", "value3"}, Cursor: 0},
+			},
+			expectedKeys: []string{"field1", "field2", "field3"},
+			expectedVals: []string{"value1", "value2", "value3"},
+		},
+		{
+			name: "odd number of elements",
+			entries: []ScanEntry{
+				{Elements: []string{"field1", "value1", "field2"}, Cursor: 0},
+			},
+			expectedKeys: []string{"field1"},
+			expectedVals: []string{"value1"},
+		},
+		{
+			name: "empty result",
+			entries: []ScanEntry{
+				{Elements: []string{}, Cursor: 0},
+			},
+			expectedKeys: []string{},
+			expectedVals: []string{},
+		},
+		{
+			name:    "error case",
+			err:     errors.New("scan error"),
+			wantErr: true,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			callCount := 0
+			scanner := NewScanner(func(cursor uint64) (ScanEntry, error) {
+				if tt.err != nil {
+					return ScanEntry{}, tt.err
+				}
+				if callCount >= len(tt.entries) {
+					return ScanEntry{}, errors.New("unexpected call")
+				}
+				entry := tt.entries[callCount]
+				callCount++
+				return entry, nil
+			})
+
+			var resultKeys, resultVals []string
+			for key, val := range scanner.Iter2() {
+				resultKeys = append(resultKeys, key)
+				resultVals = append(resultVals, val)
+			}
+
+			if tt.wantErr {
+				if scanner.Err() == nil {
+					t.Error("expected error but got none")
+				}
+			} else {
+				if scanner.Err() != nil {
+					t.Errorf("unexpected error: %v", scanner.Err())
+				}
+				if (len(resultKeys) != 0 || len(tt.expectedKeys) != 0) && !reflect.DeepEqual(resultKeys, tt.expectedKeys) {
+					t.Errorf("keys: got %v, want %v", resultKeys, tt.expectedKeys)
+				}
+				if (len(resultVals) != 0 || len(tt.expectedVals) != 0) && !reflect.DeepEqual(resultVals, tt.expectedVals) {
+					t.Errorf("values: got %v, want %v", resultVals, tt.expectedVals)
+				}
+			}
+		})
+	}
+}