diff --git a/.golangci.yml b/.golangci.yml index 9e9b000..cea9bdf 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -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 @@ -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 diff --git a/README.md b/README.md index 2f6a0de..5b2230b 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ - Easily manage **meta fields** in models without cluttering Go structs. - Supports **union types**, expanding data capabilities. - Implement strict field requirements with struct tags for **data integrity**. +- Built-in support for **multi-tenant** systems. - Wrapper around the **official** Mongo Go Driver. ## Requirements @@ -42,8 +43,8 @@ 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) + // client is the MongoDB client obtained using Go Mongo Driver's Connect method. + mgod.SetDefaultClient(client) } ``` @@ -59,10 +60,9 @@ import ( func init() { // `cfg` is optional. Can rely on default configurations by providing `nil` value in argument. cfg := &mgod.ConnectionConfig{Timeout: 5 * time.Second} - dbName := "mgod-test" opts := options.Client().ApplyURI("mongodb://root:mgod123@localhost:27017") - err := mgod.ConfigureDefaultConnection(cfg, dbName, opts) + err := mgod.ConfigureDefaultClient(cfg, opts) } ``` @@ -84,12 +84,11 @@ import ( ) model := User{} -schemaOpts := schemaopt.SchemaOptions{ - Collection: "users", - Timestamps: true, -} +dbName := "mgoddb" +collection := "users" -userModel, _ := mgod.NewEntityMongoModel(model, schemaOpts) +opts := mgod.NewEntityMongoModelOptions(dbName, collection, nil) +userModel, _ := mgod.NewEntityMongoModel(model, *opts) ``` Use the entity ODM to perform CRUD operations with ease. @@ -106,14 +105,12 @@ user, _ := userModel.InsertOne(context.TODO(), userDoc) ``` **Output:** -```json +```js { "_id": ObjectId("65697705d4cbed00e8aba717"), "name": "Gopher", "emailId": "gopher@mgod.com", "joinedOn": ISODate("2023-12-01T11:32:19.290Z"), - "createdAt": ISODate("2023-12-01T11:32:19.290Z"), - "updatedAt": ISODate("2023-12-01T11:32:19.290Z"), "__v": 0 } ``` @@ -143,9 +140,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. diff --git a/connection.go b/connection.go index d6aab0c..a2435f0 100644 --- a/connection.go +++ b/connection.go @@ -8,27 +8,27 @@ import ( "go.mongodb.org/mongo-driver/mongo/options" ) -var dbConn *mongo.Database +var mClient *mongo.Client 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 +// SetDefaultClient sets the default MongoDB client to be used by the package. +func SetDefaultClient(client *mongo.Client) { + mClient = client } -// 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 { +// ConfigureDefaultClient opens a new connection using the provided config options and sets the default MongoDB client to be used by the package. +func ConfigureDefaultClient(cfg *ConnectionConfig, 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 } @@ -37,13 +37,11 @@ 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) - return nil } diff --git a/connection_cache.go b/connection_cache.go new file mode 100644 index 0000000..8c9faeb --- /dev/null +++ b/connection_cache.go @@ -0,0 +1,54 @@ +package mgod + +import ( + "sync" + + "go.mongodb.org/mongo-driver/mongo" +) + +// dbConnCache is a cache of MongoDB database connections. +var dbConnCache *connectionCache + +func init() { + dbConnCache = newConnectionCache() +} + +// connectionCache is a thread safe construct to cache MongoDB database connections. +type connectionCache struct { + cache map[string]*mongo.Database + mux sync.RWMutex +} + +func newConnectionCache() *connectionCache { + return &connectionCache{ + cache: map[string]*mongo.Database{}, + } +} + +func (c *connectionCache) Get(dbName string) *mongo.Database { + c.mux.RLock() + defer c.mux.RUnlock() + + return c.cache[dbName] +} + +func (c *connectionCache) Set(dbName string, db *mongo.Database) { + c.mux.Lock() + defer c.mux.Unlock() + + c.cache[dbName] = db +} + +// getDBConn returns a MongoDB database connection from the cache. +// If the connection is not present in the cache, it creates a new connection and adds it to the cache (Write-through policy). +func getDBConn(dbName string) *mongo.Database { + dbConn := dbConnCache.Get(dbName) + + // Initialize the cache entry if it is not present. + if dbConn == nil { + dbConn = mClient.Database(dbName) + dbConnCache.Set(dbName, dbConn) + } + + return dbConn +} diff --git a/docs/README.md b/docs/README.md index 94d2408..3d05b44 100644 --- a/docs/README.md +++ b/docs/README.md @@ -18,4 +18,6 @@ This directory contains user-facing documentation. For those who wish to underst * [Meta fields](./meta_fields.md) ### Advanced Guide -* [Unions](./union_types.md) \ No newline at end of file +* [Multi Tenancy](./multi_tenancy.md) +* [Unions](./union_types.md) +* [Transactions](./transactions.md) diff --git a/docs/basic_usage.md b/docs/basic_usage.md index c2cc8a9..0d96a28 100644 --- a/docs/basic_usage.md +++ b/docs/basic_usage.md @@ -9,8 +9,8 @@ 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) + // client is the MongoDB client obtained using Go Mongo Driver's Connect method. + mgod.SetDefaultClient(client) } ``` @@ -26,10 +26,9 @@ import ( func init() { // `cfg` is optional. Can rely on default configurations by providing `nil` value in argument. cfg := &mgod.ConnectionConfig{Timeout: 5 * time.Second} - dbName := "mgod-test" opts := options.Client().ApplyURI("mongodb://root:mgod123@localhost:27017") - err := mgod.ConfigureDefaultConnection(cfg, dbName, opts) + err := mgod.ConfigureDefaultClient(cfg, opts) } ``` @@ -57,12 +56,15 @@ import ( ) model := User{} +dbName := "mgoddb" +collection := "users" + schemaOpts := schemaopt.SchemaOptions{ - Collection: "users", Timestamps: true, } -userModel, _ := mgod.NewEntityMongoModel(model, schemaOpts) +opts := mgod.NewEntityMongoModelOptions(dbName, collection, &schemaOpts) +userModel, _ := mgod.NewEntityMongoModel(model, *opts) ``` Use the entity ODM to perform CRUD operations with ease. diff --git a/docs/meta_fields.md b/docs/meta_fields.md index 021de52..1b1c4ea 100644 --- a/docs/meta_fields.md +++ b/docs/meta_fields.md @@ -25,7 +25,6 @@ It is the meta field that stores the timestamp of the document creation. This fi ```go schemaOpts := schemaopt.SchemaOptions{ - Collection: "users", Timestamps: true, } @@ -33,6 +32,7 @@ userDoc := User{ Name: "Gopher", EmailID: "gopher@mgod.com", } + user, _ := userModel.InsertOne(context.TODO(), userDoc) ``` @@ -59,7 +59,6 @@ It is the meta field that stores the timestamp of the document updation. This fi ```go schemaOpts := schemaopt.SchemaOptions{ - Collection: "users", Timestamps: true, } @@ -98,7 +97,6 @@ It is the field that stores the version of the document. This field is automatic ```go schemaOpts := schemaopt.SchemaOptions{ - Collection: "users", VersionKey: true } @@ -106,6 +104,7 @@ userDoc := User{ Name: "Gopher", EmailID: "gopher@mgod.com", } + user, _ := userModel.InsertOne(context.TODO(), userDoc) ``` @@ -124,7 +123,6 @@ If `VersionKey` is set to `false`. ```go schemaOpts := schemaopt.SchemaOptions{ - Collection: "users", VersionKey: false } @@ -132,6 +130,7 @@ userDoc := User{ Name: "Gopher", EmailID: "gopher@mgod.com", } + user, _ := userModel.InsertOne(context.TODO(), userDoc) ``` diff --git a/docs/multi_tenancy.md b/docs/multi_tenancy.md new file mode 100644 index 0000000..af41cc0 --- /dev/null +++ b/docs/multi_tenancy.md @@ -0,0 +1,42 @@ +--- +title: Multi Tenancy +--- + +`mgod` comes with the built-in support for multi-tenancy, enabling the use of a single Go struct with multiple databases. This feature allows creation of multiple `EntityMongoModel` of the same Go struct to be attached to different databases while using the same underlying MongoDB client connection. + +## Usage + +Create separate `EntityMongoModel` for different tenants using same Go struct and corresponding databases. + +```go +type User struct { + Name string + EmailID string `bson:"emailId"` + Amount float32 +} +collection := "users" + +tenant1DB := "tenant1" +tenant2DB := "tenant2" + +tenant1Model, _ := mgod.NewEntityMongoModelOptions(tenant1DB, collection, nil) +tenant2Model, _ := mgod.NewEntityMongoModelOptions(tenant2DB, collection, nil) +``` + +These models can now be used simultaneously inside the same service logic as well as in a transaction operation. + +```go +amount := 10000 + +tenant1Model.UpdateMany(context.TODO(), bson.M{"name": "Gopher Tenant 1"}, bson.M{"$inc": {"amount": -amount}}) +tenant2Model.UpdateMany(context.TODO(), bson.M{"name": "Gopher Tenant 2"}, bson.M{"$inc": {"amount": amount}}) +``` + +:::note +The `EntityMongoModel` is always bound to the specified database at the time of its declaration and, as such, cannot be used to perform operations across multiple databases. +::: + +```go +result, _ := tenant1Model.FindOne(context.TODO(), bson.M{"name": "Gopher Tenant 2"}) +// result will be value in this case +``` diff --git a/docs/schema_options.md b/docs/schema_options.md index dbd18d7..41b09ff 100644 --- a/docs/schema_options.md +++ b/docs/schema_options.md @@ -6,21 +6,6 @@ Schema Options is Mongo Schema level options (which modifies actual MongoDB doc) `mgod` supports the following schema options - -## Collection - -- Accepts Type: `string` -- Is Optional: `No` - -It is the name of the mongo collection in which the entity is stored. For example, `users` collection of MongoDB for `User` model in Golang. - -### Usage - -```go -schemaOpts := schemaopt.SchemaOptions{ - Collection: "users", // MongoDB collection name -} -``` - ## Timestamps - Accepts Type: `bool` @@ -33,7 +18,6 @@ It is used to track `createdAt` and `updatedAt` meta fields for the entity. See ```go schemaOpts := schemaopt.SchemaOptions{ - Collection: "users", Timestamps: true, } ``` @@ -50,7 +34,6 @@ This reports whether to add a version key (`__v`) for the entity. See [Meta Fiel ```go schemaOpts := schemaopt.SchemaOptions{ - Collection: "users", VersionKey: true, } ``` @@ -67,7 +50,6 @@ It defines whether the entity is a union type. See [Union Types](union_types.md) ```go schemaOpts := schemaopt.SchemaOptions{ - Collection: "resources", IsUnionType: true, } ``` @@ -90,7 +72,6 @@ It is the key used to identify the underlying type in case of a union type entit ```go schemaOpts := schemaopt.SchemaOptions{ - Collection: "resources", IsUnionType: true, DiscriminatorKey: "type", } diff --git a/docs/transactions.md b/docs/transactions.md new file mode 100644 index 0000000..171a8b2 --- /dev/null +++ b/docs/transactions.md @@ -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} +opts := options.Client().ApplyURI("mongodb://localhost:27017/?replicaSet=mgod_rs&authSource=admin") + +err := mgod.ConfigureDefaultClient(cfg, 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"` +} + +dbName := "mgoddb" +collection := "users" +schemaOpts := schemaopt.SchemaOptions{ + Timestamps: true, +} + +userModel, _ := mgod.NewEntityMongoModelOptions(dbName, collection, &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. +::: diff --git a/entity_mongo_model.go b/entity_mongo_model.go index e40d8c5..dcdb48c 100644 --- a/entity_mongo_model.go +++ b/entity_mongo_model.go @@ -74,12 +74,13 @@ type entityMongoModel[T any] struct { } // NewEntityMongoModel returns a new instance of EntityMongoModel for the provided model type and options. -func NewEntityMongoModel[T any](modelType T, schemaOpts schemaopt.SchemaOptions) (EntityMongoModel[T], error) { +func NewEntityMongoModel[T any](modelType T, opts entityMongoModelOptions) (EntityMongoModel[T], error) { + dbConn := getDBConn(opts.connOpts.db) if dbConn == nil { return nil, errors.ErrNoDatabaseConnection } - coll := dbConn.Collection(schemaOpts.Collection) + coll := dbConn.Collection(opts.connOpts.coll) modelName := schema.GetSchemaNameForModel(modelType) schemaCacheKey := GetSchemaCacheKey(coll.Name(), modelName) @@ -87,6 +88,11 @@ func NewEntityMongoModel[T any](modelType T, schemaOpts schemaopt.SchemaOptions) var entityModelSchema *schema.EntityModelSchema var err error + schemaOpts := schemaopt.SchemaOptions{} + if opts.schemaOpts != nil { + schemaOpts = *opts.schemaOpts + } + // build schema if not cached. if entityModelSchema, err = schema.EntityModelSchemaCacheInstance.GetSchema(schemaCacheKey); err != nil { entityModelSchema, err = schema.BuildSchemaForModel(modelType, schemaOpts) diff --git a/entity_mongo_model_opts.go b/entity_mongo_model_opts.go new file mode 100644 index 0000000..d0fa926 --- /dev/null +++ b/entity_mongo_model_opts.go @@ -0,0 +1,31 @@ +package mgod + +import ( + "github.com/Lyearn/mgod/schema/schemaopt" +) + +type entityMongoModelOptions struct { + connOpts connectionOptions + schemaOpts *schemaopt.SchemaOptions +} + +type connectionOptions struct { + db string + coll string +} + +// NewEntityMongoModelOptions creates a new entityMongoModelOptions instance. +// Its instance is used to provide necessary configuration options to the NewEntityMongoModel function. +// +// dbName is the name of the database in which the entity is stored. +// collection is the name of the mongo collection in which the entity is stored. +// schemaOpts is the schema level options for the entity. +func NewEntityMongoModelOptions(dbName string, collection string, schemaOpts *schemaopt.SchemaOptions) *entityMongoModelOptions { + return &entityMongoModelOptions{ + connOpts: connectionOptions{ + db: dbName, + coll: collection, + }, + schemaOpts: schemaOpts, + } +} diff --git a/entity_mongo_model_test.go b/entity_mongo_model_test.go index fc3948c..f394324 100644 --- a/entity_mongo_model_test.go +++ b/entity_mongo_model_test.go @@ -6,198 +6,149 @@ import ( "time" "github.com/Lyearn/mgod" - "github.com/Lyearn/mgod/dateformatter" "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/bson/primitive" "go.mongodb.org/mongo-driver/mongo" - "go.mongodb.org/mongo-driver/mongo/integration/mtest" + "go.mongodb.org/mongo-driver/mongo/options" ) type EntityMongoModelSuite struct { suite.Suite *require.Assertions +} - dbName string - collName string - - mt *mtest.T - mtOpts *mtest.Options +type testEntity struct { + ID string `bson:"_id" mgoType:"id"` + Name string + Age *int `bson:",omitempty" mgoDefault:"18"` } func TestEntityMongoModelSuite(t *testing.T) { s := new(EntityMongoModelSuite) - s.dbName = "foo" - s.collName = "bar" - suite.Run(t, s) } +func (s *EntityMongoModelSuite) SetupSuite() { + s.setupConnection() + s.setupData() +} + func (s *EntityMongoModelSuite) SetupTest() { s.Assertions = require.New(s.T()) +} - mtOpts := mtest.NewOptions() - mtOpts = mtOpts.ClientType(mtest.Mock) - mtOpts = mtOpts.DatabaseName(s.dbName) - mtOpts = mtOpts.CollectionName(s.collName) +func (s *EntityMongoModelSuite) TearDownSuite() { + entityMongoModel := s.getModel() + _, err := entityMongoModel.DeleteMany(context.Background(), bson.D{}) + if err != nil { + s.T().Fatal(err) + } +} - mt := mtest.New(s.T(), mtOpts) +func (s *EntityMongoModelSuite) setupConnection() { + cfg := &mgod.ConnectionConfig{Timeout: 5 * time.Second} + uri := "mongodb://localhost:27017/?replicaSet=replset&authSource=admin" - s.mt = mt - s.mtOpts = mtOpts + err := mgod.ConfigureDefaultClient(cfg, options.Client().ApplyURI(uri)) + if err != nil { + s.T().Fatal(err) + } } -func (s *EntityMongoModelSuite) ns() string { - return s.dbName + "." + s.collName +func (s *EntityMongoModelSuite) setupData() { + firstID := primitive.NewObjectID() + secondID := primitive.NewObjectID() + + age1 := 30 + age2 := 40 + + entities := []testEntity{ + { + ID: firstID.Hex(), + Name: "Default User 1", + Age: &age1, + }, + { + ID: secondID.Hex(), + Name: "Default User 2", + Age: &age2, + }, + } + + entityMongoModel := s.getModel() + _, err := entityMongoModel.InsertMany(context.Background(), entities) + if err != nil { + s.T().Fatal(err) + } } -type TestEntity struct { - ID string `bson:"_id" mgoType:"id"` - Name string - JoinedOn string `mgoType:"date"` - Age *int `bson:",omitempty" mgoDefault:"18"` +func (s *EntityMongoModelSuite) getModel() mgod.EntityMongoModel[testEntity] { + dbName := "mgoddb" + collection := "entityMongoModel" + schemaOpts := schemaopt.SchemaOptions{Timestamps: true} + + opts := mgod.NewEntityMongoModelOptions(dbName, collection, &schemaOpts) + model, err := mgod.NewEntityMongoModel(testEntity{}, *opts) + if err != nil { + s.T().Fatal(err) + } + + return model } func (s *EntityMongoModelSuite) TestFind() { - defer s.mt.Close() - - s.mt.RunOpts("find", s.mtOpts, func(mt *mtest.T) { - currentTime := time.Now() - currentTimeStr, _ := dateformatter.New(currentTime).GetISOString() - - firstID := primitive.NewObjectID() - secondID := primitive.NewObjectID() - - //nolint:govet // this is a mock entity. - firstEntity := TestEntity{ - ID: firstID.Hex(), - Name: "test1", - JoinedOn: currentTimeStr, - } - //nolint:govet // this is a mock entity. - secondEntity := TestEntity{ - ID: secondID.Hex(), - Name: "test2", - JoinedOn: currentTimeStr, - } - - first := mtest.CreateCursorResponse(1, s.ns(), mtest.FirstBatch, bson.D{ - {Key: "_id", Value: firstID}, - {Key: "name", Value: firstEntity.Name}, - {Key: "joinedon", Value: primitive.NewDateTimeFromTime(currentTime)}, - }) - second := mtest.CreateCursorResponse(1, s.ns(), mtest.NextBatch, bson.D{ - {Key: "_id", Value: secondID}, - {Key: "name", Value: secondEntity.Name}, - {Key: "joinedon", Value: primitive.NewDateTimeFromTime(currentTime)}, - }) - killCursors := mtest.CreateCursorResponse(0, s.ns(), mtest.NextBatch) - - mt.AddMockResponses(first, second, killCursors) - - opts := schemaopt.SchemaOptions{Collection: s.collName} - entityMongoModel, err := mgod.NewEntityMongoModel(TestEntity{}, opts) - s.Nil(err) - - testEntities, err := entityMongoModel.Find(context.Background(), bson.D{ - {Key: "name", Value: firstEntity.Name}, - }) - - s.Nil(err) - s.Equal(2, len(testEntities)) + entityMongoModel := s.getModel() + entities, err := entityMongoModel.Find(context.Background(), bson.M{ + "age": bson.M{ + "$gt": 20, + }, + "name": bson.M{ + "$regex": "Default", + }, }) + + s.NoError(err) + s.Equal(2, len(entities)) } func (s *EntityMongoModelSuite) TestFindOne() { - defer s.mt.Close() - - s.mt.RunOpts("find one", s.mtOpts, func(mt *mtest.T) { - currentTime := time.Now() - currentTimeStr, _ := dateformatter.New(currentTime).GetISOString() - - id := primitive.NewObjectID() - - //nolint:govet // this is a mock entity. - entity := TestEntity{ - ID: id.Hex(), - Name: "test", - JoinedOn: currentTimeStr, - } - - mt.AddMockResponses(mtest.CreateCursorResponse(1, s.ns(), mtest.FirstBatch, bson.D{ - {Key: "_id", Value: id}, - {Key: "name", Value: entity.Name}, - {Key: "joinedon", Value: primitive.NewDateTimeFromTime(currentTime)}, - })) - - opts := schemaopt.SchemaOptions{Collection: s.collName} - entityMongoModel, err := mgod.NewEntityMongoModel(TestEntity{}, opts) - s.Nil(err) - - testEntity, err := entityMongoModel.FindOne(context.Background(), bson.D{ - {Key: "id", Value: entity.ID}, - }) - - s.Nil(err) - s.Equal(entity.ID, testEntity.ID) + entityMongoModel := s.getModel() + entity, err := entityMongoModel.FindOne(context.Background(), bson.M{ + "age": bson.M{ + "$gt": 30, + }, + "name": bson.M{ + "$regex": "Default", + }, }) + + s.NoError(err) + s.Equal("Default User 2", entity.Name) } func (s *EntityMongoModelSuite) TestInsertOne() { - defer s.mt.Close() - id := primitive.NewObjectID() - currentTime := time.Now() - currentTimeStr, _ := dateformatter.New(currentTime).GetISOString() - - s.mt.RunOpts("insert one", s.mtOpts, func(mt *mtest.T) { - entity := TestEntity{ - ID: id.Hex(), - Name: "test", - JoinedOn: currentTimeStr, - } - - mt.AddMockResponses(mtest.CreateCursorResponse(1, s.ns(), mtest.FirstBatch, bson.D{ - {Key: "_id", Value: id}, - {Key: "name", Value: entity.Name}, - {Key: "joinedon", Value: primitive.NewDateTimeFromTime(currentTime)}, - {Key: "age", Value: 18}, - })) - - opts := schemaopt.SchemaOptions{Collection: s.collName} - entityMongoModel, err := mgod.NewEntityMongoModel(TestEntity{}, opts) - s.Nil(err) - - doc, err := entityMongoModel.InsertOne(context.Background(), entity) - - s.Nil(err) - s.Equal(entity.ID, doc.ID) - s.Equal(18, *doc.Age) - }) - - s.mt.RunOpts("insert one with error", s.mtOpts, func(mt *mtest.T) { - entity := TestEntity{ - ID: id.Hex(), - Name: "test", - JoinedOn: currentTimeStr, - } - - mt.AddMockResponses(mtest.CreateWriteErrorsResponse(mtest.WriteError{ - Index: 1, - Code: 11000, - Message: "duplicate key error", - })) - - opts := schemaopt.SchemaOptions{Collection: s.collName} - entityMongoModel, err := mgod.NewEntityMongoModel(TestEntity{}, opts) - s.Nil(err) - - docID, err := entityMongoModel.InsertOne(context.Background(), entity) - - s.Empty(docID) - s.NotNil(err) - s.True(mongo.IsDuplicateKeyError(err)) - }) + age := 18 + + entity := testEntity{ + ID: id.Hex(), + Name: "test", + Age: &age, + } + + entityMongoModel := s.getModel() + doc, err := entityMongoModel.InsertOne(context.Background(), entity) + + s.Nil(err) + s.Equal(entity.ID, doc.ID) + s.Equal(18, *doc.Age) + + // Test duplicate key error + docID, err := entityMongoModel.InsertOne(context.Background(), entity) + s.Empty(docID) + s.NotNil(err) + s.True(mongo.IsDuplicateKeyError(err)) } diff --git a/schema/schemaopt/schema_options.go b/schema/schemaopt/schema_options.go index cb68773..02c2f1d 100644 --- a/schema/schemaopt/schema_options.go +++ b/schema/schemaopt/schema_options.go @@ -1,11 +1,7 @@ package schemaopt // SchemaOptions is Mongo Schema level options (modifies actual MongoDB doc) that needs to be provided when creating a new EntityMongoModel. -// -// These options are used to identify the collection name, whether to add timestamps meta fields, etc. type SchemaOptions struct { - // Collection is the name of the mongo collection in which the entity is stored. - Collection string // Timestamps reports whether to add createdAt and updatedAt meta fields for the entity. Timestamps bool // VersionKey reports whether to add a version key for the entity. Defaults to true. diff --git a/transaction.go b/transaction.go new file mode 100644 index 0000000..bd5c248 --- /dev/null +++ b/transaction.go @@ -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 +} diff --git a/transaction_test.go b/transaction_test.go new file mode 100644 index 0000000..f542031 --- /dev/null +++ b/transaction_test.go @@ -0,0 +1,159 @@ +package mgod_test + +import ( + "context" + "errors" + "fmt" + "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 +} + +type transactionTestUser struct { + Name string + EmailID string `bson:"emailId"` +} + +func TestTransactionSuite(t *testing.T) { + s := new(TransactionSuite) + suite.Run(t, s) +} + +func (s *TransactionSuite) SetupSuite() { + s.setupConnection() +} + +func (s *TransactionSuite) SetupTest() { + s.Assertions = require.New(s.T()) +} + +func (s *TransactionSuite) setupConnection() { + cfg := &mgod.ConnectionConfig{Timeout: 5 * time.Second} + // Can use the `mlaunch` tool to start a local replica set using command `mlaunch --repl`. + uri := "mongodb://localhost:27017/?replicaSet=replset&authSource=admin" + + err := mgod.ConfigureDefaultClient(cfg, options.Client().ApplyURI(uri)) + if err != nil { + s.T().Fatal(err) + } +} + +func (s *TransactionSuite) getModelForDB(dbName string) mgod.EntityMongoModel[transactionTestUser] { + schemaOpts := schemaopt.SchemaOptions{Timestamps: true} + opts := mgod.NewEntityMongoModelOptions(dbName, "users", &schemaOpts) + userModel, err := mgod.NewEntityMongoModel(transactionTestUser{}, *opts) + if err != nil { + s.T().Fatal(err) + } + + return userModel +} + +func (s *TransactionSuite) TestWithTransaction() { + userModel := s.getModelForDB("mgod1") + userDoc := transactionTestUser{Name: "Gopher", EmailID: "gopher@mgod.com"} + + p, err := mgod.WithTransaction(context.Background(), func(sc mongo.SessionContext) (interface{}, error) { + _, err := userModel.InsertOne(sc, userDoc) + if err != nil { + return nil, err + } + + userCount, err := userModel.CountDocuments(sc, bson.M{}) + if err != nil { + return nil, err + } + + _, err = userModel.DeleteOne(sc, bson.M{}) + if err != nil { + return nil, err + } + + return userCount, nil + }) + + userCount, ok := p.(int64) + + s.NoError(err) + s.True(ok) + s.Equal(userCount, int64(1)) +} + +func (s *TransactionSuite) TestWithTransactionAbort() { + userModel := s.getModelForDB("mgod1") + 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 := userModel.InsertOne(sc, userDoc) + if err != nil { + return nil, err + } + + return nil, abortErr + }) + + s.EqualError(err, abortErr.Error()) + + userCount, err := userModel.CountDocuments(context.Background(), bson.M{}) + + s.NoError(err) + s.Equal(userCount, int64(0)) +} + +func (s *TransactionSuite) TestWithTransactionForMultiTenancy() { + userModelTenant1 := s.getModelForDB("mgod1") + userModelTenant2 := s.getModelForDB("mgod2") + + userDoc := transactionTestUser{Name: "Gopher", EmailID: "gopher@mgod.com"} + + p, err := mgod.WithTransaction(context.Background(), func(sc mongo.SessionContext) (interface{}, error) { + _, err := userModelTenant1.InsertOne(sc, userDoc) + if err != nil { + return nil, err + } + _, err = userModelTenant2.InsertOne(sc, userDoc) + if err != nil { + return nil, err + } + + userCount1, err := userModelTenant1.CountDocuments(sc, bson.M{}) + if err != nil { + return nil, err + } + userCount2, err := userModelTenant2.CountDocuments(sc, bson.M{}) + if err != nil { + return nil, err + } + + _, err = userModelTenant1.DeleteOne(sc, bson.M{}) + if err != nil { + return nil, err + } + _, err = userModelTenant2.DeleteOne(sc, bson.M{}) + if err != nil { + return nil, err + } + + return fmt.Sprintf("%d%d", userCount1, userCount2), nil + }) + + userCountStr, ok := p.(string) + + s.NoError(err) + s.True(ok) + s.Equal(userCountStr, "11") +}