search

package module
v1.0.0 Latest Latest
Warning

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

Go to latest
Published: Apr 6, 2026 License: MIT Imports: 12 Imported by: 0

README

Search module supports full-text search with relevance ranking, semantic search using embeddings, and optional Database and ElasticSearch integration.

Copyright © 2023, gFly
https://www.gFly.dev
All rights reserved.
Usage

Install

go get -u github.com/gflydev/search@v1.0.0

Table of Contents

  1. Overview
  2. Package Structure
  3. Core Concepts
  4. Drivers
  5. Making a Model Searchable
  6. Searching
  7. Indexing (Elasticsearch)
  8. Hydrating Results
  9. Complete Example
  10. Best Practices

Overview

Search Query
    ↓
Engine.For(model) / Engine.Index(name, fields...)
    ↓
Builder  →  .Query()  .Where()  .OrderBy()  .Page()  .PerPage()
    ↓
Driver.Search(Request)
    ↓
Result { Total, Hits[]{ID, Score, Data} }
    ↓
Hydrate IDs → []Model  (fetch from DB via mb.GetModelByID)

Two drivers are available out of the box:

Driver Backend Indexing needed? Fuzzy search
DatabaseDriver PostgreSQL ILIKE No No
ElasticsearchDriver Elasticsearch 7.x/8.x Yes Yes

Package Structure

├── driver.go               → Driver interface, Filter, Order, Document types
├── request.go              → Request struct
├── result.go               → Result, Hit types
├── searchable.go           → Searchable interface
├── engine.go               → Engine (entry point)
├── builder.go              → Builder (fluent query API)
├── database_driver.go      → DatabaseDriver implementation
└── elasticsearch_driver.go → ElasticsearchDriver implementation

Core Concepts

Driver

Driver is the interface every search backend must implement:

type Driver interface {
    Search(req Request) (*Result, error)
    Index(indexName string, id any, data core.Data) error
    Remove(indexName string, id any) error
    BulkIndex(indexName string, docs []Document) error
}

Index, Remove, and BulkIndex are no-ops for the DatabaseDriver — the database is already the source of truth.


Searchable Interface

Any model that should be searchable must implement the four-method Searchable interface:

type Searchable interface {
    SearchIndex() string            // table name (DB) or index name (ES)
    SearchKey() any                 // primary key
    SearchableFields() []string     // columns for ILIKE (DB driver)
    ToSearchDocument() core.Data   // document written to ES
}

Engine

Engine is the main entry point. Create one per driver and reuse it:

engine := search.New(search.NewDatabaseDriver())
Method Description
engine.For(model Searchable) Returns a Builder pre-configured from the model
engine.Index(name, fields...) Returns a Builder with explicit index and field list
engine.IndexModel(model) Indexes a single model (ES)
engine.RemoveModel(model) Removes a model from the index (ES)
engine.BulkIndex(models) Bulk-indexes a slice of models (ES)

Builder

Builder provides the fluent query API:

engine.For(models.User{}).
    Query("alice").
    Where("users.status", "active").
    OrderBy("users.fullname", search.Asc).
    Page(1).PerPage(20).
    Search()
Method Description
.Query(q string) Full-text search string
.Where(field, value) Equality filter (AND-ed, multiple allowed)
.OrderBy(field, direction) Sort by field (search.Asc / search.Desc)
.Page(n int) 1-based page number (default: 1)
.PerPage(n int) Results per page (default: 15)
.Search() Execute and return *Result
.Paginate() Alias for .Search()
.Count() Return total matching count only

Result & Hit
type Result struct {
    Total   int64   // total matching documents (before pagination)
    Page    int
    PerPage int
    Hits    []Hit
}

type Hit struct {
    ID    any        // primary key
    Score float64    // 1.0 for DB driver, ES _score for ES driver
    Data  core.Data // ES _source document (nil for DB driver)
}

Convenience helpers on *Result:

result.IDs()     // []any
result.IntIDs()  // []int  (skips non-int IDs)

Drivers

DatabaseDriver

Uses PostgreSQL ILIKE (case-insensitive) queries. No additional infrastructure is required.

