- 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).
253 lines
9.3 KiB
Go
253 lines
9.3 KiB
Go
package beachcomber
|
|
|
|
import (
|
|
"context"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// Searchable is implemented (on the pointer receiver or the value) by a
|
|
// model whose rows are kept in a search index. The GORM callbacks sync a
|
|
// Searchable model after every create, update and delete.
|
|
type Searchable interface {
|
|
// SearchableAs is the index name without the search.prefix.
|
|
SearchableAs() string
|
|
// ToSearchableArray builds the document of the row. It runs after the
|
|
// write committed, on a fresh copy reloaded by primary key, and may
|
|
// query related rows through db. An error means nothing is indexed.
|
|
ToSearchableArray(ctx context.Context, db *gorm.DB) (map[string]any, error)
|
|
// ShouldBeSearchable reports whether the row belongs in the index. A
|
|
// row that should not be searchable has its document removed.
|
|
ShouldBeSearchable() bool
|
|
}
|
|
|
|
// IndexSchemaProvider supplies the schema an engine creates the index with
|
|
// when the index does not exist yet. For Typesense it is the collection
|
|
// schema (fields, default_sorting_field); the engine adds the name.
|
|
type IndexSchemaProvider interface {
|
|
SearchIndexSchema() map[string]any
|
|
}
|
|
|
|
// SearchKeyer replaces the document key, which defaults to the decimal
|
|
// primary key. It is called on the written model, which may hold only its
|
|
// primary key, so it must derive the key from the primary key.
|
|
type SearchKeyer interface {
|
|
SearchKey() string
|
|
}
|
|
|
|
// Engine is a search index driver.
|
|
type Engine interface {
|
|
// Name is the search.driver value that selects the engine.
|
|
Name() string
|
|
// Configured reports whether the engine may send anything. When it is
|
|
// false, sync is skipped without a request.
|
|
Configured() bool
|
|
// Upsert inserts or replaces docs in index, creating the index from
|
|
// schema first when it does not exist.
|
|
Upsert(ctx context.Context, index string, schema map[string]any, docs []map[string]any) error
|
|
// Delete removes the documents with ids from index. A document that
|
|
// is already gone is not an error.
|
|
Delete(ctx context.Context, index string, ids []string) error
|
|
// Flush drops the whole index. A missing index is not an error.
|
|
Flush(ctx context.Context, index string) error
|
|
// SearchIDs returns the ids of the documents matching q, in the
|
|
// engine's order. They are candidates only: callers must re-check
|
|
// every id against the database before exposing it.
|
|
SearchIDs(ctx context.Context, index string, q Query) ([]string, error)
|
|
}
|
|
|
|
// Query is an engine search request.
|
|
type Query struct {
|
|
// Q is the query text; empty means match everything ("*").
|
|
Q string
|
|
// QueryBy lists the fields Q is matched against.
|
|
QueryBy []string
|
|
// QueryByWeights ranks the QueryBy fields, one weight per field in the
|
|
// same order (Typesense query_by_weights). Empty leaves the engine
|
|
// default; a length different from QueryBy is an error.
|
|
QueryByWeights []int
|
|
// FilterBy is an engine filter expression, for example
|
|
// "collection_id:=5".
|
|
FilterBy string
|
|
// SortBy is an engine sort expression, for example "created_at:desc".
|
|
SortBy string
|
|
// Page is 1-based; 0 leaves the engine default.
|
|
Page int
|
|
// PerPage is the page size; 0 leaves the engine default.
|
|
PerPage int
|
|
// IncludeFields and ExcludeFields only limit which document fields the
|
|
// engine sends back: a field left out of the answer stays searchable
|
|
// and highlightable. Empty keeps the engine default (every stored
|
|
// field). The document key is always returned, because the ids are the
|
|
// result. Engines that cannot project ignore them.
|
|
IncludeFields []string
|
|
ExcludeFields []string
|
|
// Highlight asks for snippets of the matches in SearchResult.Hits. Nil
|
|
// asks for no highlights and keeps the request as it was. Engines
|
|
// without HighlightSearcher ignore it.
|
|
Highlight *Highlight
|
|
}
|
|
|
|
// Highlight configures the snippets an engine returns for a Query.
|
|
// Snippets are always cut around the matches; there is deliberately no
|
|
// option to highlight a whole field.
|
|
type Highlight struct {
|
|
// Fields are the fields to highlight. Empty is the engine default,
|
|
// which is the QueryBy fields for Typesense.
|
|
Fields []string
|
|
// AffixTokens is the number of tokens of context on each side of a
|
|
// match; 0 is the engine default.
|
|
AffixTokens int
|
|
// MaxSnippetsPerArray is the most snippets kept for one array field;
|
|
// 0 means no cap.
|
|
MaxSnippetsPerArray int
|
|
}
|
|
|
|
// SearchResult is one page of candidate ids and the number of documents
|
|
// the engine found for the query. Both are candidates only: callers re-gate
|
|
// the ids, and recount any total they expose, in SQL.
|
|
type SearchResult struct {
|
|
IDs []string
|
|
Found int
|
|
// Hits has one entry per id, in the same order as IDs
|
|
// (Hits[i].ID == IDs[i]). An engine called directly may leave it nil;
|
|
// SearchPage always fills it. Like the ids, hits are candidates: drop
|
|
// the Hit of every id the SQL re-check rejects before showing a
|
|
// snippet.
|
|
Hits []Hit
|
|
}
|
|
|
|
// Hit is the engine's answer for one id beyond the id itself.
|
|
type Hit struct {
|
|
ID string
|
|
// Highlights is nil when the hit has none.
|
|
Highlights []Snippet
|
|
}
|
|
|
|
// Snippet is one highlighted excerpt of a field.
|
|
type Snippet struct {
|
|
Field string
|
|
// ArrayIndex is the element index for an array field, and -1 for a
|
|
// non-array field or when the engine did not say which element.
|
|
ArrayIndex int
|
|
Segments []Segment
|
|
}
|
|
|
|
// Segment is a run of a snippet that either matched the query or not.
|
|
// Text is plain stored text, verbatim, never HTML or markup. It can
|
|
// contain < or &, so the caller escapes it when rendering.
|
|
type Segment struct {
|
|
Text string
|
|
Match bool
|
|
}
|
|
|
|
// PageSearcher is implemented by an engine that can report how many
|
|
// documents matched besides the page of ids. It is optional so that
|
|
// existing Engine implementations stay valid; use SearchPage to call it.
|
|
type PageSearcher interface {
|
|
SearchPage(ctx context.Context, index string, q Query) (SearchResult, error)
|
|
}
|
|
|
|
// HighlightSearcher is implemented by an engine that can return snippets
|
|
// of the matches. It is optional so that existing engines stay valid. An
|
|
// engine that implements it and answers true fills Hit.Highlights for a
|
|
// Query with Highlight; ask through SupportsHighlights.
|
|
type HighlightSearcher interface {
|
|
PageSearcher
|
|
SupportsHighlights() bool
|
|
}
|
|
|
|
// SupportsHighlights reports whether e fills Hit.Highlights: true only when
|
|
// e implements HighlightSearcher and its SupportsHighlights answers true. A
|
|
// nil e is false. When it is false, an app builds its own snippets from the
|
|
// rows it loads.
|
|
func SupportsHighlights(e Engine) bool {
|
|
hs, ok := e.(HighlightSearcher)
|
|
return ok && hs.SupportsHighlights()
|
|
}
|
|
|
|
// SearchPage asks e for one page of q: through PageSearcher when e
|
|
// implements it, otherwise through SearchIDs with Found set to the number
|
|
// of ids returned. On success Hits is always in step with IDs: when the
|
|
// engine left it nil, it is one Hit without highlights per id (an empty,
|
|
// non-nil slice for zero ids); hits the engine filled are kept as they are.
|
|
func SearchPage(ctx context.Context, e Engine, index string, q Query) (SearchResult, error) {
|
|
var res SearchResult
|
|
if ps, ok := e.(PageSearcher); ok {
|
|
r, err := ps.SearchPage(ctx, index, q)
|
|
if err != nil {
|
|
return r, err
|
|
}
|
|
res = r
|
|
} else {
|
|
ids, err := e.SearchIDs(ctx, index, q)
|
|
if err != nil {
|
|
return SearchResult{}, err
|
|
}
|
|
res = SearchResult{IDs: ids, Found: len(ids)}
|
|
}
|
|
if res.Hits == nil {
|
|
res.Hits = make([]Hit, len(res.IDs))
|
|
for i, id := range res.IDs {
|
|
res.Hits[i] = Hit{ID: id}
|
|
}
|
|
}
|
|
return res, nil
|
|
}
|
|
|
|
// IndexDropper is implemented by an engine that can tell whether the index
|
|
// it dropped existed. It is optional; use DropIndex to call it.
|
|
type IndexDropper interface {
|
|
DropIndex(ctx context.Context, index string) (existed bool, err error)
|
|
}
|
|
|
|
// DropIndex drops index on e and reports whether it existed: through
|
|
// IndexDropper when e implements it, otherwise through Flush, in which case
|
|
// it reports existed true because Flush cannot tell.
|
|
func DropIndex(ctx context.Context, e Engine, index string) (bool, error) {
|
|
if d, ok := e.(IndexDropper); ok {
|
|
return d.DropIndex(ctx, index)
|
|
}
|
|
if err := e.Flush(ctx, index); err != nil {
|
|
return false, err
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
// IndexEnsurer is implemented by an engine that can create an empty index
|
|
// from its schema. It is optional; use EnsureIndex to call it.
|
|
type IndexEnsurer interface {
|
|
EnsureIndex(ctx context.Context, index string, schema map[string]any) error
|
|
}
|
|
|
|
// EnsureIndex creates index on e from schema when it does not exist yet,
|
|
// through IndexEnsurer when e implements it; for other engines it does
|
|
// nothing. A full reindex calls it first so an empty source table still
|
|
// leaves an (empty) index behind, because Upsert of zero documents creates
|
|
// nothing.
|
|
func EnsureIndex(ctx context.Context, e Engine, index string, schema map[string]any) error {
|
|
if en, ok := e.(IndexEnsurer); ok {
|
|
return en.EnsureIndex(ctx, index, schema)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Gate is the application kill-switch consulted before every sync. It
|
|
// reads its setting through db, a clean session on the write's connection
|
|
// (inside a caller's plain transaction, a savepoint of it); any error must
|
|
// count as off.
|
|
type Gate interface {
|
|
Enabled(ctx context.Context, db *gorm.DB) bool
|
|
}
|
|
|
|
// GateFunc adapts a function to Gate.
|
|
type GateFunc func(ctx context.Context, db *gorm.DB) bool
|
|
|
|
// Enabled calls f.
|
|
func (f GateFunc) Enabled(ctx context.Context, db *gorm.DB) bool {
|
|
if f == nil {
|
|
return false
|
|
}
|
|
return f(ctx, db)
|
|
}
|