Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add wrapper function to support MongoDB transactions #12

Merged
merged 6 commits into from
Dec 21, 2023
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,6 @@ linters:
- nilnil # checks that there is no simultaneous return of nil error and an invalid value
- noctx # finds sending http request without context.Context
- nolintlint # reports ill-formed or insufficient nolint directives
- nonamedreturns # reports all named returns
- nosprintfhostport # checks for misuse of Sprintf to construct a host with port in a URL
- predeclared # finds code that shadows one of Go's predeclared identifiers
- promlinter # checks Prometheus metrics naming via promlint
Expand All @@ -269,8 +268,9 @@ linters:
#- decorder # checks declaration order and count of types, constants, variables and functions
#- ginkgolinter # [if you use ginkgo/gomega] enforces standards of using ginkgo and gomega
#- goheader # checks is file header matches to pattern
# - godox # detects FIXME, TODO and other comment keywords
#- godox # detects FIXME, TODO and other comment keywords
#- ireturn # accept interfaces, return concrete types
#- nonamedreturns # reports all named returns
#- prealloc # [premature optimization, but can be used in some cases] finds slice declarations that could potentially be preallocated
#- tagalign # checks that struct tags are well aligned
#- varnamelen # [great idea, but too many false positives] checks that the length of a variable's name matches its scope
Expand Down
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,10 @@ For existing database connection,
import "github.com/Lyearn/mgod"

func init() {
// dbConn is the database connection obtained using Go Mongo Driver's Connect method.
mgod.SetDefaultConnection(dbConn)
dbName := "mgod-test"

// client is the MongoDB client obtained using Go Mongo Driver's Connect method.
mgod.SetDefaultConnection(client, dbName)
}
```

Expand Down Expand Up @@ -143,9 +145,9 @@ Inspired by the easy interface of MongoDB handling using [Mongoose](https://gith

## Future Scope
The current version of mgod is a stable release. However, there are plans to add a lot more features like -
- [ ] Enable functionality to opt out of the default conversion of date fields to ISOString format.
- [x] Implement a setup step for storing a default Mongo connection, eliminating the need to pass it during EntityMongoModel creation.
- [ ] Provide support for transactions following the integration of default Mongo connection logic.
- [x] Provide support for transactions following the integration of default Mongo connection logic.
- [ ] Enable functionality to opt out of the default conversion of date fields to ISOString format.
- [ ] Develop easy to use wrapper functions around MongoDB Aggregation operation.
- [ ] Introduce automatic MongoDB collection selection based on Go struct names as a default behavior.
- [ ] Add test cases to improve code coverage.
Expand Down
16 changes: 9 additions & 7 deletions connection.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,27 +8,29 @@ import (
"go.mongodb.org/mongo-driver/mongo/options"
)

var mClient *mongo.Client
var dbConn *mongo.Database
var defaultTimeout = 10 * time.Second

// ConnectionConfig is the configuration options available for a MongoDB connection.
type ConnectionConfig struct {
// Timeout is the timeout for various operations performed on the MongoDB server like Connect, Ping, Session etc.
// Timeout is the timeout for various operations performed on the MongoDB server like Connect, Ping etc.
Timeout time.Duration
}

// SetDefaultConnection sets the default connection to be used by the package.
func SetDefaultConnection(conn *mongo.Database) {
dbConn = conn
func SetDefaultConnection(client *mongo.Client, dbName string) {
mClient = client
dbConn = mClient.Database(dbName)
}

// ConfigureDefaultConnection opens a new connection using the provided config options and sets it as a default connection to be used by the package.
func ConfigureDefaultConnection(cfg *ConnectionConfig, dbName string, opts ...*options.ClientOptions) error {
func ConfigureDefaultConnection(cfg *ConnectionConfig, dbName string, opts ...*options.ClientOptions) (err error) {
if cfg == nil {
cfg = defaultConnectionConfig()
}

client, err := newClient(cfg, opts...)
mClient, err = newClient(cfg, opts...)
if err != nil {
return err
}
Expand All @@ -37,12 +39,12 @@ func ConfigureDefaultConnection(cfg *ConnectionConfig, dbName string, opts ...*o
defer cancel()

// Ping the MongoDB server to check if connection is established.
err = client.Ping(ctx, nil)
err = mClient.Ping(ctx, nil)
if err != nil {
return err
}

dbConn = client.Database(dbName)
dbConn = mClient.Database(dbName)

return nil
}
Expand Down
3 changes: 2 additions & 1 deletion docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,5 @@ This directory contains user-facing documentation. For those who wish to underst
* [Meta fields](./meta_fields.md)

### Advanced Guide
* [Unions](./union_types.md)
* [Unions](./union_types.md)
* [Transactions](./transactions.md)
6 changes: 4 additions & 2 deletions docs/basic_usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@ For existing database connection,
import "github.com/Lyearn/mgod"

func init() {
// dbConn is the database connection obtained using Go Mongo Driver's Connect method.
mgod.SetDefaultConnection(dbConn)
dbName := "mgod-test"

// client is the MongoDB client obtained using Go Mongo Driver's Connect method.
mgod.SetDefaultConnection(client, dbName)
}
```

Expand Down
60 changes: 60 additions & 0 deletions docs/transactions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
---
title: Transactions
---

`mgod` provides a wrapper function `WithTransaction` that supports MongoDB transactions, allowing users to perform a series of read and write operations as a single atomic unit.

## Usage

Configure default connection with `mgod`.