driver := search.NewDatabaseDriver()
engine := search.New(driver)

Options:

driver := &search.DatabaseDriver{
    CaseSensitive: true,  // use LIKE instead of ILIKE (default: false)
    IDField:       "id",  // primary key column name (default: "id")
}

How it works:

For a Query("alice") across ["users.fullname", "users.email"] the driver executes:

-- Count
SELECT COUNT(*) FROM users
WHERE (users.fullname ILIKE '%alice%' OR users.email ILIKE '%alice%')

-- Paginated IDs
SELECT users.id FROM users
WHERE (users.fullname ILIKE '%alice%' OR users.email ILIKE '%alice%')
ORDER BY users.fullname ASC
LIMIT 20 OFFSET 0

ElasticsearchDriver

Uses the Elasticsearch REST API via Go's standard net/http — no extra dependency required.

driver := search.NewElasticsearchDriver(search.ElasticsearchConfig{
    Host:     "http://localhost:9200",  // required
    Username: "",                       // optional (HTTP Basic Auth)
    Password: "",
    Timeout:  10 * time.Second,        // optional (default: 10 s)
})
engine := search.New(driver)

Docker Compose (already configured):

es:
  image: elasticsearch:7.17.28
  ports:
    - "${CONTAINER_ES_PORT:-9200}:9200"

Start with: make es.run

How it works:

A Query("alice") across ["fullname", "email"] with Where("status", "active") generates:

{
  "query": {
    "bool": {
      "must": [{
        "multi_match": {
          "query": "alice",
          "fields": ["fullname", "email"],
          "type": "best_fields",
          "fuzziness": "AUTO"
        }
      }],
      "filter": [{ "term": { "status": "active" } }]
    }
  },
  "from": 0,
  "size": 20
}

The driver returns ES _id and _score in each Hit. The _source document is available via hit.Data.


Making a Model Searchable

Add a dedicated file inside the model's package (e.g. user_searchable.go):

package models

import "github.com/gflydev/core"

func (u User) SearchIndex() string { return TableUser }
func (u User) SearchKey() any      { return u.ID }

func (u User) SearchableFields() []string {
    return []string{
        "fullname",
        "email",
        "phone",
    }
}

func (u User) ToSearchDocument() core.Data {
    return core.Data{
        "id":       u.ID,
        "fullname": u.Fullname,
        "email":    u.Email,
        "phone":    u.Phone,
        "status":   string(u.Status),
    }
}

Note: SearchableFields should use qualified column names (table.column) when the DatabaseDriver query involves JOINs to avoid ambiguity. For the ElasticsearchDriver the field names are the plain document field names stored in the index.


Searching

engine := search.New(search.NewDatabaseDriver())

result, err := engine.For(models.User{}).
    Query("alice").
    Search()

// result.Total → total matching records
// result.Hits  → []Hit with ID and Score
Filtering

Additional Where calls are AND-ed together:

result, err := engine.For(models.User{}).
    Query("alice").
    Where("users.status", "active").
    Where("users.deleted_at", nil).
    Search()

For the DatabaseDriver, filter field names must be qualified (table.column). For the ElasticsearchDriver, filter field names match the document field names in the index.

Sorting
result, err := engine.For(models.User{}).
    Query("alice").
    OrderBy("users.created_at", search.Desc).
    Search()

Constants: search.Asc, search.Desc

Pagination
result, err := engine.For(models.User{}).
    Query("alice").
    Page(2).PerPage(15).
    Paginate()

// result.Total   → total count across all pages
// result.Page    → 2
// result.PerPage → 15
// result.Hits    → up to 15 hits for page 2
Count Only
total, err := engine.For(models.User{}).
    Query("alice").
    Where("users.status", "active").
    Count()
Ad-hoc Search Without Searchable

When you do not want to implement the full Searchable interface, use engine.Index() directly:

result, err := engine.
    Index("users", "users.fullname", "users.email").
    Query("alice").
    Where("users.status", "active").
    PerPage(10).
    Search()

Indexing (Elasticsearch)

The DatabaseDriver does not require indexing — skip this section if you only use PostgreSQL search.

Index a Single Model

Call after creating or updating a model:

