Skip to content

Commit

Permalink
Add full-text search functionality
Browse files Browse the repository at this point in the history
  • Loading branch information
dmitrymomot committed Jan 21, 2024
1 parent 7acbff6 commit 32f9aef
Show file tree
Hide file tree
Showing 4 changed files with 222 additions and 44 deletions.
39 changes: 1 addition & 38 deletions README.md
Expand Up @@ -70,44 +70,7 @@ func findUsers(repo repository.Repository[User]) {

The package includes a full-text search builder to create text queries easily. The text search query uses the [MongoDB text search](https://docs.mongodb.com/manual/text-search/) feature.

#### Creating a Text Index in Your Collection

When you want to create a text index, specify the field and use the TextIndex option. This field typically stores the text you want to search. MongoDB uses this field to determine if a document is a match. You can also specify the weights for each field to control the relative score of each field.

```go
// Create a text index
err := repo.CreateIndex( context.TODO(), "name", mongorepository.TextIndex(
mongorepository.NewTextIndexConfig(
map[string]int32{
"name": 10,
"bio": 5,
"tags": 1,
},
),
))
```

#### Document Structure

Ensure your documents have a field (like name) that stores the text you want to search. This field is used by MongoDB to determine if a document is a match.

```go
type User struct {
ID primitive.ObjectID `bson:"_id,omitempty"`
Name string `bson:"name"`
Bio string `bson:"bio"`
Tags []string `bson:"tags"`
// Other fields...
}
```

#### Searching for Documents

To search for documents, use the Text helper to create a text search query. The text search query uses the [MongoDB text search](https://docs.mongodb.com/manual/text-search/) feature.

```go
users, err := repo.FindManyByFilter(ctx, 0, 10, mongorepository.TextSearch("John"))
```
How to use it - see the tests: [full_text_search_test.go](full_text_search_test.go).

### TTL Index

Expand Down
90 changes: 90 additions & 0 deletions full_text_search.go
@@ -0,0 +1,90 @@
package mongorepository

import (
"context"
"errors"
"fmt"

"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
)

// CreateFullTextIndex creates a full-text index in the MongoDB collection based on the specified key and options.
// It takes a context.Context as the first argument, the key for the index as the second argument,
// and optional IndexOption(s) as the third argument(s).
// The function returns an error if the index creation fails.
func (r *mongoRepository[T]) CreateFullTextIndex(ctx context.Context, keys map[string]int32, lang string) error {
// Build the index keys and weights
idxKeys := make(bson.D, 0, len(keys))
weights := make(bson.D, 0, len(keys))
for k, w := range keys {
idxKeys = append(idxKeys, bson.E{Key: k, Value: "text"})
weights = append(weights, bson.E{Key: k, Value: w})
}
if lang == "" {
lang = "english"
}

// Set index options
idxOpt := options.Index()
idxOpt.SetWeights(weights)
idxOpt.SetDefaultLanguage(lang)
idxOpt.SetName(fmt.Sprintf("%s_fts_index", lang))
idxOpt.SetSparse(true)

// Create the index
indexModel := mongo.IndexModel{
Keys: idxKeys,
Options: idxOpt,
}

// Create the index
if _, err := r.collection.Indexes().CreateOne(ctx, indexModel); err != nil {
return errors.Join(ErrFailedToCreateIndex, err)
}
return nil
}

// Search finds documents in the collection based on the provided search term.
// It allows skipping a certain number of documents and limiting the number of documents to be returned.
// The function returns a slice of documents of type T and an error.
func (r *mongoRepository[T]) Search(ctx context.Context, skip, limit int64, searchTerm string) ([]T, error) {
filter := bson.M{"$text": bson.M{"$search": searchTerm}}
if limit == 0 {
limit = 10
}
// Set the find options
findOptions := options.Find().
SetSkip(skip).
SetLimit(limit).
SetProjection(bson.M{"score": bson.M{"$meta": "textScore"}}).
SetSort(bson.D{{Key: "score", Value: bson.M{"$meta": "textScore"}}})
// Find documents
cursor, err := r.collection.Find(ctx, filter, findOptions)
if err != nil {
if errors.Is(err, mongo.ErrNoDocuments) {
return nil, errors.Join(ErrFailedToFindManyByFilter, ErrNotFound, err)
}
return nil, errors.Join(ErrFailedToFindManyByFilter, err)
}
defer cursor.Close(ctx)

var results []T
for cursor.Next(ctx) {
var element T
if err := cursor.Decode(&element); err != nil {
return nil, errors.Join(ErrFailedToFindManyByFilter, err)
}
results = append(results, element)
}

if err := cursor.Err(); err != nil {
return nil, errors.Join(ErrFailedToFindManyByFilter, err)
}
if len(results) == 0 {
return nil, errors.Join(ErrFailedToFindManyByFilter, ErrNotFound)
}

return results, nil
}
125 changes: 125 additions & 0 deletions full_text_search_test.go
@@ -0,0 +1,125 @@
package mongorepository_test

import (
"context"
"testing"

mongorepository "github.com/dmitrymomot/mongo-repository"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.mongodb.org/mongo-driver/bson/primitive"
)

func TestFullTextSearch(t *testing.T) {
type User struct {
ID primitive.ObjectID `bson:"_id,omitempty"`
Name string `bson:"name"`
Bio string `bson:"bio,omitempty"`
Tags []string `bson:"tags,omitempty"`
}

db := setupMongoDB(t)
repo := mongorepository.NewMongoRepository[User](db, "users")

// Create unique index for email field
require.NoError(t, repo.CreateFullTextIndex(
context.Background(),
map[string]int32{
"name": 10,
"bio": 5,
"tags": 1,
},
"english",
))

// Create test users
users := []User{
{
ID: primitive.NewObjectID(),
Name: "Test John Doe",
Bio: "Software Engineer",
Tags: []string{"go", "mongodb", "developer", "test"},
},
{
ID: primitive.NewObjectID(),
Name: "Jane Smith",
Bio: "Data Scientist",
Tags: []string{"python", "machine learning", "data analysis"},
},
{
ID: primitive.NewObjectID(),
Name: "Kayla TestJohnson",
Bio: "Frontend Developer",
Tags: []string{"javascript", "react", "web development"},
},
{
ID: primitive.NewObjectID(),
Name: "Alex Brown",
Bio: "Backend Test Developer",
Tags: []string{"golang", "spring", "web development"},
},
{
ID: primitive.NewObjectID(),
Name: "Emily Davis",
Bio: "UI/UX Designer Test",
Tags: []string{"design", "user experience", "prototyping"},
},
{
ID: primitive.NewObjectID(),
Name: "Michael Wilson",
Bio: "Golang Engineer",
Tags: []string{"test", "docker", "kubernetes", "cloud", "microservices", "go-test"},
},
{
ID: primitive.NewObjectID(),
Name: "Clark Thompson",
Bio: "Product Manager",
Tags: []string{"agile", "scrum", "product development", "testify"},
},
{
ID: primitive.NewObjectID(),
Name: "David Lee",
Bio: "Web Developer",
Tags: []string{"javascript", "node.js", "react", "web development"},
},
{
ID: primitive.NewObjectID(),
Name: "Jessica Martinez",
Bio: "Mobile App Developer",
Tags: []string{"android", "java", "kotlin"},
},
{
ID: primitive.NewObjectID(),
Name: "Ryan Clark",
Bio: "Data Engineer",
Tags: []string{"big data", "hadoop", "test"},
},
}
for _, user := range users {
// Test Create
id, err := repo.Create(context.Background(), user)
require.NoError(t, err)
require.NotEmpty(t, id)
}

// Test full text search
t.Run("Search", func(t *testing.T) {
users, err := repo.Search(context.Background(), 0, 10, "test")
require.NoError(t, err)
assert.Len(t, users, 5)
assert.Equal(t, "Test John Doe", users[0].Name)
assert.Equal(t, "Alex Brown", users[1].Name)
assert.Equal(t, "Emily Davis", users[2].Name)
assert.Equal(t, "Michael Wilson", users[3].Name)
assert.Equal(t, "Ryan Clark", users[4].Name)
})

// Test full text search with exclusion
t.Run("SearchExclude", func(t *testing.T) {
users, err := repo.Search(context.Background(), 0, 10, "web -test")
require.NoError(t, err)
assert.Len(t, users, 2)
assert.Equal(t, "David Lee", users[0].Name)
assert.Equal(t, "Kayla TestJohnson", users[1].Name)
})
}
12 changes: 6 additions & 6 deletions repository_test.go
Expand Up @@ -10,13 +10,13 @@ import (
"go.mongodb.org/mongo-driver/bson/primitive"
)

type User struct {
ID primitive.ObjectID `bson:"_id,omitempty"`
Name string `bson:"name"`
Email string `bson:"email"`
}

func TestRepository(t *testing.T) {
type User struct {
ID primitive.ObjectID `bson:"_id,omitempty"`
Name string `bson:"name"`
Email string `bson:"email"`
}

db := setupMongoDB(t)
repo := mongorepository.NewMongoRepository[User](db, "users")

Expand Down

0 comments on commit 32f9aef

Please sign in to comment.