1
0
mirror of https://github.com/redis/go-redis.git synced 2026-10-22 19:49:00 +03:00
Files
go-redis/search_collect_test.go
Nedyalko Dyakov 8ba2e2a20f feat(search): add COLLECT reducer for FT.AGGREGATE (#3886)
* feat(search): add COLLECT reducer builder + response decode for FT.AGGREGATE

Add FTAggregateCollect options, NewCollectReducer constructor, and
AggregateBuilder.Collect that render the REDUCE COLLECT clause (FIELDS/
DISTINCT/SORTBY/LIMIT) with automatic @-prefixing and arg-count. Add
AggregateRow.Collect to decode the sparse RESP2/RESP3 entry array into a
uniform CollectColumn. Integration-tested against Redis 8.8. Additive:
no existing type or serializer changed.

Requires Redis 8.8+ with search-enable-unstable-features.

* fix(search): validate COLLECT args, harden integration test

- reject empty field names and ASC+DESC combinations client-side,
  matching the FT.AGGREGATE pipeline SORTBY error wording
- gate the integration test on the live server version via INFO;
  the float REDIS_VERSION check parses 8.10 as 8.1 and rots
- restore search-enable-unstable-features, drop the index, and Del
  only the test's own keys instead of FlushDB; remove broken itoa

* docs(example): add FT.AGGREGATE COLLECT reducer example

Runnable example for the COLLECT reducer, mirroring the
search-aggregate-steps layout:

- top-2 products per category via NewCollectReducer (explicit FIELDS
  with SORTBY rating DESC and LIMIT), next to a plain COUNT reducer
- whole documents per brand via AggregateBuilder.Collect with LOAD *
  and FIELDS *
- replies decoded with AggregateRow.Collect (RESP2/RESP3 agnostic)

Verified live against redislabs/client-libs-test:8.8.0. Notes in code
and README: on current unstable builds, SORTBY combined with FIELDS *
drops the sort field from the entries and does not sort - use an
explicit FIELDS list when sorting.

* chore(git): remove binary

---------

Co-authored-by: ofekshenawa <104765379+ofekshenawa@users.noreply.github.com>
2026-07-29 13:20:27 +03:00

298 lines
8.5 KiB
Go

package redis
import (
"context"
"reflect"
"testing"
)
func TestSearchCollect_String(t *testing.T) {
if got := SearchCollect.String(); got != "COLLECT" {
t.Fatalf("SearchCollect.String() = %q, want %q", got, "COLLECT")
}
}
// TestNewCollectReducer_Wire is the wire oracle: an options-struct COLLECT
// query must serialize to the exact REDUCE COLLECT clause, with the argument
// count computed from the assembled tokens and every name @-prefixed. Field
// names are supplied with mixed prefixes to exercise normalization.
func TestNewCollectReducer_Wire(t *testing.T) {
m := &mockCmdable{}
c := m.asCmdable()
reducer, err := NewCollectReducer(FTAggregateCollect{
Fields: []string{"title", "@rating", "year"}, // mixed @-prefix
SortBy: []FTAggregateSortBy{{FieldName: "rating", Desc: true}},
Limit: &FTAggregateCollectLimit{Offset: 0, Count: 5},
As: "top_movies",
})
if err != nil {
t.Fatalf("NewCollectReducer: %v", err)
}
opts := &FTAggregateOptions{
GroupBy: []FTAggregateGroupBy{{
Fields: []interface{}{"@genre"},
Reduce: []FTAggregateReducer{reducer},
}},
}
cmd := c.FTAggregateWithArgs(context.Background(), "idx", "*", opts)
if cmd.Err() != nil {
t.Fatalf("unexpected error: %v", cmd.Err())
}
want := []interface{}{
"FT.AGGREGATE", "idx", "*",
"GROUPBY", 1, "@genre",
"REDUCE", "COLLECT", 12,
"FIELDS", 3, "@title", "@rating", "@year",
"SORTBY", 2, "@rating", "DESC",
"LIMIT", 0, 5,
"AS", "top_movies",
"DIALECT", 2,
}
if !reflect.DeepEqual(cmd.args, want) {
t.Fatalf("args mismatch:\n got: %v\n want: %v", cmd.args, want)
}
// narg must equal the token count.
if got := len(reducer.Args); got != 12 {
t.Fatalf("narg = %d, want 12", got)
}
}
func TestBuildCollectArgs(t *testing.T) {
tests := []struct {
name string
in FTAggregateCollect
want []interface{}
wantErr bool
}{
{
name: "fields all",
in: FTAggregateCollect{FieldsAll: true},
want: []interface{}{"FIELDS", "*"},
},
{
name: "fields all wins over explicit list",
in: FTAggregateCollect{FieldsAll: true, Fields: []string{"x"}},
want: []interface{}{"FIELDS", "*"},
},
{
name: "explicit fields normalized",
in: FTAggregateCollect{Fields: []string{"a", "@b", "@@c"}},
want: []interface{}{"FIELDS", 3, "@a", "@b", "@c"},
},
{
name: "sortby default asc emits nothing",
in: FTAggregateCollect{
Fields: []string{"a"},
SortBy: []FTAggregateSortBy{{FieldName: "a"}},
},
want: []interface{}{"FIELDS", 1, "@a", "SORTBY", 1, "@a"},
},
{
name: "sortby explicit asc emits ASC",
in: FTAggregateCollect{
Fields: []string{"a"},
SortBy: []FTAggregateSortBy{{FieldName: "a", Asc: true}},
},
want: []interface{}{"FIELDS", 1, "@a", "SORTBY", 2, "@a", "ASC"},
},
{
name: "sortby multi keys mixed direction",
in: FTAggregateCollect{
Fields: []string{"a"},
SortBy: []FTAggregateSortBy{{FieldName: "a", Desc: true}, {FieldName: "b"}},
},
want: []interface{}{"FIELDS", 1, "@a", "SORTBY", 3, "@a", "DESC", "@b"},
},
{
name: "distinct emitted",
in: FTAggregateCollect{FieldsAll: true, Distinct: true},
want: []interface{}{"FIELDS", "*", "DISTINCT"},
},
{
name: "limit emitted",
in: FTAggregateCollect{FieldsAll: true, Limit: &FTAggregateCollectLimit{Offset: 2, Count: 7}},
want: []interface{}{"FIELDS", "*", "LIMIT", 2, 7},
},
{
name: "no fields selector is an error",
in: FTAggregateCollect{},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := buildCollectArgs(tt.in)
if tt.wantErr {
if err == nil {
t.Fatalf("expected error, got args %v", got)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !reflect.DeepEqual(got, tt.want) {
t.Fatalf("args mismatch:\n got: %v\n want: %v", got, tt.want)
}
})
}
}
func TestEnsureAtPrefix(t *testing.T) {
cases := map[string]string{
"name": "@name",
"@name": "@name",
"@@name": "@name",
"@@@name": "@name",
"__key": "@__key",
"@__key": "@__key",
"": "@",
}
for in, want := range cases {
if got := ensureAtPrefix(in); got != want {
t.Errorf("ensureAtPrefix(%q) = %q, want %q", in, got, want)
}
}
}
func TestNewCollectReducer_Error(t *testing.T) {
if _, err := NewCollectReducer(FTAggregateCollect{}); err == nil {
t.Fatal("expected error for missing FIELDS selector")
}
}
// TestAggregateBuilder_Collect checks the builder appends a correctly rendered
// COLLECT reducer to the last GROUPBY step. The wire serialization itself is
// covered by TestNewCollectReducer_Wire (same serializer path).
func TestAggregateBuilder_Collect(t *testing.T) {
b := &AggregateBuilder{options: &FTAggregateOptions{LimitOffset: -1}}
b.GroupBy("@genre").Collect(FTAggregateCollect{
Fields: []string{"title"},
Limit: &FTAggregateCollectLimit{Offset: 0, Count: 3},
As: "top",
})
if b.err != nil {
t.Fatalf("unexpected builder error: %v", b.err)
}
if n := len(b.options.Steps); n != 1 {
t.Fatalf("steps = %d, want 1", n)
}
g := b.options.Steps[0].GroupBy
if g == nil || len(g.Reduce) != 1 {
t.Fatalf("expected one reducer on the GroupBy step, got %+v", g)
}
r := g.Reduce[0]
if r.Reducer != SearchCollect || r.As != "top" {
t.Fatalf("reducer = %v as=%q, want COLLECT as=top", r.Reducer, r.As)
}
want := []interface{}{"FIELDS", 1, "@title", "LIMIT", 0, 3}
if !reflect.DeepEqual(r.Args, want) {
t.Fatalf("reducer args mismatch:\n got: %v\n want: %v", r.Args, want)
}
}
func TestAggregateBuilder_Collect_Errors(t *testing.T) {
t.Run("without groupby", func(t *testing.T) {
b := &AggregateBuilder{options: &FTAggregateOptions{LimitOffset: -1}}
b.Collect(FTAggregateCollect{FieldsAll: true})
if b.err == nil {
t.Fatal("expected error: Collect must follow a GroupBy step")
}
})
t.Run("invalid options", func(t *testing.T) {
b := &AggregateBuilder{options: &FTAggregateOptions{LimitOffset: -1}}
b.GroupBy("@genre").Collect(FTAggregateCollect{})
if b.err == nil {
t.Fatal("expected error: missing FIELDS selector")
}
})
}
// TestAggregateRow_Collect_RESP3 decodes the RESP3 map form, including a sparse
// entry that omits a projected field.
func TestAggregateRow_Collect_RESP3(t *testing.T) {
row := AggregateRow{Fields: map[string]interface{}{
"fruits": []interface{}{
map[interface{}]interface{}{"name": "apple", "sweetness": "4"},
map[interface{}]interface{}{"name": "strawberry"}, // sparse: no sweetness
},
}}
col, err := row.Collect("fruits")
if err != nil {
t.Fatalf("Collect: %v", err)
}
if len(col) != 2 {
t.Fatalf("len = %d, want 2", len(col))
}
if col[0]["name"] != "apple" || col[0]["sweetness"] != "4" {
t.Fatalf("entry 0 = %v", col[0])
}
if col[1]["name"] != "strawberry" {
t.Fatalf("entry 1 name = %v", col[1]["name"])
}
if _, ok := col[1]["sweetness"]; ok {
t.Fatalf("entry 1 should be sparse, has sweetness: %v", col[1])
}
}
// TestAggregateRow_Collect_RESP2 decodes the RESP2 flat key/value array form.
func TestAggregateRow_Collect_RESP2(t *testing.T) {
row := AggregateRow{Fields: map[string]interface{}{
"fruits": []interface{}{
[]interface{}{"name", "apple", "sweetness", "4"},
[]interface{}{"name", "strawberry"}, // sparse
},
}}
col, err := row.Collect("fruits")
if err != nil {
t.Fatalf("Collect: %v", err)
}
if len(col) != 2 {
t.Fatalf("len = %d, want 2", len(col))
}
if col[0]["name"] != "apple" || col[0]["sweetness"] != "4" {
t.Fatalf("entry 0 = %v", col[0])
}
if _, ok := col[1]["sweetness"]; ok {
t.Fatalf("entry 1 should be sparse: %v", col[1])
}
}
func TestAggregateRow_Collect_EdgeCases(t *testing.T) {
t.Run("absent alias", func(t *testing.T) {
row := AggregateRow{Fields: map[string]interface{}{}}
col, err := row.Collect("missing")
if err != nil || col != nil {
t.Fatalf("got (%v, %v), want (nil, nil)", col, err)
}
})
t.Run("non-array value", func(t *testing.T) {
row := AggregateRow{Fields: map[string]interface{}{"x": "scalar"}}
if _, err := row.Collect("x"); err == nil {
t.Fatal("expected error for non-array COLLECT value")
}
})
t.Run("odd-length resp2 entry", func(t *testing.T) {
row := AggregateRow{Fields: map[string]interface{}{
"x": []interface{}{[]interface{}{"name", "apple", "dangling"}},
}}
if _, err := row.Collect("x"); err == nil {
t.Fatal("expected error for odd-length key/value entry")
}
})
t.Run("bad entry type", func(t *testing.T) {
row := AggregateRow{Fields: map[string]interface{}{
"x": []interface{}{42},
}}
if _, err := row.Collect("x"); err == nil {
t.Fatal("expected error for non-map/non-array entry")
}
})
}