esEngine := search.New(search.NewElasticsearchDriver(cfg))

// After create
user, err := services.CreateUser(dto)
esEngine.IndexModel(user)

// After update
user, err := services.UpdateUser(dto)
esEngine.IndexModel(*user)
Remove a Model

Call before or after deleting a model:

esEngine.RemoveModel(user)
services.DeleteUserByID(user.ID)
Bulk Index

Use for initial import or scheduled full re-sync:

var users []models.User
// ... fetch all users from DB ...

searchables := make([]search.Searchable, len(users))
for i, u := range users {
    searchables[i] = u
}

err := esEngine.BulkIndex(searchables)

Hydrating Results

Both drivers return Hit.ID values (primary keys). Fetch full model structs from the database:

result, err := engine.For(models.User{}).Query("alice").Search()
if err != nil {
    return err
}

users := make([]models.User, 0, len(result.Hits))
for _, id := range result.IntIDs() {
    user, err := mb.GetModelByID[models.User](id)
    if err != nil {
        continue // skip missing records
    }
    users = append(users, *user)
}

Elasticsearch only: hit.Data contains the raw _source document already available in the Result. If the indexed document contains all required fields you can use hit.Data directly and skip the database round-trip.


Complete Example

1. Implement Searchable (internal/domain/models/user_searchable.go)
package models

import "github.com/gflydev/core"

func (u User) SearchIndex() string { return TableUser }
func (u User) SearchKey() any      { return u.ID }

func (u User) SearchableFields() []string {
    return []string{
        "fullname",
        "email",
        "phone",
    }
}

func (u User) ToSearchDocument() core.Data {
    return core.Data{
        "id":       u.ID,
        "fullname": u.Fullname,
        "email":    u.Email,
        "phone":    u.Phone,
        "status":   string(u.Status),
    }
}
2. Create engines (internal/services/search_engines.go)
package services

import "github.com/gflydev/search"

var DBSearchEngine = search.New(search.NewDatabaseDriver())

var ESSearchEngine = search.New(search.NewElasticsearchDriver(
    search.ElasticsearchConfig{Host: "http://localhost:9200"},
))
3. Use in a service (internal/services/user_services.go)
func SearchUsers(keyword, status string, page, perPage int) ([]models.User, int64, error) {
    builder := DBSearchEngine.For(models.User{}).
        Query(keyword).
        Page(page).PerPage(perPage)

    if status != "" {
        builder = builder.Where("status", status)
    }

    result, err := builder.Search()
    if err != nil {
        return nil, 0, err
    }

    users := make([]models.User, 0, len(result.Hits))
    for _, id := range result.IntIDs() {
        user, err := mb.GetModelByID[models.User](id)
        if err != nil {
            continue
        }
        users = append(users, *user)
    }

    return users, result.Total, nil
}
4. Use in a controller (internal/http/controllers/api/user/)
func (h *SearchUsersApi) Handle(c *core.Ctx) error {
    keyword := c.QueryParam("q")
    status  := c.QueryParam("status")
    page, _ := strconv.Atoi(c.QueryParam("page"))
    if page < 1 {
        page = 1
    }

    users, total, err := services.SearchUsers(keyword, status, page, 20)
    if err != nil {
        return c.Error(http.Error{Code: "SEARCH_ERROR", Message: err.Error()})
    }

    data := http.ToListResponse(users, transformers.ToUserResponse)
    return c.Success(http.List[response.User]{
        Meta: http.Meta{Page: page, PerPage: 20, Total: int(total)},
        Data: data,
    })
}

Best Practices

Topic Recommendation
Driver selection Use DatabaseDriver for simple keyword search and small datasets. Switch to ElasticsearchDriver when you need fuzzy matching, relevance scoring, or high query throughput.
Field qualification Always prefix DatabaseDriver field names with the table name (users.email) when the query involves JOINs to prevent ambiguous column errors.
ES indexing Call engine.IndexModel inside the service layer, immediately after a successful create or update, to keep the index in sync.
Bulk re-index Run engine.BulkIndex as a console command or scheduled job (internal/console/) rather than inline in a request handler.
Hydration Prefer fetching full models from PostgreSQL using mb.GetModelByID rather than relying on the ES _source document, unless the source contains all required fields and a second DB round-trip is unacceptable.
Engine reuse Create engine instances once (e.g. package-level vars in a service file) and reuse them — each search.New() call is lightweight but the underlying HTTP client should be shared.
Empty query An empty Query("") with the DatabaseDriver skips the ILIKE conditions and returns all rows matching only the Where filters, behaving like a normal filtered list.
Sorting with ES Only sort on keyword or numeric field types in ES. Sorting on text fields requires a .keyword sub-field mapping (field.keyword).

