feat(beachcomber): search highlights and field projection (quick-261108-pe4)
- Additive: Query.IncludeFields, ExcludeFields and Highlight; left empty, the Typesense request is the same as before. - SearchResult.Hits holds one Hit per id, in step with IDs; SearchPage always fills it. - Snippets are plain-text Segment runs split at private-use highlight tags, never HTML; MaxSnippetsPerArray is applied client-side. - Optional HighlightSearcher capability, checked with SupportsHighlights (true for Typesense, false for null and third-party engines). - README and docs: Highlights sections with the rule that a caller drops the hit of every id failing the SQL re-check before showing a snippet. Targets framework v0.6.4 (additive, no breaking change).
This commit is contained in:
@@ -10,6 +10,7 @@ import (
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -48,8 +49,9 @@ type Engine struct {
|
||||
|
||||
var _ beachcomber.Engine = (*Engine)(nil)
|
||||
var (
|
||||
_ beachcomber.IndexDropper = (*Engine)(nil)
|
||||
_ beachcomber.IndexEnsurer = (*Engine)(nil)
|
||||
_ beachcomber.IndexDropper = (*Engine)(nil)
|
||||
_ beachcomber.IndexEnsurer = (*Engine)(nil)
|
||||
_ beachcomber.HighlightSearcher = (*Engine)(nil)
|
||||
)
|
||||
|
||||
// New returns an engine for cfg. Every request is bounded by
|
||||
@@ -252,6 +254,9 @@ type searchAnswer struct {
|
||||
Document struct {
|
||||
ID json.RawMessage `json:"id"`
|
||||
} `json:"document"`
|
||||
// Highlights stays raw so an odd highlights value never makes
|
||||
// the answer unreadable.
|
||||
Highlights json.RawMessage `json:"highlights"`
|
||||
} `json:"hits"`
|
||||
}
|
||||
|
||||
@@ -261,9 +266,11 @@ const MaxPerPage = 250
|
||||
|
||||
// SearchIDs sends GET /collections/{index}/documents/search with q
|
||||
// (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.
|
||||
// per_page, and returns hits[].document.id in order. include_fields
|
||||
// (always with id), exclude_fields and, for Query.Highlight,
|
||||
// highlight_fields, highlight_affix_num_tokens and the highlight tags are
|
||||
// sent only when set. 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 {
|
||||
@@ -272,12 +279,19 @@ func (e *Engine) SearchIDs(ctx context.Context, index string, q beachcomber.Quer
|
||||
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.
|
||||
// SearchPage is SearchIDs plus the found count of the answer (how many
|
||||
// documents matched in the index, not how many the caller may see) and
|
||||
// Hits, one per id in the same order. With Query.Highlight set, each Hit
|
||||
// carries the snippets of the answer's highlights array, split into plain
|
||||
// text segments; without it, Highlights stays nil.
|
||||
func (e *Engine) SearchPage(ctx context.Context, index string, q beachcomber.Query) (beachcomber.SearchResult, error) {
|
||||
return e.search(ctx, index, q)
|
||||
}
|
||||
|
||||
// SupportsHighlights returns true: SearchPage fills Hits[].Highlights when
|
||||
// Query.Highlight is set. It implements beachcomber.HighlightSearcher.
|
||||
func (e *Engine) SupportsHighlights() bool { return true }
|
||||
|
||||
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))
|
||||
@@ -285,6 +299,19 @@ func (e *Engine) search(ctx context.Context, index string, q beachcomber.Query)
|
||||
if q.PerPage > MaxPerPage {
|
||||
return beachcomber.SearchResult{}, fmt.Errorf("typesense: search %s: per_page %d exceeds %d", index, q.PerPage, MaxPerPage)
|
||||
}
|
||||
for _, f := range q.ExcludeFields {
|
||||
if f == "id" {
|
||||
return beachcomber.SearchResult{}, fmt.Errorf("typesense: search %s: exclude_fields cannot leave out id, because the ids are the result", index)
|
||||
}
|
||||
}
|
||||
if h := q.Highlight; h != nil {
|
||||
if h.AffixTokens < 0 {
|
||||
return beachcomber.SearchResult{}, fmt.Errorf("typesense: search %s: Highlight.AffixTokens %d is negative", index, h.AffixTokens)
|
||||
}
|
||||
if h.MaxSnippetsPerArray < 0 {
|
||||
return beachcomber.SearchResult{}, fmt.Errorf("typesense: search %s: Highlight.MaxSnippetsPerArray %d is negative", index, h.MaxSnippetsPerArray)
|
||||
}
|
||||
}
|
||||
params := url.Values{}
|
||||
text := q.Q
|
||||
if text == "" {
|
||||
@@ -313,6 +340,27 @@ func (e *Engine) search(ctx context.Context, index string, q beachcomber.Query)
|
||||
if q.PerPage > 0 {
|
||||
params.Set("per_page", strconv.Itoa(q.PerPage))
|
||||
}
|
||||
if len(q.IncludeFields) > 0 {
|
||||
// The Typesense docs do not promise id under include_fields.
|
||||
fields := q.IncludeFields
|
||||
if !slices.Contains(fields, "id") {
|
||||
fields = append(slices.Clip(fields), "id")
|
||||
}
|
||||
params.Set("include_fields", strings.Join(fields, ","))
|
||||
}
|
||||
if len(q.ExcludeFields) > 0 {
|
||||
params.Set("exclude_fields", strings.Join(q.ExcludeFields, ","))
|
||||
}
|
||||
if h := q.Highlight; h != nil {
|
||||
params.Set("highlight_start_tag", highlightStartTag)
|
||||
params.Set("highlight_end_tag", highlightEndTag)
|
||||
if len(h.Fields) > 0 {
|
||||
params.Set("highlight_fields", strings.Join(h.Fields, ","))
|
||||
}
|
||||
if h.AffixTokens > 0 {
|
||||
params.Set("highlight_affix_num_tokens", strconv.Itoa(h.AffixTokens))
|
||||
}
|
||||
}
|
||||
path := "/collections/" + url.PathEscape(index) + "/documents/search"
|
||||
code, answer, err := e.do(ctx, http.MethodGet, path, params, nil, "")
|
||||
if err != nil {
|
||||
@@ -326,19 +374,28 @@ func (e *Engine) search(ctx context.Context, index string, q beachcomber.Query)
|
||||
return beachcomber.SearchResult{}, fmt.Errorf("typesense: search %s: unreadable answer", index)
|
||||
}
|
||||
ids := make([]string, 0, len(res.Hits))
|
||||
hits := make([]beachcomber.Hit, 0, len(res.Hits))
|
||||
for _, h := range res.Hits {
|
||||
raw := bytes.TrimSpace(h.Document.ID)
|
||||
if len(raw) == 0 || bytes.Equal(raw, []byte("null")) {
|
||||
continue
|
||||
}
|
||||
id := string(raw)
|
||||
var s string
|
||||
if err := json.Unmarshal(raw, &s); err == nil {
|
||||
ids = append(ids, s)
|
||||
continue
|
||||
id = s
|
||||
}
|
||||
ids = append(ids, string(raw))
|
||||
hit := beachcomber.Hit{ID: id}
|
||||
// Only with Highlight set are the tags the private-use characters;
|
||||
// otherwise the snippets carry Typesense's default mark tags and
|
||||
// must not be read as text.
|
||||
if q.Highlight != nil {
|
||||
hit.Highlights = parseHighlights(h.Highlights, q.Highlight)
|
||||
}
|
||||
ids = append(ids, id)
|
||||
hits = append(hits, hit)
|
||||
}
|
||||
return beachcomber.SearchResult{IDs: ids, Found: res.Found}, nil
|
||||
return beachcomber.SearchResult{IDs: ids, Found: res.Found, Hits: hits}, nil
|
||||
}
|
||||
|
||||
// do sends one request with the API key header and returns the status and
|
||||
|
||||
@@ -3,10 +3,12 @@ package typesense_test
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"html"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/beachcomber"
|
||||
@@ -47,3 +49,57 @@ func ExampleEngine_SearchIDs() {
|
||||
// [3 1] <nil>
|
||||
// false
|
||||
}
|
||||
|
||||
func ExampleEngine_SearchPage() {
|
||||
// A stand-in Typesense node. The documents carry only id and title:
|
||||
// text is excluded from the answer, yet still highlighted.
|
||||
node := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
fmt.Println("exclude_fields", q.Get("exclude_fields"), "highlight_fields", q.Get("highlight_fields"),
|
||||
"highlight_affix_num_tokens", q.Get("highlight_affix_num_tokens"))
|
||||
fmt.Println("private-use start tag:", q.Get("highlight_start_tag") == "\ue000")
|
||||
fmt.Fprint(w, `{"found":2,"hits":[`+
|
||||
`{"document":{"id":"3","title":"Kind of Blue"},"highlights":[{"field":"text","snippet":"a <b> \ue000blue\ue001 note"}]},`+
|
||||
`{"document":{"id":"8","title":"Draft"},"highlights":[{"field":"text","snippet":"private \ue000blue\ue001 draft"}]}]}`)
|
||||
}))
|
||||
defer node.Close()
|
||||
u, _ := url.Parse(node.URL)
|
||||
port, _ := strconv.Atoi(u.Port())
|
||||
engine := typesense.New(typesense.Config{APIKey: "test-only-key", Host: u.Hostname(), Port: port, Protocol: "http"})
|
||||
|
||||
fmt.Println("highlights:", beachcomber.SupportsHighlights(engine))
|
||||
res, err := beachcomber.SearchPage(context.Background(), engine, "acme_blog_posts", beachcomber.Query{
|
||||
Q: "blue",
|
||||
QueryBy: []string{"title", "text"},
|
||||
ExcludeFields: []string{"text"},
|
||||
Highlight: &beachcomber.Highlight{Fields: []string{"text"}, AffixTokens: 3},
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
// Stands in for the SQL re-check: id 8 is not visible to the caller, so
|
||||
// its hit is dropped before any snippet is shown.
|
||||
allowed := map[string]bool{"3": true}
|
||||
for _, hit := range res.Hits {
|
||||
if !allowed[hit.ID] {
|
||||
continue
|
||||
}
|
||||
for _, sn := range hit.Highlights {
|
||||
var b strings.Builder
|
||||
for _, seg := range sn.Segments {
|
||||
text := html.EscapeString(seg.Text) // segments are plain text
|
||||
if seg.Match {
|
||||
text = "<mark>" + text + "</mark>"
|
||||
}
|
||||
b.WriteString(text)
|
||||
}
|
||||
fmt.Println(hit.ID, sn.Field, sn.ArrayIndex, b.String())
|
||||
}
|
||||
}
|
||||
// Output:
|
||||
// highlights: true
|
||||
// exclude_fields text highlight_fields text highlight_affix_num_tokens 3
|
||||
// private-use start tag: true
|
||||
// 3 text -1 a <b> <mark>blue</mark> note
|
||||
}
|
||||
|
||||
116
modules/beachcomber/typesense/highlights.go
Normal file
116
modules/beachcomber/typesense/highlights.go
Normal file
@@ -0,0 +1,116 @@
|
||||
package typesense
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/beachcomber"
|
||||
)
|
||||
|
||||
// highlightStartTag and highlightEndTag are two Unicode private-use
|
||||
// characters that do not occur in normal text. The engine sends them as
|
||||
// highlight_start_tag and highlight_end_tag, so a snippet can be split into
|
||||
// segments without parsing markup.
|
||||
const (
|
||||
highlightStartTag = ""
|
||||
highlightEndTag = ""
|
||||
)
|
||||
|
||||
// splitSegments splits a snippet tagged with highlightStartTag and
|
||||
// highlightEndTag into matched and unmatched runs. Empty runs are left out
|
||||
// and the text is never trimmed, escaped or unescaped.
|
||||
func splitSegments(s string) []beachcomber.Segment {
|
||||
var out []beachcomber.Segment
|
||||
match := false
|
||||
for s != "" {
|
||||
tag := highlightStartTag
|
||||
if match {
|
||||
tag = highlightEndTag
|
||||
}
|
||||
i := strings.Index(s, tag)
|
||||
if i < 0 {
|
||||
out = appendSegment(out, s, match)
|
||||
break
|
||||
}
|
||||
out = appendSegment(out, s[:i], match)
|
||||
s = s[i+len(tag):]
|
||||
match = !match
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func appendSegment(out []beachcomber.Segment, text string, match bool) []beachcomber.Segment {
|
||||
if text == "" {
|
||||
return out
|
||||
}
|
||||
return append(out, beachcomber.Segment{Text: text, Match: match})
|
||||
}
|
||||
|
||||
// tsHighlight is one entry of a hit's highlights array.
|
||||
// A string field carries snippet; a string[] field carries snippets with
|
||||
// the element index of each in indices.
|
||||
type tsHighlight struct {
|
||||
Field string `json:"field"`
|
||||
Snippet string `json:"snippet"`
|
||||
Snippets []string `json:"snippets"`
|
||||
Indices []int `json:"indices"`
|
||||
}
|
||||
|
||||
// parseHighlights turns a hit's highlights array into snippets, in the
|
||||
// order of the array. An entry that does not decode, or has no snippet,
|
||||
// contributes nothing; a value that is not an array gives nil.
|
||||
func parseHighlights(raw json.RawMessage, h *beachcomber.Highlight) []beachcomber.Snippet {
|
||||
if len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
var entries []json.RawMessage
|
||||
if err := json.Unmarshal(raw, &entries); err != nil {
|
||||
return nil
|
||||
}
|
||||
var out []beachcomber.Snippet
|
||||
for _, entry := range entries {
|
||||
var th tsHighlight
|
||||
if err := json.Unmarshal(entry, &th); err != nil {
|
||||
continue
|
||||
}
|
||||
if len(th.Snippets) > 0 {
|
||||
out = appendArraySnippets(out, th, h)
|
||||
continue
|
||||
}
|
||||
if th.Snippet == "" {
|
||||
continue
|
||||
}
|
||||
segs := splitSegments(th.Snippet)
|
||||
if len(segs) == 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, beachcomber.Snippet{Field: th.Field, ArrayIndex: -1, Segments: segs})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// appendArraySnippets appends one snippet per element of an array field's
|
||||
// snippets, with ArrayIndex taken from indices (-1 when indices does not
|
||||
// cover the element). Typesense 28.0 has no parameter that caps snippets
|
||||
// per array (checked 2026-11-08), so MaxSnippetsPerArray is applied here
|
||||
// and keeps the first snippets in Typesense's order. Elements with no
|
||||
// segments are left out and do not count toward the cap.
|
||||
func appendArraySnippets(out []beachcomber.Snippet, th tsHighlight, h *beachcomber.Highlight) []beachcomber.Snippet {
|
||||
kept := 0
|
||||
for i, sn := range th.Snippets {
|
||||
if h != nil && h.MaxSnippetsPerArray > 0 && kept >= h.MaxSnippetsPerArray {
|
||||
break
|
||||
}
|
||||
segs := splitSegments(sn)
|
||||
if len(segs) == 0 {
|
||||
continue
|
||||
}
|
||||
idx := -1
|
||||
if i < len(th.Indices) {
|
||||
idx = th.Indices[i]
|
||||
}
|
||||
out = append(out, beachcomber.Snippet{Field: th.Field, ArrayIndex: idx, Segments: segs})
|
||||
kept++
|
||||
}
|
||||
return out
|
||||
}
|
||||
276
modules/beachcomber/typesense/highlights_test.go
Normal file
276
modules/beachcomber/typesense/highlights_test.go
Normal file
@@ -0,0 +1,276 @@
|
||||
package typesense
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/beachcomber"
|
||||
)
|
||||
|
||||
const searchKey = "GET /ts/collections/posts/documents/search"
|
||||
|
||||
// onlyQuery returns the parsed query of the single call f received.
|
||||
func onlyQuery(t *testing.T, f *fakeTypesense) url.Values {
|
||||
t.Helper()
|
||||
calls := f.take()
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("calls = %d, want 1", len(calls))
|
||||
}
|
||||
params, err := url.ParseQuery(calls[0].rawQuery)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
func TestTypesenseSearchSendsNoNewParamsByDefault(t *testing.T) {
|
||||
f, e := newFake(t)
|
||||
f.answer(searchKey, 200, `{"found":2,"hits":[`+
|
||||
`{"document":{"id":"42"},"highlights":[{"field":"name","snippet":"a <mark>blue</mark> train","matched_tokens":["blue"]}]},`+
|
||||
`{"document":{"id":7}}]}`)
|
||||
q := beachcomber.Query{
|
||||
Q: "blue train",
|
||||
QueryBy: []string{"name", "artist_display", "notes"},
|
||||
QueryByWeights: []int{10, 10, 1},
|
||||
FilterBy: "collection_id:=5",
|
||||
SortBy: "created_at:desc",
|
||||
Page: 2,
|
||||
PerPage: 250,
|
||||
}
|
||||
res, err := e.SearchPage(t.Context(), "posts", q)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
params := onlyQuery(t, f)
|
||||
keys := make([]string, 0, len(params))
|
||||
for k := range params {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
want := []string{"filter_by", "page", "per_page", "q", "query_by", "query_by_weights", "sort_by"}
|
||||
if !reflect.DeepEqual(keys, want) {
|
||||
t.Fatalf("keys = %v, want %v", keys, want)
|
||||
}
|
||||
if res.Found != 2 || !reflect.DeepEqual(res.IDs, []string{"42", "7"}) {
|
||||
t.Fatalf("res = %+v", res)
|
||||
}
|
||||
wantHits := []beachcomber.Hit{{ID: "42"}, {ID: "7"}}
|
||||
if !reflect.DeepEqual(res.Hits, wantHits) {
|
||||
t.Fatalf("hits = %+v, want %+v (mark snippets must not be read)", res.Hits, wantHits)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTypesenseHighlightsStringSnippet(t *testing.T) {
|
||||
f, e := newFake(t)
|
||||
f.answer(searchKey, 200, `{"found":1,"hits":[{"document":{"id":"5"},"highlights":[{"field":"title","snippet":"a blue train","matched_tokens":["blue"]}]}]}`)
|
||||
res, err := e.SearchPage(t.Context(), "posts", beachcomber.Query{
|
||||
Q: "blue",
|
||||
QueryBy: []string{"title"},
|
||||
Highlight: &beachcomber.Highlight{Fields: []string{"title"}, AffixTokens: 6},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
params := onlyQuery(t, f)
|
||||
if params.Get("highlight_fields") != "title" || params.Get("highlight_affix_num_tokens") != "6" ||
|
||||
params.Get("highlight_start_tag") != "" || params.Get("highlight_end_tag") != "" {
|
||||
t.Fatalf("params = %v", params)
|
||||
}
|
||||
if params.Has("highlight_full_fields") {
|
||||
t.Fatal("highlight_full_fields must never be sent")
|
||||
}
|
||||
want := []beachcomber.Hit{{ID: "5", Highlights: []beachcomber.Snippet{{
|
||||
Field: "title",
|
||||
ArrayIndex: -1,
|
||||
Segments: []beachcomber.Segment{{Text: "a "}, {Text: "blue", Match: true}, {Text: " train"}},
|
||||
}}}}
|
||||
if !reflect.DeepEqual(res.Hits, want) {
|
||||
t.Fatalf("hits = %+v, want %+v", res.Hits, want)
|
||||
}
|
||||
|
||||
// A zero Highlight sends the two tags and nothing else new.
|
||||
if _, err := e.SearchPage(t.Context(), "posts", beachcomber.Query{Q: "blue", QueryBy: []string{"title"}, Highlight: &beachcomber.Highlight{}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
params = onlyQuery(t, f)
|
||||
for k := range params {
|
||||
switch k {
|
||||
case "q", "query_by", "highlight_start_tag", "highlight_end_tag":
|
||||
default:
|
||||
t.Fatalf("zero Highlight sent %s (query %v)", k, params)
|
||||
}
|
||||
}
|
||||
if !params.Has("highlight_start_tag") || !params.Has("highlight_end_tag") {
|
||||
t.Fatalf("zero Highlight must send the tags: %v", params)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTypesenseHighlightsKeepStoredText(t *testing.T) {
|
||||
for name, snippet := range map[string]string{
|
||||
"literal": `<b>bold</b> & blue <x`,
|
||||
"escaped": `<b>bold</b> & blue <x`,
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
f, e := newFake(t)
|
||||
f.answer(searchKey, 200, `{"found":1,"hits":[{"document":{"id":"1"},"highlights":[{"field":"body","snippet":"`+snippet+`"}]}]}`)
|
||||
res, err := e.SearchPage(t.Context(), "posts", beachcomber.Query{Q: "blue", QueryBy: []string{"body"}, Highlight: &beachcomber.Highlight{}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(res.Hits) != 1 || len(res.Hits[0].Highlights) != 1 {
|
||||
t.Fatalf("hits = %+v", res.Hits)
|
||||
}
|
||||
want := []beachcomber.Segment{{Text: "<b>bold</b> & "}, {Text: "blue", Match: true}, {Text: " <x"}}
|
||||
got := res.Hits[0].Highlights[0].Segments
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("segments = %+v, want %+v", got, want)
|
||||
}
|
||||
for _, s := range got {
|
||||
if strings.Contains(s.Text, "mark") {
|
||||
t.Fatalf("segment carries markup: %q", s.Text)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTypesenseHitsStayInStepWithIDs(t *testing.T) {
|
||||
f, e := newFake(t)
|
||||
f.answer(searchKey, 200, `{"found":5,"hits":[`+
|
||||
`{"document":{"id":"12"},"highlights":[{"field":"title","snippet":"x"},{"field":"notes","snippet":5}]},`+
|
||||
`{"document":{"id":null},"highlights":[{"field":"title","snippet":"y"}]},`+
|
||||
`{"document":{},"highlights":[{"field":"title","snippet":"z"}]},`+
|
||||
`{"document":{"id":7}},`+
|
||||
`{"document":{"id":"3"},"highlights":[]}]}`)
|
||||
res, err := e.SearchPage(t.Context(), "posts", beachcomber.Query{Q: "x", QueryBy: []string{"title"}, Highlight: &beachcomber.Highlight{}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(res.IDs, []string{"12", "7", "3"}) || len(res.Hits) != 3 {
|
||||
t.Fatalf("res = %+v", res)
|
||||
}
|
||||
for i, h := range res.Hits {
|
||||
if h.ID != res.IDs[i] {
|
||||
t.Fatalf("hits[%d].ID = %q, ids[%d] = %q", i, h.ID, i, res.IDs[i])
|
||||
}
|
||||
}
|
||||
if len(res.Hits[0].Highlights) != 1 || res.Hits[0].Highlights[0].Field != "title" {
|
||||
t.Fatalf("hits[0] = %+v (the undecodable notes entry is dropped alone)", res.Hits[0])
|
||||
}
|
||||
if len(res.Hits[1].Highlights) != 0 || len(res.Hits[2].Highlights) != 0 {
|
||||
t.Fatalf("hits = %+v", res.Hits)
|
||||
}
|
||||
|
||||
f.answer(searchKey, 200, `{"found":0,"hits":[]}`)
|
||||
res, err = e.SearchPage(t.Context(), "posts", beachcomber.Query{Q: "x", Highlight: &beachcomber.Highlight{}})
|
||||
if err != nil || res.Hits == nil || len(res.Hits) != 0 {
|
||||
t.Fatalf("empty answer = %+v %v", res, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTypesenseHighlightsStringArray(t *testing.T) {
|
||||
f, e := newFake(t)
|
||||
f.answer(searchKey, 200, `{"found":2,"hits":[`+
|
||||
`{"document":{"id":"1"},"highlights":[{"field":"tracks","snippets":["blue note","x blue"],"indices":[2,0],"matched_tokens":[["blue"],["blue"]]}]},`+
|
||||
`{"document":{"id":"2"},"highlights":[`+
|
||||
`{"field":"title","snippet":"blue train"},`+
|
||||
`{"field":"tags","snippets":["blue"]}]}]}`)
|
||||
q := beachcomber.Query{Q: "blue", QueryBy: []string{"title", "tracks", "tags"}, Highlight: &beachcomber.Highlight{}}
|
||||
res, err := e.SearchPage(t.Context(), "posts", q)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
params := onlyQuery(t, f)
|
||||
for k := range params {
|
||||
if strings.Contains(k, "snippet") || strings.Contains(k, "max") {
|
||||
t.Fatalf("a snippet-cap parameter was sent: %s", k)
|
||||
}
|
||||
}
|
||||
want := []beachcomber.Snippet{
|
||||
{Field: "tracks", ArrayIndex: 2, Segments: []beachcomber.Segment{{Text: "blue", Match: true}, {Text: " note"}}},
|
||||
{Field: "tracks", ArrayIndex: 0, Segments: []beachcomber.Segment{{Text: "x "}, {Text: "blue", Match: true}}},
|
||||
}
|
||||
if !reflect.DeepEqual(res.Hits[0].Highlights, want) {
|
||||
t.Fatalf("tracks = %+v, want %+v", res.Hits[0].Highlights, want)
|
||||
}
|
||||
second := res.Hits[1].Highlights
|
||||
if len(second) != 2 || second[0].Field != "title" || second[0].ArrayIndex != -1 ||
|
||||
second[1].Field != "tags" || second[1].ArrayIndex != -1 {
|
||||
t.Fatalf("mixed hit = %+v (order of the highlights array, -1 without indices)", second)
|
||||
}
|
||||
|
||||
q.Highlight.MaxSnippetsPerArray = 1
|
||||
res, err = e.SearchPage(t.Context(), "posts", q)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(res.Hits[0].Highlights, want[:1]) {
|
||||
t.Fatalf("capped = %+v, want %+v", res.Hits[0].Highlights, want[:1])
|
||||
}
|
||||
params = onlyQuery(t, f)
|
||||
for k := range params {
|
||||
if strings.Contains(k, "snippet") || strings.Contains(k, "max") {
|
||||
t.Fatalf("the cap was sent as %s; it is client-side", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTypesenseFieldProjection(t *testing.T) {
|
||||
f, e := newFake(t)
|
||||
f.answer(searchKey, 200, `{"found":1,"hits":[{"document":{"id":"9"},"highlights":[{"field":"body","snippet":"the blue one"}]}]}`)
|
||||
|
||||
for _, tc := range []struct {
|
||||
include []string
|
||||
want string
|
||||
}{
|
||||
{[]string{"title"}, "title,id"},
|
||||
{[]string{"id", "title"}, "id,title"},
|
||||
} {
|
||||
if _, err := e.SearchPage(t.Context(), "posts", beachcomber.Query{Q: "x", IncludeFields: tc.include}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := onlyQuery(t, f).Get("include_fields"); got != tc.want {
|
||||
t.Fatalf("include_fields for %v = %q, want %q", tc.include, got, tc.want)
|
||||
}
|
||||
}
|
||||
|
||||
res, err := e.SearchPage(t.Context(), "posts", beachcomber.Query{
|
||||
Q: "blue", QueryBy: []string{"body"}, ExcludeFields: []string{"body"},
|
||||
Highlight: &beachcomber.Highlight{Fields: []string{"body"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
params := onlyQuery(t, f)
|
||||
if params.Get("exclude_fields") != "body" || params.Get("highlight_fields") != "body" || params.Has("include_fields") {
|
||||
t.Fatalf("params = %v", params)
|
||||
}
|
||||
if len(res.Hits) != 1 || len(res.Hits[0].Highlights) != 1 || res.Hits[0].Highlights[0].Field != "body" {
|
||||
t.Fatalf("an excluded field must still be highlighted: %+v", res.Hits)
|
||||
}
|
||||
|
||||
for name, q := range map[string]beachcomber.Query{
|
||||
"exclude_fields": {ExcludeFields: []string{"body", "id"}},
|
||||
"AffixTokens": {Highlight: &beachcomber.Highlight{AffixTokens: -1}},
|
||||
"MaxSnippetsPerArray": {Highlight: &beachcomber.Highlight{MaxSnippetsPerArray: -1}},
|
||||
} {
|
||||
_, err := e.SearchPage(t.Context(), "posts", q)
|
||||
if err == nil || !strings.Contains(err.Error(), name) {
|
||||
t.Fatalf("%s: err = %v", name, err)
|
||||
}
|
||||
}
|
||||
if calls := f.take(); len(calls) != 0 {
|
||||
t.Fatalf("a rejected query sent %d requests", len(calls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTypesenseSupportsHighlights(t *testing.T) {
|
||||
_, e := newFake(t)
|
||||
if !beachcomber.SupportsHighlights(e) {
|
||||
t.Fatal("the Typesense engine supports highlights")
|
||||
}
|
||||
var _ beachcomber.HighlightSearcher = e
|
||||
}
|
||||
Reference in New Issue
Block a user