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