Skip to main content
gFly v1.18.1
Toggle Dark/Light/Auto mode Toggle Dark/Light/Auto mode Toggle Dark/Light/Auto mode Back to homepage

Search

gFly’s search module provides full-text search capabilities with relevance ranking. It supports two backends — PostgreSQL for simple use cases and Elasticsearch for advanced fuzzy matching and high-throughput scenarios — behind a unified fluent API.

Installation

go get -u github.com/gflydev/search@v1.0.0
Note
IMPORTANT!
Searchable model files should be placed alongside your models, e.g. app/models/user_searchable.go

Drivers

Driver Backend Indexing required Fuzzy match Relevance score
DatabaseDriver PostgreSQL ILIKE No No Always 1.0
ElasticsearchDriver Elasticsearch 7.x / 8.x Yes Yes ES _score
Note
IMPORTANT!
DatabaseDriver is not yet fully functional and should not be used in production. Use ElasticsearchDriver for all search needs.

Architecture

The search flow follows a simple pipeline:

Search Query → Engine → Builder → Driver → Result → (Optional Hydration)

Making a Model Searchable

Implement the Searchable interface by adding four methods to your model. By convention, place them in a dedicated _searchable.go file.

// file: app/models/user_searchable.go

func (u User) SearchIndex() string {
    return TableUser // table name for DB, index name for ES
}

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

func (u User) SearchableFields() []string {
    // Use "table.column" format for DatabaseDriver (supports JOINs)
    return []string{"fullname", "email", "phone"}
}

func (u User) ToSearchDocument() core.Data {
    // Used by ElasticsearchDriver to build the indexed document
    return core.Data{
        "id":       u.ID,
        "fullname": u.Fullname,
        "email":    u.Email,
    }
}

Creating the Engine

Create engine instances once at the package level and reuse them.

DatabaseDriver (not recommended — currently not functional):

import "github.com/gflydev/search"

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

ElasticsearchDriver:

import "github.com/gflydev/search"

var esEngine = search.New(search.NewElasticsearchDriver(search.ESConfig{
    Host:     "http://localhost:9200",
    Username: "",        // optional
    Password: "",        // optional
    Timeout:  10,        // seconds, default 10
}))

Search API

Query Construction

Method Description
.Query(string) Full-text search term
.Where(field, value) Equality filter (multiple calls are AND-ed)
.OrderBy(field, direction) Sort by field (search.Asc / search.Desc)
.Page(n) 1-based page number (default: 1)
.PerPage(n) Results per page (default: 15)

Execution

Method Returns
.Search() / .Paginate() Paginated Result
.Count() Total matching count only

Usage Examples

Basic search:

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

With filtering and sorting:

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

Count only:

total, err := engine.For(models.User{}).
    Query("alice").
    Where("status", "active").
    Count()

Ad-hoc search (without implementing Searchable):

result, err := engine.Index("users", "fullname", "email").
    Query("alice").
    Search()

Result Structure

type Result struct {
    Total  int64  // total matches across all pages
    Page   int    // current page
    PerPage int   // results per page
    Hits   []Hit
}

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

Convenience helpers:

ids    := result.IDs()    // []any
intIDs := result.IntIDs() // []int

Hydrating Results

Both drivers return only primary keys in Hits. Fetch the full model objects after searching:

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

users, err := mb.GetModelsByIDs[models.User](result.IntIDs())
Note
TIP
For ElasticsearchDriver, if all required fields are already indexed in ToSearchDocument(), you can read directly from hit.Data and skip the database round-trip.

Elasticsearch Indexing

The ElasticsearchDriver requires explicit index management. Call these methods from your service layer or console commands.

Index a single model (after create or update):

esEngine.IndexModel(user)

Remove a model (before or after delete):

esEngine.RemoveModel(user)

Bulk index (initial import or scheduled re-sync):

// searchables is []search.Searchable
esEngine.BulkIndex(searchables)
Note
TIP
Run bulk re-indexing as a console command or scheduled job — not inside an HTTP request handler.

Complete Example

Searchable model — app/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"} }
func (u User) ToSearchDocument() core.Data {
    return core.Data{
        "id":       u.ID,
        "fullname": u.Fullname,
        "email":    u.Email,
    }
}