Documentation

Index

Constants

View Source
const (
	Asc  = "asc"
	Desc = "desc"
)

Order direction constants

Variables

This section is empty.

Functions

This section is empty.

Types

type Builder

type Builder struct {
	// contains filtered or unexported fields
}

Builder provides a fluent API for constructing and executing search queries, inspired by Laravel Scout's query builder.

result, err := engine.For(models.User{}).
    Query("john doe").
    Where("status", "active").
    OrderBy("created_at", search.Desc).
    Page(2).PerPage(10).
    Search()

func (*Builder) Count

func (b *Builder) Count() (int64, error)

Count returns only the total number of matching documents without fetching any result data. Useful for existence checks or badge counts.

func (*Builder) OrderBy

func (b *Builder) OrderBy(field, direction string) *Builder

OrderBy sets the sort field and direction (search.Asc or search.Desc).

func (*Builder) Page

func (b *Builder) Page(page int) *Builder

Page sets the 1-based page number.

func (*Builder) Paginate

func (b *Builder) Paginate() (*Result, error)

Paginate is a convenience method equivalent to Search. It is provided so call-sites can express intent clearly.

result, err := builder.Page(p).PerPage(20).Paginate()

func (*Builder) PerPage

func (b *Builder) PerPage(perPage int) *Builder

PerPage sets the maximum number of results returned per page.

func (*Builder) Query

func (b *Builder) Query(query string) *Builder

Query sets the full-text search string.

func (*Builder) Search

func (b *Builder) Search() (*Result, error)

Search executes the query and returns the raw Result (hits + total).

To obtain typed model instances from the result IDs use the helper functions provided in the examples package or mb.GetModelByID directly.

func (*Builder) Where

func (b *Builder) Where(field string, value any) *Builder

Where appends an equality filter applied after the full-text match. Multiple Where calls are AND-ed together.

builder.Where("status", "active").Where("role", "admin")

type DatabaseDriver

type DatabaseDriver struct {
	// CaseSensitive switches from ILIKE to LIKE when true.
	// Default is false (case-insensitive).
	CaseSensitive bool

	// IDField is the primary-key column name used in the SELECT and COUNT
	// queries.  Defaults to "id".
	IDField string
}

DatabaseDriver implements Driver using the existing PostgreSQL database. It builds ILIKE (case-insensitive LIKE) conditions across the supplied fields and executes them via the mb query builder.

No separate index is maintained – the source of truth is the database itself. Index/Remove/BulkIndex are no-ops for this driver.

Usage:

driver := search.NewDatabaseDriver()
engine := search.New(driver)

result, err := engine.For(models.User{}).
    Query("alice").
    Where("status", "active").
    Page(1).PerPage(20).
    Search()

func NewDatabaseDriver

func NewDatabaseDriver() *DatabaseDriver

NewDatabaseDriver creates a DatabaseDriver with sensible defaults.

func (*DatabaseDriver) BulkIndex

func (d *DatabaseDriver) BulkIndex(_ string, _ []Document) error

BulkIndex is a no-op for the database driver.

func (*DatabaseDriver) Index

func (d *DatabaseDriver) Index(_ string, _ any, _ core.Data) error

Index is a no-op for the database driver.

func (*DatabaseDriver) Remove

func (d *DatabaseDriver) Remove(_ string, _ any) error

Remove is a no-op for the database driver.

func (*DatabaseDriver) Search

func (d *DatabaseDriver) Search(req Request) (*Result, error)

Search builds and executes two SQL queries:

  1. A COUNT query to obtain the total number of matching rows.
  2. A SELECT <id> query to obtain the paginated primary-key list.

