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:
Jakub Zych
2026-10-02 11:37:01 +02:00
parent e06e0cc8bf
commit 3ae49bb6f4
7 changed files with 253 additions and 17 deletions

View File

@@ -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

View 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)
}
}