```go
cfg := &mgod.ConnectionConfig{Timeout: 5 * time.Second}
dbName := "mgod_test"
opts := options.Client().ApplyURI("mongodb://localhost:27017/?replicaSet=mgod_rs&authSource=admin")

err := mgod.ConfigureDefaultConnection(cfg, dbName, opts)
```

:::info
To use Transactions, it is compulsory to run MongoDB daemon as a replica set.
Refer Community Forum Discussion - [Why replica set is mandatory for transactions in MongoDB?](https://www.mongodb.com/community/forums/t/why-replica-set-is-mandatory-for-transactions-in-mongodb/9533)
:::

Create models to be used inside a MongoDB transaction.

```go
type User struct {
Name string
EmailID string `bson:"emailId"`
}

schemaOpts := schemaopt.SchemaOptions{
Collection: "users",
Timestamps: true,
}

userModel, _ := mgod.NewEntityMongoModel(User{}, schemaOpts)
```

Use `WithTransaction` function to perform multiple CRUD operations as an atomic unit.

```go
userDoc1 := User{Name: "Gopher1", EmailID: "gopher1@mgod.com"}
userDoc2 := User{Name: "Gopher2", EmailID: "gopher2@mgod.com"}

_, err := mgod.WithTransaction(context.Background(), func(sc mongo.SessionContext) (interface{}, error) {
_, err1 := s.userModel.InsertOne(sc, userDoc1)
_, err2 := s.userModel.InsertOne(sc, userDoc2)

if err1 != nil || err2 != nil {
return nil, errors.New("abort transaction")
}

return nil, nil
})
```

:::warning
Make sure to pass the session's context (`sc` here) only in EntityMongoModel's operation functions.
:::
33 changes: 33 additions & 0 deletions transaction.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package mgod

import (
"context"
"log/slog"

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

// TransactionFunc is the function that is executed in a MongoDB transaction.
//
// SessionContext(sc) combines the context.Context and mongo.Session interfaces.
type TransactionFunc func(sc mongo.SessionContext) (interface{}, error)

// WithTransaction executes the given transaction function with a new session.
func WithTransaction(ctx context.Context, transactionFunc TransactionFunc) (interface{}, error) {
session, err := mClient.StartSession()
if err != nil {
slog.ErrorContext(ctx, "Error occurred during WithTransaction", err)
return nil, err
}
defer session.EndSession(ctx)

// Reason behind using read preference:
// https://www.mongodb.com/community/forums/t/why-can-t-read-preference-be-secondary-in-a-transaction/204432
payload, transactionErr := session.WithTransaction(ctx, transactionFunc, &options.TransactionOptions{
ReadPreference: readpref.Primary(),
})

return payload, transactionErr
}
103 changes: 103 additions & 0 deletions transaction_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package mgod_test

import (
"context"
"errors"
"testing"
"time"

"github.com/Lyearn/mgod"
"github.com/Lyearn/mgod/schema/schemaopt"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
)

type TransactionSuite struct {
suite.Suite
*require.Assertions

userModel mgod.EntityMongoModel[TransactionTestUser]
}

type TransactionTestUser struct {
Name string
EmailID string `bson:"emailId"`
}

func TestTransactionSuite(t *testing.T) {
s := new(TransactionSuite)
suite.Run(t, s)
}

func (s *TransactionSuite) SetupTest() {
s.Assertions = require.New(s.T())
s.SetupConnectionAndModel()
}

func (s *TransactionSuite) SetupConnectionAndModel() {
cfg := &mgod.ConnectionConfig{Timeout: 5 * time.Second}
dbName := "mgod_test"
opts := options.Client().ApplyURI("mongodb://localhost:27017/?replicaSet=mgod_rs&authSource=admin")

err := mgod.ConfigureDefaultConnection(cfg, dbName, opts)
if err != nil {
s.T().Fatal(err)
}

schemaOpts := schemaopt.SchemaOptions{
Collection: "users",
Timestamps: true,
}
userModel, err := mgod.NewEntityMongoModel(TransactionTestUser{}, schemaOpts)
if err != nil {
s.T().Fatal(err)
}

s.userModel = userModel
}

func (s *TransactionSuite) TestWithTransaction() {
userDoc := TransactionTestUser{Name: "Gopher", EmailID: "gopher@mgod.com"}

p, err := mgod.WithTransaction(context.Background(), func(sc mongo.SessionContext) (interface{}, error) {
user, err := s.userModel.InsertOne(sc, userDoc)
return user, err
})

user, ok := p.(TransactionTestUser)

s.True(ok)
s.NoError(err)
s.Equal(user.Name, userDoc.Name)
s.Equal(user.EmailID, userDoc.EmailID)

userCount, err := s.userModel.CountDocuments(context.Background(), bson.M{})

s.NoError(err)
s.Equal(userCount, int64(1))
}

func (s *TransactionSuite) TestWithTransactionAbort() {
userDoc := TransactionTestUser{Name: "Gopher", EmailID: "gopher@mgod.com"}

abortErr := errors.New("dummy error to abort transaction")

_, err := mgod.WithTransaction(context.Background(), func(sc mongo.SessionContext) (interface{}, error) {
_, err := s.userModel.InsertOne(sc, userDoc)
if err != nil {
return nil, err
}

return nil, abortErr
})

s.EqualError(err, abortErr.Error())

userCount, err := s.userModel.CountDocuments(context.Background(), bson.M{})

s.NoError(err)
s.Equal(userCount, int64(0))
}