The caller can then hydrate full models using mb.GetModelByID[T].

type Document

type Document struct {
	ID   any
	Data core.Data
}

Document is a unit of data to be written into a search index.

type Driver

type Driver interface {
	// Search executes a query and returns paginated hits together with
	// the total number of matching documents.
	Search(req Request) (*Result, error)

	// Index adds or replaces a single document in the search index.
	// For the database driver this is a no-op – the source of truth is
	// already the database.
	Index(indexName string, id any, data core.Data) error

	// Remove deletes a document from the search index.
	Remove(indexName string, id any) error

	// BulkIndex adds or replaces multiple documents in one operation.
	BulkIndex(indexName string, docs []Document) error
}

Driver is the interface every search backend must satisfy.

Implementations are provided for PostgreSQL (DatabaseDriver) and Elasticsearch (ElasticsearchDriver). Custom drivers (e.g. Meilisearch, Typesense, Algolia) can be added by satisfying this interface.

type ElasticsearchConfig

type ElasticsearchConfig struct {
	// Host is the base URL of the Elasticsearch node, e.g. "http://localhost:9200".
	Host string

	// Username and Password are used for HTTP Basic Auth (optional).
	Username string
	Password string // #nosec G117 -- Legitimate password field for Elasticsearch HTTP Basic Auth config

	// Timeout for individual HTTP requests (default: 10 s).
	Timeout time.Duration
}

ElasticsearchConfig holds connection settings for the Elasticsearch cluster.

type ElasticsearchDriver

type ElasticsearchDriver struct {
	// contains filtered or unexported fields
}

ElasticsearchDriver implements Driver against an Elasticsearch 7.x/8.x cluster using plain HTTP requests – no external client library required.

Indexing workflow (mirrors Laravel Scout):

  1. After creating/updating a model call engine.IndexModel(model).
  2. After deleting a model call engine.RemoveModel(model).
  3. To re-index a whole collection use engine.BulkIndex(models).

Search uses a multi-match bool query with optional term filters and simple field sorting.

Usage:

cfg := search.ElasticsearchConfig{Host: "http://localhost:9200"}
engine := search.New(search.NewElasticsearchDriver(cfg))

// Index a model
engine.IndexModel(user)

// Search
result, err := engine.For(models.User{}).
    Query("alice").
    Where("status", "active").
    Page(1).PerPage(20).
    Search()

func NewElasticsearchDriver

func NewElasticsearchDriver(cfg ElasticsearchConfig) *ElasticsearchDriver

NewElasticsearchDriver creates an ElasticsearchDriver with the supplied configuration. The Host field is normalised so bare addresses like "localhost:9200" or "es:9200" are automatically prefixed with "http://".

func (*ElasticsearchDriver) BulkIndex

func (d *ElasticsearchDriver) BulkIndex(indexName string, docs []Document) error

BulkIndex uses the ES _bulk API for efficient multi-document indexing.

func (*ElasticsearchDriver) Index

func (d *ElasticsearchDriver) Index(indexName string, id any, data core.Data) error

Index creates or replaces a document in the ES index.

func (*ElasticsearchDriver) Remove

func (d *ElasticsearchDriver) Remove(indexName string, id any) error

Remove deletes a document from the ES index.

func (*ElasticsearchDriver) Search

func (d *ElasticsearchDriver) Search(req Request) (*Result, error)

Search executes a bool/multi-match query against the configured ES index.

type Engine

type Engine struct {
	// contains filtered or unexported fields
}

Engine wraps a Driver and is the main entry point for all search operations. Create one Engine per driver (database, Elasticsearch, …) and reuse it throughout the application.

Basic usage:

engine := search.New(search.NewDatabaseDriver())

result, err := engine.For(models.User{}).
    Query("alice").
    Where("status", "active").
    Page(1).PerPage(20).
    Search()

func New

func New(driver Driver) *Engine

New creates a new Engine backed by the supplied Driver.

func (*Engine) BulkIndex

func (e *Engine) BulkIndex(models []Searchable) error

BulkIndex indexes a slice of Searchable models in a single operation. All models must share the same index (i.e. be of the same type).