Service — app/services/user_search_service.go

package services

import (
    "gfly/internal/domain/models"
    "github.com/gflydev/search"

    "github.com/gflydev/core/errors"
    "github.com/gflydev/core/log"
    coreUtils "github.com/gflydev/core/utils"
    mb "github.com/gflydev/db"
)

// ====================================================================
// ========================= Search Engine ============================
// ====================================================================

// ESSearchEngine is the application-wide Elasticsearch search engine for users.
// The host is read from the ES_HOST environment variable (default: http://localhost:9200).
var ESSearchEngine = search.New(search.NewElasticsearchDriver(search.ElasticsearchConfig{
    Host: coreUtils.Getenv("ES_HOST", "http://localhost:9200"),
}))

// ====================================================================
// ========================= Main functions ===========================
// ====================================================================

// SearchUsers searches users in Elasticsearch by keyword with optional status
// filter and returns hydrated models.User slice alongside the total count.
func SearchUsers(keyword, status string, page, perPage int) ([]models.User, int64, error) {
    builder := ESSearchEngine.For(models.User{}).
        Query(keyword).
        Page(page).
        OrderBy("id", "desc").
        PerPage(perPage)

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

    result, err := builder.Search()
    if err != nil {
        log.Errorf("SearchUsers: elasticsearch query failed: %v", err)
        return nil, 0, errors.New("error occurs while searching users")
    }

    users, err := hydrateUsersByIDs(result.IntIDs())
    if err != nil {
        return nil, 0, err
    }

    return users, result.Total, nil
}

// AddIndexUser indexes a newly created user into Elasticsearch.
// Call this immediately after a successful CreateUser.
func AddIndexUser(user models.User) error {
    if err := ESSearchEngine.IndexModel(user); err != nil {
        log.Errorf("AddIndexUser: failed to index user %d: %v", user.ID, err)
        return errors.New("error occurs while indexing user")
    }

    log.Infof("AddIndexUser: indexed user %d (%s)", user.ID, user.Email)

    return nil
}

// UpdateIndexUser re-indexes an updated user in Elasticsearch.
// Call this immediately after a successful UpdateUser.
func UpdateIndexUser(user models.User) error {
    if err := ESSearchEngine.IndexModel(user); err != nil {
        log.Errorf("UpdateIndexUser: failed to re-index user %d: %v", user.ID, err)
        return errors.New("error occurs while updating user index")
    }

    log.Infof("UpdateIndexUser: re-indexed user %d (%s)", user.ID, user.Email)

    return nil
}

// RemoveIndexUser removes a user from the Elasticsearch index.
// Call this before or after a successful DeleteUserByID.
func RemoveIndexUser(user models.User) error {
    if err := ESSearchEngine.RemoveModel(user); err != nil {
        log.Errorf("RemoveIndexUser: failed to remove user %d from index: %v", user.ID, err)
        return errors.New("error occurs while removing user from index")
    }

    log.Infof("RemoveIndexUser: removed user %d from index", user.ID)

    return nil
}

// BulkIndexUsers re-indexes all provided users in a single Elasticsearch bulk
// request. Useful for initial import or full re-sync jobs.
func BulkIndexUsers(users []models.User) error {
    searchableData := make([]search.Searchable, len(users))
    for idx := range users {
        searchableData[idx] = users[idx]
    }

    if err := ESSearchEngine.BulkIndex(searchableData); err != nil {
        log.Errorf("BulkIndexUsers: bulk index failed: %v", err)
        return errors.New("error occurs while bulk indexing users")
    }

    log.Infof("BulkIndexUsers: indexed %d users", len(users))

    return nil
}

// ====================================================================
// ======================== Helper Functions ==========================
// ====================================================================

// hydrateUsersByIDs fetches full User models from the database for the
// given primary key list. Missing or deleted records are silently skipped.
func hydrateUsersByIDs(ids []int) ([]models.User, error) {
    users := make([]models.User, 0, len(ids))

    for _, id := range ids {
        user, err := mb.GetModelByID[models.User](id)
        if err != nil {
            log.Warnf("hydrateUsersByIDs: user %d not found, skipping", id)
            continue
        }
        users = append(users, *user)
    }

    return users, nil
}

