Help us shape the future of Go! Take the 2026 Go Developer Survey

postgressearch

package
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Nov 1, 2026 License: MIT Imports: 11 Imported by: 0

README

Arkoren Search Postgres Driver

arkoren.dev/driver/postgres/postgressearch implements search.Driver on PostgreSQL full-text search, in two tables that the application creates by adding driver.Migrations() to its migrator. It never runs DDL.

Run go get arkoren.dev/driver/postgres in your application's Go module. With an existing PostgreSQL facade db and application migrator, wrap the database and register the shipped migrations:

import "arkoren.dev/driver/postgres/postgressearch"

driver, err := postgressearch.NewFrom(db)
err = migrator.Add(driver.Migrations()...)

Run the migrator before starting the search driver, then wrap it with search.New and synchronize your registered indexes. The PostgreSQL search guide provides consumer-local database setup, migration wiring, indexing, and search verification. The database pool remains yours; driver.Stop does not close it. Rolling back these migrations deletes all indexed documents. Keep source data and plan search downtime before a rollback or rebuild.

For multiple independent search stores, set Builder.Instance during NewFrom. The empty instance retains the default tables; nonempty instances accept 1–40 bytes of [a-z0-9_], including a leading digit such as 2026_catalog. Both migration directions quote generated SQL identifiers without renaming tables, indexes, advisory locks, or migration groups. Renaming an existing instance remains unsupported. Start and Ping only check table readiness; the application migrator is still the only DDL execution owner.

See the driver API reference.

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

Constants

This section is empty.

Variables

View Source
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

func (engine *Builder) Instance(value string) *Builder

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

func (driver *Driver) Check(ctx context.Context, schemas []search.Schema) error

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

func (driver *Driver) Database() *database.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

func (driver *Driver) Delete(ctx context.Context, schema search.Schema, ids []string) error

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

func (driver *Driver) Drop(ctx context.Context, schema search.Schema) error

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

func (driver *Driver) Ping(ctx context.Context) error

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

func (driver *Driver) Put(ctx context.Context, schema search.Schema, docs []search.Document) error

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

func (driver *Driver) Start(ctx context.Context) error

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

func (driver *Driver) Stop(context.Context) error

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

func (driver *Driver) Sync(ctx context.Context, schemas []search.Schema) error

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
}

Directories

Path Synopsis
internal
statement
Package statement compiles PostgreSQL search SQL without executing it.
Package statement compiles PostgreSQL search SQL without executing it.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL