- docs/backend: admin controllers, forms, lists and filters, relation manager, users and permissions, settings, partials and widgets, admin SPA - docs/services: storage, outbound HTTP, realtime, Web Push, search, parity testing and the Frontend and AJAX (not provided) page - Examples for cabana (with testdata/docs YAML), fetchguard, lighthouse and its centrifugo driver, flare, beachcomber and typesense, tide; lighthouse and beachcomber TestDocs* regions run on their Postgres harnesses - concept map rows link their guide pages and the not-provided rows the Frontend and AJAX page; index lists Backend, Database and Services - TestDocsRequiredPages asserts the D-08 section order
184 lines
8.4 KiB
Markdown
184 lines
8.4 KiB
Markdown
---
|
|
title: Search
|
|
description: Keep models in a search index with beachcomber, synced after commit behind a kill-switch, and re-check the candidate IDs a search returns in SQL.
|
|
section: services
|
|
order: 140
|
|
---
|
|
# Search
|
|
|
|
[beachcomber](../../modules/beachcomber/README.md) is the SummerCMS counterpart of Laravel Scout, as WinterCMS applications use it without a queue. A model opts in by implementing `beachcomber.Searchable`; after a write of such a model commits, its row is reloaded and its document written to, or removed from, the search index. A search asks the index for matching IDs, and the application loads the rows from the database.
|
|
|
|
## Engines
|
|
|
|
`search.driver` selects the engine. The default, `null`, indexes nothing and finds nothing. The `typesense` engine, from the `beachcomber/typesense` package, talks to a Typesense server over its HTTP API. An engine registers itself from its package's `init` function, so the application imports the engine package for its side effect; an unknown name stops the start-up. `search.prefix` is prepended to every index name, for example to keep staging and production apart on one server:
|
|
|
|
```go src=modules/beachcomber/example_test.go#ExampleFrom
|
|
cfg, err := compass.Open(compass.Options{Dir: "config", Env: "development", Environ: []string{}})
|
|
if err != nil {
|
|
fmt.Println(err)
|
|
return
|
|
}
|
|
_ = cfg.Set("search.prefix", "staging_")
|
|
svc, err := beachcomber.From(backpack.New(cfg)) // search.driver defaults to null
|
|
if err != nil {
|
|
fmt.Println(err)
|
|
return
|
|
}
|
|
fmt.Println(svc.Engine().Name(), svc.Engine().Configured(), svc.IndexName(&Post{}))
|
|
ids, err := svc.Engine().SearchIDs(context.Background(), svc.IndexName(&Post{}), beachcomber.Query{Q: "go"})
|
|
fmt.Println(ids, err)
|
|
|
|
_ = cfg.Set("search.driver", "elastic")
|
|
_, err = beachcomber.From(backpack.New(cfg))
|
|
fmt.Println(err != nil)
|
|
// Output:
|
|
// null false staging_acme_blog_posts
|
|
// [] <nil>
|
|
// true
|
|
```
|
|
|
|
An engine implements `beachcomber.Engine` and registers with `beachcomber.RegisterEngine`. The examples on this page use a small in-memory engine that matches titles:
|
|
|
|
```go src=modules/beachcomber/example_test.go#memoryEngine.SearchIDs
|
|
func (e *memoryEngine) SearchIDs(ctx context.Context, index string, q beachcomber.Query) ([]string, error) {
|
|
e.mu.Lock()
|
|
defer e.mu.Unlock()
|
|
ids := []string{}
|
|
for id, d := range e.docs[index] {
|
|
if strings.Contains(strings.ToLower(fmt.Sprint(d["title"])), strings.ToLower(q.Q)) {
|
|
ids = append(ids, id)
|
|
}
|
|
}
|
|
slices.Sort(ids)
|
|
return ids, nil
|
|
}
|
|
```
|
|
|
|
## Searchable models
|
|
|
|
A model implements `beachcomber.Searchable` with three methods, and needs no import of beachcomber to do so:
|
|
|
|
```go src=modules/beachcomber/example_test.go#Post.SearchableAs
|
|
// SearchableAs is the index name, before search.prefix.
|
|
func (Post) SearchableAs() string { return "acme_blog_posts" }
|
|
```
|
|
|
|
```go src=modules/beachcomber/example_test.go#Post.ShouldBeSearchable
|
|
// ShouldBeSearchable keeps drafts out of the index.
|
|
func (p *Post) ShouldBeSearchable() bool { return p.Published }
|
|
```
|
|
|
|
```go src=modules/beachcomber/example_test.go#Post.ToSearchableArray
|
|
// ToSearchableArray builds the document from the committed row.
|
|
func (p *Post) ToSearchableArray(ctx context.Context, db *gorm.DB) (map[string]any, error) {
|
|
return map[string]any{
|
|
"id": strconv.FormatUint(uint64(p.ID), 10),
|
|
"blog_id": int64(p.BlogID),
|
|
"title": p.Title,
|
|
}, nil
|
|
}
|
|
```
|
|
|
|
`ToSearchableArray` runs after the commit on a fresh copy of the row, so it may query related rows. A row whose `ShouldBeSearchable` is false, a soft-deleted row and a deleted row have their documents removed. A model can also implement `beachcomber.IndexSchemaProvider`, the schema the engine creates a missing index with, and `beachcomber.SearchKeyer`, a document key other than the primary key.
|
|
|
|
## Sync after commit
|
|
|
|
`beachcomber.From` installs GORM callbacks that register the sync with `lagoon.AfterCommit`:
|
|
|
|
- Inside `lagoon.Transaction`, the sync runs after the commit, and never after a rollback.
|
|
- A single-statement write syncs after GORM commits it.
|
|
- Inside a plain GORM transaction the sync is skipped with a warning, because the commit cannot be observed. Wrap such writes in `lagoon.Transaction`, or call `beachcomber.Service.Sync` after the commit.
|
|
|
|
The sync runs inline in the writing goroutine, so a search right after a save finds the document, and it is bounded by the engine's timeout. It is never fatal: a failure is logged as `search: sync failed` with the index, key and operation, never the document or the API key, and the write stays committed. A write without a primary key value, such as `Model(&Post{}).Where(...).Updates(...)`, cannot be synced row by row; bulk paths call `beachcomber.Service.Sync` and `beachcomber.Service.Remove` per row, or reindex.
|
|
|
|
## The kill-switch
|
|
|
|
Nothing is sent when the engine is not configured (the `null` engine, or Typesense without an API key), when no database is published, or when the application's `beachcomber.Gate` reports off. Install the gate from a plugin's `Boot`. A gate must treat a read error as off:
|
|
|
|
```go src=modules/beachcomber/example_test.go#gate
|
|
svc.SetGate(beachcomber.GateFunc(func(ctx context.Context, db *gorm.DB) bool {
|
|
var enabled bool
|
|
err := db.WithContext(ctx).Raw(`SELECT search_enabled FROM acme_blog_settings WHERE id = 1`).Scan(&enabled).Error
|
|
return err == nil && enabled
|
|
}))
|
|
```
|
|
|
|
## Searching
|
|
|
|
`beachcomber.Engine.SearchIDs` returns the IDs of matching documents, in the engine's order. They are candidates, not answers: the index can be stale (a write it missed, a document from before a permission change) and its filters are only as good as the document. Re-check every ID in SQL, with the same ownership, visibility and soft-delete conditions the rest of the API applies, before a row reaches a response:
|
|
|
|
```go src=modules/beachcomber/example_test.go#search
|
|
ids, err := svc.Engine().SearchIDs(ctx, svc.IndexName(&Post{}), beachcomber.Query{
|
|
Q: term,
|
|
QueryBy: []string{"title"},
|
|
FilterBy: "blog_id:=" + strconv.FormatUint(uint64(blogID), 10),
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// The ids are candidates from an index that may be stale or loosely
|
|
// filtered: re-check every one in SQL before exposing a row.
|
|
var posts []Post
|
|
err = db.WithContext(ctx).
|
|
Where("id IN ? AND blog_id = ? AND published", ids, blogID).
|
|
Order("id").
|
|
Find(&posts).Error
|
|
return posts, err
|
|
```
|
|
|
|
An empty result is an empty list, never an error.
|
|
|
|
> [!WARNING]
|
|
> Never return rows, or even counts, straight from search IDs. A stale index would otherwise show a draft, a deleted record or another user's data.
|
|
|
|
## Typesense
|
|
|
|
The Typesense engine follows the Scout Typesense wire contract, so indexes built by a WinterCMS application can be searched by the port:
|
|
|
|
```yaml
|
|
driver: typesense
|
|
typesense:
|
|
host: 127.0.0.1
|
|
port: 8108
|
|
protocol: http
|
|
```
|
|
|
|
in `config/search.yaml`, with the key in `SUMMER_SEARCH__TYPESENSE__API_KEY`. Without an API key nothing is ever sent. A search is one request to the collection's search endpoint, and the engine returns the hit IDs in Typesense's order:
|
|
|
|
```go src=modules/beachcomber/typesense/example_test.go#ExampleEngine_SearchIDs
|
|
// A stand-in Typesense node.
|
|
node := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
q := r.URL.Query()
|
|
fmt.Println(r.Method, r.URL.Path, "key sent:", r.Header.Get("X-TYPESENSE-API-KEY") != "")
|
|
fmt.Println("q", q.Get("q"), "query_by", q.Get("query_by"), "filter_by", q.Get("filter_by"))
|
|
fmt.Fprint(w, `{"hits":[{"document":{"id":"3"}},{"document":{"id":"1"}}]}`)
|
|
}))
|
|
defer node.Close()
|
|
u, _ := url.Parse(node.URL)
|
|
port, _ := strconv.Atoi(u.Port())
|
|
|
|
engine := typesense.New(typesense.Config{
|
|
APIKey: "test-only-key", // SUMMER_SEARCH__TYPESENSE__API_KEY
|
|
Host: u.Hostname(),
|
|
Port: port,
|
|
Protocol: "http",
|
|
ConnectionTimeout: 2 * time.Second,
|
|
})
|
|
ids, err := engine.SearchIDs(context.Background(), "acme_blog_posts", beachcomber.Query{
|
|
Q: "go",
|
|
QueryBy: []string{"title"},
|
|
FilterBy: "blog_id:=7",
|
|
})
|
|
fmt.Println(ids, err)
|
|
|
|
// Without an API key the engine is not configured and nothing is sent.
|
|
fmt.Println(typesense.New(typesense.Config{}).Configured())
|
|
// Output:
|
|
// GET /collections/acme_blog_posts/documents/search key sent: true
|
|
// q go query_by title filter_by blog_id:=7
|
|
// [3 1] <nil>
|
|
// false
|
|
```
|
|
|
|
Each request times out after `search.typesense.connection_timeout_seconds` (2 seconds by default). A failed answer is a `typesense.StatusError` with the method, path and status, never the answer body.
|