Command — app/console/commands/user_search_command.go

Console commands are useful for testing your search setup and running bulk re-index jobs outside of an HTTP request cycle.

Two commands are registered in this file:

Command Description
user-search Runs a set of search scenarios and prints results to the log
user-index-bulk Fetches all users from the database and bulk-indexes them into Elasticsearch
package commands

import (
    "gfly/internal/services"

    "github.com/gflydev/console"
    "github.com/gflydev/core/log"
    mb "github.com/gflydev/db"

    "gfly/internal/domain/models"
    "time"
)

// ---------------------------------------------------------------
//
//	Register commands.
//	./artisan cmd:run user-search          — full-text search demo
//	./artisan cmd:run user-index-bulk      — bulk re-index all users
//
// ---------------------------------------------------------------

func init() {
    console.RegisterCommand(&userSearchCommand{}, "user-search")
    console.RegisterCommand(&userIndexBulkCommand{}, "user-index-bulk")
}

// ---------------------------------------------------------------
//                     userSearchCommand
// ---------------------------------------------------------------

// userSearchCommand tests SearchUsers against Elasticsearch.
type userSearchCommand struct {
    console.Command
}

// Handle runs a set of search scenarios and prints results to the log.
func (c *userSearchCommand) Handle() {
    log.Info("=== user-search: starting ===")

    scenarios := []struct {
        keyword string
        status  string
    }{
        {"admin", ""},
        {"admin", "active"},
        {"", "active"},
        {"", "pending"},
    }

    for _, s := range scenarios {
        label := s.keyword
        if label == "" {
            label = "(empty)"
        }

        users, total, err := services.SearchUsers(s.keyword, s.status, 1, 10)
        if err != nil {
            log.Errorf("  [keyword=%q status=%q] error: %v", s.keyword, s.status, err)
            continue
        }

        log.Infof("  [keyword=%q status=%q] total=%d returned=%d",
            label, s.status, total, len(users))

        for idx := range users {
            u := users[idx]
            log.Infof("    → id=%-4d email=%-30s status=%s", u.ID, u.Email, u.Status)
        }
    }

    log.Infof("=== user-search: done at %s ===", time.Now().Format("2006-01-02 15:04:05"))
}

// ---------------------------------------------------------------
//                   userIndexBulkCommand
// ---------------------------------------------------------------

// userIndexBulkCommand fetches every user from the database and
// bulk-indexes them into Elasticsearch. Run this once to populate
// the index or after a schema change.
type userIndexBulkCommand struct {
    console.Command
}

// Handle loads all users and calls BulkIndexUsers.
func (c *userIndexBulkCommand) Handle() {
    log.Info("=== user-index-bulk: starting ===")

    var users []models.User

    total, err := mb.Instance().
        Model(&models.User{}).
        Where(models.TableUser+".deleted_at", mb.Null, nil).
        Find(&users)

    if err != nil {
        log.Errorf("user-index-bulk: failed to load users: %v", err)
        return
    }

    log.Infof("user-index-bulk: loaded %d users from database", total)

    if err = services.BulkIndexUsers(users); err != nil {
        log.Errorf("user-index-bulk: %v", err)
        return
    }

    log.Infof("=== user-index-bulk: done at %s ===", time.Now().Format("2006-01-02 15:04:05"))
}

Run the commands:

# Test search scenarios against Elasticsearch
./build/artisan cmd:run user-search

# Bulk re-index all users (initial import or after schema change)
./build/artisan cmd:run user-index-bulk
Note
TIP
Run user-index-bulk once after deploying to populate the Elasticsearch index for the first time, then call AddIndexUser / UpdateIndexUser / RemoveIndexUser from your service layer to keep it in sync going forward.

Controller — app/http/controllers/user_controller.go

func (c *UserController) Search(g *fiber.Ctx) error {
    query   := g.Query("q")
    page    := g.QueryInt("page", 1)
    perPage := c.QueryInt("per_page", 15)

    users, total, err := services.SearchUsers(query, "active", page, perPage)
    if err != nil {
        return responses.Error(g, err)
    }

    return responses.JSON(g, fiber.Map{
        "data":     users,
        "total":    total,
        "page":     page,
        "per_page": perPage,
    })
}