func (*Engine) For

func (e *Engine) For(s Searchable) *Builder

For returns a Builder pre-configured from a Searchable model. The index name and searchable fields are derived from the model automatically.

engine.For(models.User{}).Query("alice").Search()

func (*Engine) Index

func (e *Engine) Index(index string, fields ...string) *Builder

Index returns a Builder scoped to the given index name. Pass the searchable fields explicitly when you cannot (or prefer not to) create a full Searchable implementation.

engine.Index("users", "fullname", "email").Query("bob").Search()

func (*Engine) IndexModel

func (e *Engine) IndexModel(s Searchable) error

IndexModel adds or updates a single Searchable model in the search index. For the database driver this is a no-op.

func (*Engine) RemoveModel

func (e *Engine) RemoveModel(s Searchable) error

RemoveModel removes a single Searchable model from the search index.

type Filter

type Filter struct {
	Field string
	Value any
}

Filter represents an equality condition applied on top of the full-text search.

type Hit

type Hit struct {
	// ID is the primary key of the matched document.
	ID any

	// Score is the relevance score.  The database driver always sets this
	// to 1.0; the Elasticsearch driver uses the native _score value.
	Score float64

	// Data contains the indexed document fields as returned by
	// Elasticsearch.  The database driver leaves this nil – callers should
	// hydrate full models from the database using the ID.
	Data core.Data
}

Hit is a single document returned by a search operation.

type Order

type Order struct {
	Field     string
	Direction string // Asc or Desc
}

Order controls sorting of search results.

type Request

type Request struct {
	// Index is the table name (DatabaseDriver) or ES index name
	// (ElasticsearchDriver).
	Index string

	// Fields lists the columns / document fields to match the Query against.
	// Used by the DatabaseDriver for ILIKE conditions and by the
	// ElasticsearchDriver for multi-match queries.
	Fields []string

	// Query is the full-text search string supplied by the caller.
	Query string

	// Filters are additional equality conditions applied after the
	// full-text match.
	Filters []Filter

	// Order controls result sorting.
	Order Order

	// Page is the 1-based page number.
	Page int

	// PerPage is the maximum number of results to return per page.
	PerPage int
}

Request carries every parameter needed to execute a single search operation.

type Result

type Result struct {
	// Total is the number of matching documents before pagination.
	Total int64

	// Page is the current page number (mirrors the request).
	Page int

	// PerPage is the page size (mirrors the request).
	PerPage int

	// Hits contains the matched documents for the current page.
	Hits []Hit
}

Result is the complete response for one search operation.

func (*Result) IDs

func (r *Result) IDs() []any

IDs returns every hit ID as []any.

func (*Result) IntIDs

func (r *Result) IntIDs() []int

IntIDs returns hit IDs cast to int. Handles int, float64 (JSON numbers), and string (Elasticsearch _id) types.

type Searchable

type Searchable interface {
	// SearchIndex returns the logical name of the search index.
	// For the DatabaseDriver this is the table name.
	// For the ElasticsearchDriver this is the ES index name.
	SearchIndex() string

	// SearchKey returns the unique identifier used to address the document
	// inside the index (primary key of the model).
	SearchKey() any

	// SearchableFields returns the list of column / field names that the
	// DatabaseDriver will apply ILIKE conditions against when a Query
	// string is present in the Request.
	SearchableFields() []string

	// ToSearchDocument returns a flat map of the data that should be
	// written to the Elasticsearch index when indexing this model.
	ToSearchDocument() core.Data
}

Searchable is the interface a model must implement to participate in search operations. It is analogous to Laravel Scout's Searchable trait.

Implement it on any domain model to make it indexable and searchable:

func (u User) SearchIndex() string            { return "users" }
func (u User) SearchKey() any                 { return u.ID }
func (u User) SearchableFields() []string     { return []string{"fullname", "email", "phone"} }
func (u User) ToSearchDocument() core.Data {
    return core.Data{
        "id":       u.ID,
        "fullname": u.Fullname,
        "email":    u.Email,
        "phone":    u.Phone,
        "status":   string(u.Status),
    }
}

Jump to

Keyboard shortcuts

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