feat(12-01): report the search engine's found count and field weights
- optional beachcomber.PageSearcher returns a page of candidate ids plus the engine's found count; beachcomber.SearchPage falls back to SearchIDs for engines without it, so Engine is unchanged - Query.QueryByWeights is sent to Typesense as query_by_weights; a mismatched weight list or a page above typesense.MaxPerPage (250) is refused before any request
This commit is contained in:
@@ -214,21 +214,46 @@ func (e *Engine) Flush(ctx context.Context, index string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// searchAnswer is the part of a search answer SearchIDs reads.
|
||||
// searchAnswer is the part of a search answer SearchIDs and SearchPage read.
|
||||
type searchAnswer struct {
|
||||
Hits []struct {
|
||||
Found int `json:"found"`
|
||||
Hits []struct {
|
||||
Document struct {
|
||||
ID json.RawMessage `json:"id"`
|
||||
} `json:"document"`
|
||||
} `json:"hits"`
|
||||
}
|
||||
|
||||
// MaxPerPage is the largest page Typesense answers (Scout's maxPerPage);
|
||||
// a larger Query.PerPage is an error, so callers page instead.
|
||||
const MaxPerPage = 250
|
||||
|
||||
// SearchIDs sends GET /collections/{index}/documents/search with q
|
||||
// (default "*"), query_by, filter_by, sort_by, page and per_page, and
|
||||
// returns hits[].document.id in order. The ids are candidates: the caller
|
||||
// must re-check each one in SQL before exposing it. No hits is an empty
|
||||
// list.
|
||||
// (default "*"), query_by, query_by_weights, filter_by, sort_by, page and
|
||||
// per_page, and returns hits[].document.id in order. The ids are
|
||||
// candidates: the caller must re-check each one in SQL before exposing it.
|
||||
// No hits is an empty list.
|
||||
func (e *Engine) SearchIDs(ctx context.Context, index string, q beachcomber.Query) ([]string, error) {
|
||||
res, err := e.search(ctx, index, q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return res.IDs, nil
|
||||
}
|
||||
|
||||
// SearchPage is SearchIDs plus the found count of the answer: how many
|
||||
// documents matched in the index, not how many the caller may see.
|
||||
func (e *Engine) SearchPage(ctx context.Context, index string, q beachcomber.Query) (beachcomber.SearchResult, error) {
|
||||
return e.search(ctx, index, q)
|
||||
}
|
||||
|
||||
func (e *Engine) search(ctx context.Context, index string, q beachcomber.Query) (beachcomber.SearchResult, error) {
|
||||
if len(q.QueryByWeights) > 0 && len(q.QueryByWeights) != len(q.QueryBy) {
|
||||
return beachcomber.SearchResult{}, fmt.Errorf("typesense: search %s: %d query_by_weights for %d query_by fields", index, len(q.QueryByWeights), len(q.QueryBy))
|
||||
}
|
||||
if q.PerPage > MaxPerPage {
|
||||
return beachcomber.SearchResult{}, fmt.Errorf("typesense: search %s: per_page %d exceeds %d", index, q.PerPage, MaxPerPage)
|
||||
}
|
||||
params := url.Values{}
|
||||
text := q.Q
|
||||
if text == "" {
|
||||
@@ -238,6 +263,13 @@ func (e *Engine) SearchIDs(ctx context.Context, index string, q beachcomber.Quer
|
||||
if len(q.QueryBy) > 0 {
|
||||
params.Set("query_by", strings.Join(q.QueryBy, ","))
|
||||
}
|
||||
if len(q.QueryByWeights) > 0 {
|
||||
weights := make([]string, len(q.QueryByWeights))
|
||||
for i, w := range q.QueryByWeights {
|
||||
weights[i] = strconv.Itoa(w)
|
||||
}
|
||||
params.Set("query_by_weights", strings.Join(weights, ","))
|
||||
}
|
||||
if q.FilterBy != "" {
|
||||
params.Set("filter_by", q.FilterBy)
|
||||
}
|
||||
@@ -253,14 +285,14 @@ func (e *Engine) SearchIDs(ctx context.Context, index string, q beachcomber.Quer
|
||||
path := "/collections/" + url.PathEscape(index) + "/documents/search"
|
||||
code, answer, err := e.do(ctx, http.MethodGet, path, params, nil, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return beachcomber.SearchResult{}, err
|
||||
}
|
||||
if !ok2xx(code) {
|
||||
return nil, statusError(http.MethodGet, path, code)
|
||||
return beachcomber.SearchResult{}, statusError(http.MethodGet, path, code)
|
||||
}
|
||||
var res searchAnswer
|
||||
if err := json.Unmarshal(answer, &res); err != nil {
|
||||
return nil, fmt.Errorf("typesense: search %s: unreadable answer", index)
|
||||
return beachcomber.SearchResult{}, fmt.Errorf("typesense: search %s: unreadable answer", index)
|
||||
}
|
||||
ids := make([]string, 0, len(res.Hits))
|
||||
for _, h := range res.Hits {
|
||||
@@ -275,7 +307,7 @@ func (e *Engine) SearchIDs(ctx context.Context, index string, q beachcomber.Quer
|
||||
}
|
||||
ids = append(ids, string(raw))
|
||||
}
|
||||
return ids, nil
|
||||
return beachcomber.SearchResult{IDs: ids, Found: res.Found}, nil
|
||||
}
|
||||
|
||||
// do sends one request with the API key header and returns the status and
|
||||
|
||||
79
modules/beachcomber/typesense/searchpage_test.go
Normal file
79
modules/beachcomber/typesense/searchpage_test.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package typesense
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/beachcomber"
|
||||
)
|
||||
|
||||
func TestTypesenseSearchPageFoundAndWeights(t *testing.T) {
|
||||
f, e := newFake(t)
|
||||
f.answer("GET /ts/collections/posts/documents/search", 200,
|
||||
`{"found":1234,"out_of":5000,"page":2,"hits":[{"document":{"id":"42"}},{"document":{"id":7}}]}`)
|
||||
q := beachcomber.Query{
|
||||
Q: "blue train",
|
||||
QueryBy: []string{"name", "artist_display", "notes"},
|
||||
QueryByWeights: []int{10, 10, 1},
|
||||
FilterBy: "collection_id:=5",
|
||||
Page: 2,
|
||||
PerPage: 250,
|
||||
}
|
||||
res, err := beachcomber.SearchPage(t.Context(), e, "posts", q)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Found != 1234 || !reflect.DeepEqual(res.IDs, []string{"42", "7"}) {
|
||||
t.Fatalf("res = %+v", res)
|
||||
}
|
||||
calls := f.take()
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("calls = %d", len(calls))
|
||||
}
|
||||
params, err := url.ParseQuery(calls[0].rawQuery)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := map[string]string{
|
||||
"q": "blue train", "query_by": "name,artist_display,notes", "query_by_weights": "10,10,1",
|
||||
"filter_by": "collection_id:=5", "page": "2", "per_page": "250",
|
||||
}
|
||||
for k, v := range want {
|
||||
if params.Get(k) != v {
|
||||
t.Fatalf("%s = %q, want %q (query %s)", k, params.Get(k), v, calls[0].rawQuery)
|
||||
}
|
||||
}
|
||||
// SearchIDs shares the request and returns the same page.
|
||||
ids, err := e.SearchIDs(t.Context(), "posts", q)
|
||||
if err != nil || !reflect.DeepEqual(ids, []string{"42", "7"}) {
|
||||
t.Fatalf("ids = %v err = %v", ids, err)
|
||||
}
|
||||
if got := f.take(); len(got) != 1 || got[0].rawQuery != calls[0].rawQuery {
|
||||
t.Fatalf("SearchIDs query = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTypesenseSearchPageRejectsBadQueries(t *testing.T) {
|
||||
f, e := newFake(t)
|
||||
f.answer("GET /ts/collections/posts/documents/search", 200, `{"found":0,"hits":[]}`)
|
||||
_, err := e.SearchPage(t.Context(), "posts", beachcomber.Query{QueryBy: []string{"name", "notes"}, QueryByWeights: []int{10}})
|
||||
if err == nil || !strings.Contains(err.Error(), "query_by_weights") {
|
||||
t.Fatalf("weights mismatch: %v", err)
|
||||
}
|
||||
_, err = e.SearchPage(t.Context(), "posts", beachcomber.Query{PerPage: MaxPerPage + 1})
|
||||
if err == nil || !strings.Contains(err.Error(), "per_page") {
|
||||
t.Fatalf("per_page: %v", err)
|
||||
}
|
||||
if calls := f.take(); len(calls) != 0 {
|
||||
t.Fatalf("a rejected query sent %d requests", len(calls))
|
||||
}
|
||||
res, err := e.SearchPage(t.Context(), "posts", beachcomber.Query{QueryBy: []string{"name"}})
|
||||
if err != nil || res.Found != 0 || len(res.IDs) != 0 {
|
||||
t.Fatalf("empty answer = %+v %v", res, err)
|
||||
}
|
||||
if got := f.take(); strings.Contains(got[0].rawQuery, "query_by_weights") {
|
||||
t.Fatalf("weights sent without QueryByWeights: %s", got[0].rawQuery)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user