Documentation
¶
Overview ¶
Package postgressearch provides a PostgreSQL search.Driver built on full-text search (tsvector, tsquery, and a GIN index) over two tables that the application creates with its migrator.
Setup ¶
The driver never runs DDL. Driver.Migrations returns the migrations of both tables; add them to the application's migrator, run them, then use the driver:
driver, err := postgressearch.NewFrom(db)
if err != nil {
return err
}
migrator := migrate.New(db)
err = migrator.Add(driver.Migrations()...) // with the application's own
// run migrator.Up in the release command, then:
engine := search.New(driver, func(engine *search.Builder) {
engine.Indexes(Products)
})
err = engine.Sync(ctx) // DML only: it records index definitions
The tables are search_documents (one row per document: index name, ID, the JSON source, and the tsvector) and search_indexes (one row per index: its stored definition). With Builder.Instance the names are prefixed "<instance>_", so several independent search backends share one database. Table names are not configurable, and renaming an instance is unsupported. Instance names may start with a digit. Both migration directions quote SQL identifiers, and readiness resolves their literal names, without changing existing physical names, migration groups, or advisory-lock identities. Start and Ping never create DDL.
How text and filters work ¶
Text uses the built-in "simple" configuration: no stemming, no stop words. Before indexing, every character that is not a letter or digit becomes a space, so the words match the contract's rules (red-shoes is two words). The searchable fields of a document are concatenated with a gap, so a phrase never spans two fields. Queries are built from bound parameters: each word a separate tsquery joined with &&, phrases with phraseto_tsquery, the prefix word with a :* match, and exclusions with !!. Relevance is ts_rank.
Filters and orders read the stored JSON source directly (lower(source ->> 'field') and typed casts), so they are not served by an index: they are fine for small and medium indexes and scan the rows the text query selects. Strings compare case-insensitively and byte-wise (COLLATE "C"), numbers as float8, and documents with a missing, null, or differently typed value match no comparison and sort last.
Deploying and caveats ¶
Driver.Sync runs under a transaction-scoped advisory lock and changes only data: it upserts index definitions and, when an index's searchable fields changed, recomputes tsv for every document of the index, which can be slow on large indexes. Without an enclosing database transaction, writes commit before the call returns. Inside an enclosing transaction, other transactions see them only after that transaction commits. The driver does not own the *database.Database it wraps: Driver.Stop does nothing, and the application stops the database driver itself.
Construction policies ¶
NewFrom applies Builder policies directly once in order before validation. Nil entries panic; nil or empty slice expansions omit edits. Retained drafts cannot change the published instance, tables, or migration group. The application keeps ownership of the database and remains the only DDL owner.
Index ¶
- Variables
- type Builder
- type BuilderFunc
- type Driver
- func (driver *Driver) Check(ctx context.Context, schemas []search.Schema) error
- func (driver *Driver) Database() *database.Database
- func (driver *Driver) Delete(ctx context.Context, schema search.Schema, ids []string) error
- func (driver *Driver) Drop(ctx context.Context, schema search.Schema) error
- func (driver *Driver) Migrations() []migrate.Definition
- func (driver *Driver) Ping(ctx context.Context) error
- func (driver *Driver) Put(ctx context.Context, schema search.Schema, docs []search.Document) error
- func (driver *Driver) Search(ctx context.Context, schema search.Schema, spec search.Spec, limit, offset int) (search.Matches, error)
- func (driver *Driver) Start(ctx context.Context) error
- func (driver *Driver) Stop(context.Context) error
- func (driver *Driver) Sync(ctx context.Context, schemas []search.Schema) error
Constants ¶
This section is empty.
Variables ¶
var ErrInvalidConfig = errors.New("invalid PostgreSQL search configuration")
ErrInvalidConfig indicates that the arguments or instance setting given to NewFrom cannot establish a driver: no database, a database that is not PostgreSQL, or an invalid instance name.
Example:
_, err := postgressearch.NewFrom(nil)
if errors.Is(err, postgressearch.ErrInvalidConfig) {
return err // Supply the application's PostgreSQL database.
}
Functions ¶
This section is empty.
Types ¶
type Builder ¶
type Builder struct {
// contains filtered or unexported fields
}
Builder chooses the PostgreSQL search instance during NewFrom. The application owns the database and executes Migrations; construction never runs DDL. Callbacks run directly once in order, even on invalid inputs. Nil entries panic; expanded nil or empty slices omit edits. Retained drafts are detached.
Example:
driver, err := postgressearch.NewFrom(db, func(engine *postgressearch.Builder) {
engine.Instance("catalog")
})
if err != nil {
return err
}
return migrator.Add(driver.Migrations()...)
func (*Builder) Instance ¶
Instance replaces the instance identity and returns this draft. Empty selects default tables; otherwise NewFrom requires at most 40 bytes of [a-z0-9_]. Renaming an existing instance is unsupported because it changes table names. Leading digits are valid: generated SQL identifiers are quoted, while physical names, migration groups, and advisory-lock identities retain their literal bytes.
Example:
driver, err := postgressearch.NewFrom(db, func(engine *postgressearch.Builder) {
engine.Instance("catalog")
})
if err != nil {
return err
}
return migrator.Add(driver.Migrations()...)
type BuilderFunc ¶
type BuilderFunc = func(*Builder)
BuilderFunc edits one NewFrom construction draft. Policies run synchronously once in order, including on invalid inputs; nil entries panic. The published instance is detached from retained drafts and the database remains borrowed.
Example:
var configure postgressearch.BuilderFunc = func(engine *postgressearch.Builder) {
engine.Instance("2026_catalog")
}
driver, err := postgressearch.NewFrom(db, configure)
if err != nil {
return err
}
_ = driver.Migrations()
type Driver ¶
type Driver struct {
// contains filtered or unexported fields
}
Driver implements search.Driver on PostgreSQL full-text search. Create it with NewFrom. It is safe for concurrent use and borrows the database facade; the application owns its pool and executes Migrations before startup.
Example:
driver, err := postgressearch.NewFrom(db)
if err != nil {
return err
}
return driver.Start(ctx) // After application-owned migrations have run.
func NewFrom ¶
func NewFrom(db *database.Database, configure ...BuilderFunc) (*Driver, error)
NewFrom creates a driver that stores documents through db, which must be backed by PostgreSQL (its dialect is query.Postgres). It does no I/O: the tables are created by the migrations of Driver.Migrations, which the application runs, and Driver.Start or Driver.Ping verify them.
Only Builder.Instance applies. The application owns db, so Driver.Stop does not close anything. A nil db, another dialect, or an invalid instance name returns an error wrapping ErrInvalidConfig. Policies run once in order on one fresh draft before validation; nil entries panic, expanded nil/empty slices omit edits, and retained drafts are detached. Generated SQL identifiers are quoted, so digit-leading instance names retain the same table, index, advisory-lock, and migration identities as other names.
Example:
driver, err := postgressearch.NewFrom(db, func(engine *postgressearch.Builder) {
engine.Instance("catalog")
})
if err != nil {
return err
}
engine := search.New(driver, func(engine *search.Builder) {
engine.Indexes(Products)
})
func (*Driver) Check ¶
Check compares the stored definitions with schemas without changing anything. It fails with search.ErrNotFound for an index without a definition row and with search.ErrSchemaChanged for a different one. Extra stored indexes are ignored. Run Sync explicitly to apply differences; Check never writes definitions or creates schema objects.
Example:
if err := driver.Check(ctx, []search.Schema{Products.Schema()}); err != nil {
return err
}
func (*Driver) Database ¶
Database returns the database the driver reads and writes through, the one passed to NewFrom. It remains application-owned; Stop never closes it. Calls through this facade retain its normal context routing and telemetry policies.
Example:
driver, err := postgressearch.NewFrom(db)
if err != nil {
return err
}
_ = driver.Database().Dialect()
func (*Driver) Delete ¶
Delete removes the documents with ids; missing IDs succeed. The logical index must exist even for an empty ID list, otherwise search.ErrNotFound is returned. Names and IDs follow the already-validated search.Driver contract.
Example:
if err := driver.Delete(ctx, Products.Schema(), []string{"p1"}); err != nil {
return err
}
func (*Driver) Drop ¶
Drop removes the definition row first, which waits for writers holding its share lock and stops later ones, then the documents, in one transaction. Dropping a missing index succeeds. This permanently deletes indexed documents, not the application's source records or the shared tables. A later Sync recreates the logical index empty.
Example:
if err := driver.Drop(ctx, Products.Schema()); err != nil {
return err
}
func (*Driver) Migrations ¶
func (driver *Driver) Migrations() []migrate.Definition
Migrations returns the migrations that create the driver's two tables: search_documents (with a GIN index on its tsvector) and search_indexes, each prefixed "<instance>_" when Builder.Instance is set. They belong to the group "arkoren.dev:driver/postgres:postgressearch", plus ":<instance>". The application adds them to its migrator, ahead of any migration of its own that needs them; the driver never creates tables itself. Each migration creates one table strictly, so a leftover table fails loudly, and its down step drops the table with its documents. Both migration directions quote generated table and index names without changing their identities, including when an instance starts with a digit. Definitions only describe DDL; constructing or reading them never runs it.
Example:
if err := migrator.Add(driver.Migrations()...); err != nil {
return err
}
func (*Driver) Ping ¶
Ping verifies the connection and that both tables exist, read-only. When a table is missing it returns an error wrapping search.ErrNotFound that names Driver.Migrations. Use it as a readiness check. It resolves table names as single quoted identifiers, including numeric-prefix instances, and preserves database execution/cancellation errors in the chain.
Example:
if err := driver.Ping(ctx); err != nil {
return err
}
func (*Driver) Put ¶
Put upserts docs in chunks of [putChunkSize], each chunk one statement that writes only while the index's definition row exists, and holds a share lock on that row so a concurrent Drop waits for it. A chunk that writes nothing means the index is missing and fails with search.ErrNotFound. The tsvector comes from the searchable fields of schema. IDs must already be valid and unique within docs. Large batches are not atomic across chunks; repeating an upsert batch after a failure is safe.
Example:
err := driver.Put(ctx, Products.Schema(), []search.Document{
{ID: "p1", Source: []byte(`{"title":"Red shoes"}`)},
})
if err != nil {
return err
}
func (*Driver) Search ¶
func (driver *Driver) Search(ctx context.Context, schema search.Schema, spec search.Spec, limit, offset int) (search.Matches, error)
Search answers spec with one SELECT for the window and one capped count for the total. The order is the query's orders, then ts_rank for text queries, then the ID in byte order. See the package documentation for how text and filters compile. limit must be positive and offset+limit must not exceed search.MaxHits. The supplied spec is already validated; returned document bytes are independent. Window and count use separate reads, not a shared snapshot of concurrent writes.
Example:
matches, err := driver.Search(ctx, Products.Schema(), search.Spec{}, 20, 0)
if err != nil {
return err
}
_ = matches.Items
func (*Driver) Start ¶
Start verifies the connection and that both tables exist, so a deployment that skipped the migrations fails fast. It is Driver.Ping. Startup neither synchronizes logical indexes nor creates missing tables.
Example:
if err := driver.Start(ctx); err != nil {
return err
}
func (*Driver) Stop ¶
Stop does nothing and returns nil: the application owns the database and stops its driver itself. It ignores ctx and waits for no background work; finish application operations before stopping the database owner.
Example:
if err := driver.Stop(ctx); err != nil {
return err
}
func (*Driver) Sync ¶
Sync records the definition of every schema and, for an index whose searchable fields changed, recomputes the tsvector of its documents. It runs in one transaction under an advisory lock named after the documents table, so concurrent deploys queue instead of interleaving, and it changes data only. Recomputing is a full pass over the index and can be slow. Schema input follows search.Driver's validation contract; the supplied facade keeps ownership of its connection and transaction scopes.
Example:
if err := driver.Sync(ctx, []search.Schema{Products.Schema()}); err != nil {
return err
}