From 3d2bbb9da47f9e63e690758949483e09971a49a4 Mon Sep 17 00:00:00 2001 From: Dave Day Date: Fri, 3 Oct 2014 15:19:38 +1000 Subject: [PATCH] appengine/search: Introduce SortOptions for search. Change-Id: Id3f777a0619acacd3906e22132b95f22e3ca585c --- search/search.go | 101 ++++++++++++++++++++++++++++++++++++++++++ search/search_test.go | 80 +++++++++++++++++++++++++++++++++ 2 files changed, 181 insertions(+) diff --git a/search/search.go b/search/search.go index 00b0f00..11b64f7 100644 --- a/search/search.go +++ b/search/search.go @@ -402,6 +402,7 @@ func (x *Index) Search(c appengine.Context, query string, opts *SearchOptions) * t.limit = opts.Limit } t.idsOnly = opts.IDsOnly + t.sort = opts.Sort } return t } @@ -420,6 +421,11 @@ func moreSearch(t *Iterator) error { if t.idsOnly { req.Params.KeysOnly = &t.idsOnly } + if t.sort != nil { + if err := sortToProto(t.sort, req.Params); err != nil { + return err + } + } if t.searchCursor != nil { req.Params.Cursor = t.searchCursor @@ -452,9 +458,103 @@ type SearchOptions struct { // operation; no document fields are populated. IDsOnly bool + // Sort controls the ordering of search results. + Sort *SortOptions + // TODO: cursor, offset, maybe others. } +// SortOptions control the ordering and scoring of search results. +type SortOptions struct { + // Expressions is a slice of expressions representing a multi-dimensional + // sort. + Expressions []SortExpression + + // Scorer, when specified, will cause the documents to be scored according to + // search term frequency. + Scorer Scorer + + // Limit is the maximum number of objects to score and/or sort. Limit cannot + // be more than 10,000. The zero value indicates a default limit. + Limit int +} + +// SortExpression defines a single dimension for sorting a document. +type SortExpression struct { + // Expr is evaluated to providing a sorting value for each document. + // See https://developers.google.com/appengine/docs/go/search/options for + // the supported expression syntax. + Expr string + + // Reverse causes the documents to be sorted in ascending order. + Reverse bool + + // The default value to use when no field is present or the expresion + // cannot be calculated for a document. For text sorts, Default must + // be of type string; for numeric sorts, float64. + Default interface{} +} + +// A Scorer defines how a document is scored. +type Scorer interface { + toProto(*pb.ScorerSpec) +} + +type enumScorer struct { + enum pb.ScorerSpec_Scorer +} + +func (e enumScorer) toProto(spec *pb.ScorerSpec) { + spec.Scorer = e.enum.Enum() +} + +var ( + // MatchScorer assigns a score based on term frequency in a document. + MatchScorer Scorer = enumScorer{pb.ScorerSpec_MATCH_SCORER} + + // RescoringMatchScorer assigns a score based on the quality of the query + // match. It is similar to a MatchScorer but uses a more complex scoring + // algorithm based on match term frequency and other factors like field type. + // Please be aware that this algorithm is continually refined and can change + // over time without notice. This means that the ordering of search results + // that use this scorer can also change without notice. + RescoringMatchScorer Scorer = enumScorer{pb.ScorerSpec_RESCORING_MATCH_SCORER} +) + +func sortToProto(sort *SortOptions, params *pb.SearchParams) error { + for _, e := range sort.Expressions { + spec := &pb.SortSpec{ + SortExpression: proto.String(e.Expr), + } + if e.Reverse { + spec.SortDescending = proto.Bool(false) + } + if e.Default != nil { + switch d := e.Default.(type) { + case float64: + spec.DefaultValueNumeric = &d + case string: + spec.DefaultValueText = &d + default: + return fmt.Errorf("search: invalid Default type %T for expression %q", d, e.Expr) + } + } + params.SortSpec = append(params.SortSpec, spec) + } + + spec := &pb.ScorerSpec{} + if sort.Limit > 0 { + spec.Limit = proto.Int32(int32(sort.Limit)) + params.ScorerSpec = spec + } + if sort.Scorer != nil { + sort.Scorer.toProto(spec) + params.ScorerSpec = spec + } + + return nil +} + // Iterator is the result of searching an index for a query or listing an // index. type Iterator struct { @@ -469,6 +569,7 @@ type Iterator struct { searchRes []*pb.SearchResult searchQuery string searchCursor *string + sort *SortOptions more func(*Iterator) error diff --git a/search/search_test.go b/search/search_test.go index d1dc37a..de644bf 100644 --- a/search/search_test.go +++ b/search/search_test.go @@ -477,3 +477,83 @@ func TestPutBadStatus(t *testing.T) { t.Fatalf("Put: got %v error, want %q", err, wantErr) } } + +func TestSortOptions(t *testing.T) { + index, err := Open("Doc") + if err != nil { + t.Fatalf("err from Open: %v", err) + } + + noErr := errors.New("") // sentinel error when there isn't oneā€¦ + + testCases := []struct { + desc string + sort *SortOptions + wantSort []*pb.SortSpec + wantScorer *pb.ScorerSpec + wantErr string + }{ + { + desc: "No SortOptions", + }, + { + desc: "Basic", + sort: &SortOptions{ + Expressions: []SortExpression{ + {Expr: "dog"}, + {Expr: "cat", Reverse: true}, + {Expr: "gopher", Default: "blue"}, + {Expr: "fish", Default: 2.0}, + }, + Limit: 42, + Scorer: MatchScorer, + }, + wantSort: []*pb.SortSpec{ + {SortExpression: proto.String("dog")}, + {SortExpression: proto.String("cat"), SortDescending: proto.Bool(false)}, + {SortExpression: proto.String("gopher"), DefaultValueText: proto.String("blue")}, + {SortExpression: proto.String("fish"), DefaultValueNumeric: proto.Float64(2)}, + }, + wantScorer: &pb.ScorerSpec{ + Limit: proto.Int32(42), + Scorer: pb.ScorerSpec_MATCH_SCORER.Enum(), + }, + }, + { + desc: "Bad expression default", + sort: &SortOptions{ + Expressions: []SortExpression{ + {Expr: "dog", Default: true}, + }, + }, + wantErr: `search: invalid Default type bool for expression "dog"`, + }, + { + desc: "RescoringMatchScorer", + sort: &SortOptions{Scorer: RescoringMatchScorer}, + wantScorer: &pb.ScorerSpec{Scorer: pb.ScorerSpec_RESCORING_MATCH_SCORER.Enum()}, + }, + } + + for _, tt := range testCases { + c := aetesting.FakeSingleContext(t, "search", "Search", func(req *pb.SearchRequest, _ *pb.SearchResponse) error { + params := req.Params + if !reflect.DeepEqual(params.SortSpec, tt.wantSort) { + t.Errorf("%s: params.SortSpec=%v; want %v", tt.desc, params.SortSpec, tt.wantSort) + } + if !reflect.DeepEqual(params.ScorerSpec, tt.wantScorer) { + t.Errorf("%s: params.ScorerSpec=%v; want %v", tt.desc, params.ScorerSpec, tt.wantScorer) + } + return noErr // Always return some error to prevent response parsing. + }) + + it := index.Search(c, "gopher", &SearchOptions{Sort: tt.sort}) + _, err := it.Next(nil) + if err == nil { + t.Fatalf("%s: err==nil; should not happen", tt.desc) + } + if err.Error() != tt.wantErr { + t.Errorf("%s: got error %q, want %q", tt.desc, err, tt.wantErr) + } + } +}