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.
go get -u github.com/gflydev/search@v1.0.0
NoteIMPORTANT!
Searchable model files should be placed alongside your models, e.g.app/models/user_searchable.go
| 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 |
NoteIMPORTANT!
DatabaseDriveris not yet fully functional and should not be used in production. UseElasticsearchDriverfor all search needs.
The search flow follows a simple pipeline:
Search Query → Engine → Builder → Driver → Result → (Optional Hydration)
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,
}
}
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
}))
| 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) |
| Method | Returns |
|---|---|
.Search() / .Paginate() |
Paginated Result |
.Count() |
Total matching count only |
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()
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
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())
NoteTIP
For ElasticsearchDriver, if all required fields are already indexed inToSearchDocument(), you can read directly fromhit.Dataand skip the database round-trip.
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)
NoteTIP
Run bulk re-indexing as a console command or scheduled job — not inside an HTTP request handler.
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,
}
}
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
}
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
NoteTIP
Runuser-index-bulkonce after deploying to populate the Elasticsearch index for the first time, then callAddIndexUser/UpdateIndexUser/RemoveIndexUserfrom your service layer to keep it in sync going forward.
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,
})
}