From ae71944c8bb47fb1a58f90bd7ac8a6484dcf7a18 Mon Sep 17 00:00:00 2001 From: Akalanka Perera Date: Thu, 22 May 2025 09:11:06 +0530 Subject: [PATCH 01/22] test: updated a few assertions for accuracy --- tests/core_create_test.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/core_create_test.go b/tests/core_create_test.go index 168b91b..ff6286d 100644 --- a/tests/core_create_test.go +++ b/tests/core_create_test.go @@ -26,7 +26,7 @@ func TestCoreCreate(t *testing.T) { Convey("Create users", t, func() { Convey("Create a single user", func() { user := UserModel.Create(mocks.Ciri).ExecT() - So(user.ID, ShouldNotBeNil) + So(user.ID.IsZero(), ShouldBeFalse) So(user.Name, ShouldEqual, mocks.Ciri.Name) So(user.Age, ShouldEqual, fixtures.DefaultUserAge) So(user.CreatedAt.Unix(), ShouldBeBetweenOrEqual, time.Now().Add(-10*time.Second).Unix(), time.Now().Unix()) @@ -35,8 +35,8 @@ func TestCoreCreate(t *testing.T) { Convey("Create many users", func() { users := UserModel.CreateMany(mocks.Users[1:]).ExecTT() So(len(users), ShouldEqual, len(mocks.Users[1:])) - So(users[0].ID, ShouldNotBeNil) - So(users[1].ID, ShouldNotBeNil) + So(users[0].ID.IsZero(), ShouldBeFalse) + So(users[1].ID.IsZero(), ShouldBeFalse) So(users[0].Name, ShouldEqual, mocks.Geralt.Name) So(users[1].Name, ShouldEqual, mocks.Eredin.Name) So(users[0].Age, ShouldEqual, mocks.Geralt.Age) @@ -45,7 +45,7 @@ func TestCoreCreate(t *testing.T) { Convey("Create a single user in a different database", func() { TEMPORARY_DB := fmt.Sprintf("%s_%s", t.Name(), "temporary_1") user := UserModel.Create(mocks.Ciri).SetDatabase(TEMPORARY_DB).ExecT() - So(user.ID, ShouldNotBeNil) + So(user.ID.IsZero(), ShouldBeFalse) var newUser User elemental.UseDatabase(TEMPORARY_DB).Collection(UserModel.Collection().Name()).FindOne(context.TODO(), primitive.M{"_id": user.ID}).Decode(&newUser) So(newUser.Name, ShouldEqual, mocks.Ciri.Name) @@ -53,7 +53,7 @@ func TestCoreCreate(t *testing.T) { Convey("Create a single user in a different collection in a different database", func() { TEMPORARY_DB := fmt.Sprintf("%s_%s", t.Name(), "temporary_2") user := UserModel.Create(mocks.Geralt).SetDatabase(TEMPORARY_DB).SetCollection("witchers").ExecT() - So(user.ID, ShouldNotBeNil) + So(user.ID.IsZero(), ShouldBeFalse) var newUser User elemental.UseDatabase(TEMPORARY_DB).Collection("witchers").FindOne(context.TODO(), primitive.M{"_id": user.ID}).Decode(&newUser) So(newUser.Name, ShouldEqual, mocks.Geralt.Name) @@ -64,7 +64,7 @@ func TestCoreCreate(t *testing.T) { Name: "Katakan", Category: "Vampire", }).ExecT() - So(monster.ID, ShouldNotBeNil) + So(monster.ID.IsZero(), ShouldBeFalse) So(monster.Name, ShouldEqual, "Katakan") So(monster.CreatedAt.Unix(), ShouldBeBetweenOrEqual, time.Now().Add(-10*time.Second).Unix(), time.Now().Unix()) So(monster.UpdatedAt.Unix(), ShouldBeBetweenOrEqual, time.Now().Add(-10*time.Second).Unix(), time.Now().Unix()) From 95362c098c531b1e1f8ed0c10b6a0078df8f57cc Mon Sep 17 00:00:00 2001 From: Akalanka Perera Date: Mon, 26 May 2025 09:06:50 +0530 Subject: [PATCH 02/22] refactor(wip): schema validation and population --- core/schema_reflect_types.go | 6 +++++- core/schema_types.go | 3 +-- core/schema_validator.go | 24 ++++++++++++++---------- tests/core_read_populate_test.go | 8 ++++---- tests/fixtures/fixtures.go | 27 ++++++++------------------- tests/plugin_filter_query_test.go | 2 +- tests/tests.go | 4 +--- 7 files changed, 34 insertions(+), 40 deletions(-) diff --git a/core/schema_reflect_types.go b/core/schema_reflect_types.go index bd1b63e..1b67722 100644 --- a/core/schema_reflect_types.go +++ b/core/schema_reflect_types.go @@ -5,6 +5,10 @@ import ( "reflect" ) +type FieldType interface { + String() string +} + // Inbuilt types var Slice = reflect.Slice @@ -27,4 +31,4 @@ var String = reflect.String // Custom types -var ObjectID = reflect.TypeOf(primitive.NilObjectID).Kind() +var ObjectID = reflect.TypeOf(primitive.NilObjectID) diff --git a/core/schema_types.go b/core/schema_types.go index f91ff30..7831b48 100644 --- a/core/schema_types.go +++ b/core/schema_types.go @@ -1,7 +1,6 @@ package elemental import ( - "reflect" "regexp" "go.mongodb.org/mongo-driver/mongo/options" @@ -17,7 +16,7 @@ type SchemaOptions struct { } type Field struct { - Type reflect.Kind // Type of the field. Can be of reflect.Kind, an alias defined within elemental such as elemental.String or a custom reflection + Type FieldType // Type of the field. Can be of reflect.Kind, reflect.Type, an Elemental alias such as elemental.String or a custom reflection Schema *Schema // Defines a subschema for the field if it is a subdocument Required bool // Whether the field is required or not when creating a new document Default any // Default value for the field when creating a new document diff --git a/core/schema_validator.go b/core/schema_validator.go index aeb0d13..ec84a62 100644 --- a/core/schema_validator.go +++ b/core/schema_validator.go @@ -75,15 +75,15 @@ func enforceSchema[T any](schema Schema, doc *T, reflectedEntityType *reflect.Ty } } + hasRef := definition.Type == ObjectID && (definition.Ref != "" || definition.Collection != "") + // Type check - if definition.Type != reflect.Invalid { - actualKind := reflectedField.Type.Kind() - if actualKind == reflect.Ptr { - actualKind = reflectedField.Type.Elem().Kind() - } - if actualKind != definition.Type { - panic(fmt.Errorf("field %s has an invalid type. It must be of type %s", field, definition.Type.String())) - } + actualType := reflectedField.Type + if actualType.Kind() == reflect.Ptr { + actualType = actualType.Elem() + } + if actualType.String() != definition.Type.String() && !hasRef { + panic(fmt.Errorf("field %s has an invalid type. It must be of type %s", field, definition.Type.String())) } // Nested schema validation @@ -94,8 +94,12 @@ func enforceSchema[T any](schema Schema, doc *T, reflectedEntityType *reflect.Ty continue } - // Reference/Collection - if definition.Type == reflect.Struct && (definition.Ref != "" || definition.Collection != "") && val != nil { + // Nested ref validation + if hasRef { + + } + + if hasRef && val != nil { subdocumentField := reflectedField if subdocumentIDField, ok := subdocumentField.Type.FieldByName("ID"); ok { entityToInsert = lo.Assign( diff --git a/tests/core_read_populate_test.go b/tests/core_read_populate_test.go index e14acaa..b41c4a7 100644 --- a/tests/core_read_populate_test.go +++ b/tests/core_read_populate_test.go @@ -61,21 +61,21 @@ func TestCoreReadPopulate(t *testing.T) { Convey("Find with populated fields", t, func() { Convey("Populate a with multiple calls", func() { - bestiary := BestiaryModel.Find().Populate("monster").Populate("kingdom").ExecTT() + bestiary := BestiaryModel.Find().Populate("monster").Populate("kingdom").Exec().([]DetailedBestiary) So(bestiary, ShouldHaveLength, 3) So(bestiary[0].Monster.Name, ShouldEqual, "Katakan") So(bestiary[0].Monster.Category, ShouldEqual, "Vampire") So(bestiary[0].Kingdom.Name, ShouldEqual, "Nilfgaard") }) Convey("Populate with a single call", func() { - bestiary := BestiaryModel.Find().Populate("monster", "kingdom").ExecTT() + bestiary := BestiaryModel.Find().Populate("monster", "kingdom").Exec().([]DetailedBestiary) So(bestiary, ShouldHaveLength, 3) So(bestiary[0].Monster.Name, ShouldEqual, "Katakan") So(bestiary[0].Monster.Category, ShouldEqual, "Vampire") So(bestiary[0].Kingdom.Name, ShouldEqual, "Nilfgaard") }) Convey("Populate with a single call (Comma separated string)", func() { - bestiary := BestiaryModel.Find().Populate("monster kingdom").ExecTT() + bestiary := BestiaryModel.Find().Populate("monster kingdom").Exec().([]DetailedBestiary) So(bestiary, ShouldHaveLength, 3) So(bestiary[0].Monster.Name, ShouldEqual, "Katakan") So(bestiary[0].Monster.Category, ShouldEqual, "Vampire") @@ -85,7 +85,7 @@ func TestCoreReadPopulate(t *testing.T) { bestiary := BestiaryModel.Find().Populate(primitive.M{ "path": "monster", "select": primitive.M{"name": 1}, - }, "kingdom").ExecTT() + }, "kingdom").Exec().([]DetailedBestiary) So(bestiary, ShouldHaveLength, 3) So(bestiary[0].Monster.Name, ShouldEqual, "Katakan") So(bestiary[0].Monster.Category, ShouldEqual, "") diff --git a/tests/fixtures/fixtures.go b/tests/fixtures/fixtures.go index 9eb3f95..3a51161 100644 --- a/tests/fixtures/fixtures.go +++ b/tests/fixtures/fixtures.go @@ -54,16 +54,15 @@ type Monster struct { UpdatedAt time.Time `json:"updated_at" bson:"updated_at"` } -type Bestiary struct { +type GenericBestiary[T any, Y any] struct { ID primitive.ObjectID `json:"_id" bson:"_id"` - Monster Monster `json:"monster" bson:"monster"` - Kingdom Kingdom `json:"kingdom" bson:"kingdom"` + Monster T `json:"monster" bson:"monster"` + Kingdom Y `json:"kingdom" bson:"kingdom"` } -type BestiaryWithID struct { - ID primitive.ObjectID `json:"_id" bson:"_id"` - MonsterID string `json:"monster_id" bson:"monster_id"` -} +type Bestiary = GenericBestiary[any, any] + +type DetailedBestiary = GenericBestiary[Monster, Kingdom] const DefaultUserAge = 18 @@ -137,23 +136,13 @@ var KingdomModel = elemental.NewModel[Kingdom]("Kingdom", elemental.NewSchema(ma var BestiaryModel = elemental.NewModel[Bestiary]("Bestiary", elemental.NewSchema(map[string]elemental.Field{ "Monster": { - Type: elemental.Struct, + Type: elemental.ObjectID, Ref: "Monster", }, "Kingdom": { - Type: elemental.Struct, + Type: elemental.ObjectID, Ref: "Kingdom", }, }, elemental.SchemaOptions{ Collection: "bestiary", })) - -var BestiaryWithIDModel = elemental.NewModel[BestiaryWithID]("BestiaryWithID", elemental.NewSchema(map[string]elemental.Field{ - "MonsterID": { - Type: elemental.String, - Ref: "Monster", - IsRefID: true, - }, -}, elemental.SchemaOptions{ - Collection: "bestiaryWithID", -})) diff --git a/tests/plugin_filter_query_test.go b/tests/plugin_filter_query_test.go index bbbae9e..61a8190 100644 --- a/tests/plugin_filter_query_test.go +++ b/tests/plugin_filter_query_test.go @@ -272,7 +272,7 @@ func TestPluginFilterQuery(t *testing.T) { Kingdom: kingdom, }).Exec() - bestiaries := BestiaryModel.QS("include=monster,kingdom").ExecTT() + bestiaries := BestiaryModel.QS("include=monster,kingdom").Exec().([]DetailedBestiary) So(bestiaries, ShouldHaveLength, 1) So(bestiaries[0].Monster.Name, ShouldEqual, monster.Name) So(bestiaries[0].Monster.Category, ShouldEqual, monster.Category) diff --git a/tests/tests.go b/tests/tests.go index c22a703..f63f0dc 100644 --- a/tests/tests.go +++ b/tests/tests.go @@ -16,7 +16,7 @@ type Monster = fixtures.Monster type Bestiary = fixtures.Bestiary -type BestiaryWithID = fixtures.BestiaryWithID +type DetailedBestiary = fixtures.DetailedBestiary type MonsterWeakness = fixtures.MonsterWeakness @@ -30,8 +30,6 @@ var KingdomModel = fixtures.KingdomModel var BestiaryModel = fixtures.BestiaryModel -var BestiaryWithIDModel = fixtures.BestiaryWithIDModel - // Test helper function to wait for a condition to be true or timeout. // It will keep checking the condition every 100 milliseconds until the timeout is reached. // If the condition is true, the assertion will pass. From 5f05bfdec6d5e246d3ae3df8d79b826d9f613ebb Mon Sep 17 00:00:00 2001 From: Akalanka Perera Date: Mon, 26 May 2025 19:52:13 +0530 Subject: [PATCH 03/22] refactor: middleware and preprocessed some things for perf --- core/model.go | 21 +++++++------ core/model_actions.go | 5 +--- core/model_audit.go | 10 ++++--- core/model_middleware.go | 56 ++++++++++++++++++++--------------- core/model_populate.go | 6 ++-- core/model_query_delete.go | 10 +++---- core/model_query_update.go | 45 +++++++++++++++------------- core/model_utils.go | 13 ++++---- core/schema_reflect_types.go | 28 ++++++++++++++++-- core/schema_validator.go | 25 +++++----------- tests/core_middleware_test.go | 27 +++++++++++------ utils/caster.go | 10 ------- 12 files changed, 138 insertions(+), 118 deletions(-) diff --git a/core/model.go b/core/model.go index 1b0df4f..d59b492 100644 --- a/core/model.go +++ b/core/model.go @@ -40,6 +40,7 @@ type Model[T any] struct { softDeleteEnabled bool deletedAtFieldName string triggerExit chan bool + docReflectType reflect.Type // The reflect type of a sample document of this model } var pluralizeClient = pluralize.NewClient() @@ -64,6 +65,7 @@ func NewModel[T any](name string, schema Schema) Model[T] { middleware: &middleware, triggerExit: make(chan bool, 1), } + model.preprocess() Models[name] = model onConnectionComplete := func() { model.CreateCollection() @@ -84,12 +86,11 @@ func NewModel[T any](name string, schema Schema) Model[T] { // This method validates the document against the model schema and panics if any errors are found. func (m Model[T]) Create(doc T) Model[T] { m.executor = func(m Model[T], ctx context.Context) any { - documentToInsert, detailedDocument := enforceSchema(m.Schema, &doc, nil) - detailedDocumentEntity := utils.CastBSON[T](detailedDocument) - m.middleware.pre.save.run(detailedDocumentEntity) + documentToInsert := enforceSchema(m.Schema, &doc, nil) + m.middleware.pre.save.run(&documentToInsert) lo.Must(m.Collection().InsertOne(ctx, documentToInsert)) - m.middleware.post.save.run(detailedDocumentEntity) - return detailedDocumentEntity + m.middleware.post.save.run(&documentToInsert) + return utils.CastBSON[T](documentToInsert) } return m } @@ -104,14 +105,12 @@ func (m Model[T]) CreateMany(docs []T) Model[T] { // This method validates the document against the model schema and panics if any errors are found. func (m Model[T]) InsertMany(docs []T) Model[T] { m.executor = func(m Model[T], ctx context.Context) any { - var documentsToInsert, detailedDocuments []any + var documentsToInsert []any for _, doc := range docs { - documentToInsert, detailedDocument := enforceSchema(m.Schema, &doc, nil) - documentsToInsert = append(documentsToInsert, documentToInsert) - detailedDocuments = append(detailedDocuments, detailedDocument) + documentsToInsert = append(documentsToInsert, enforceSchema(m.Schema, &doc, nil)) } lo.Must(m.Collection().InsertMany(ctx, documentsToInsert)) - return utils.CastBSONSlice[T](detailedDocuments) + return utils.CastBSONSlice[T](documentsToInsert) } return m } @@ -124,8 +123,8 @@ func (m Model[T]) Find(query ...primitive.M) Model[T] { var results []T cursor := lo.Must(m.Collection().Aggregate(ctx, m.pipeline)) m.checkConditionsAndPanicForErr(cursor.All(ctx, &results)) - m.middleware.post.find.run(results) m.checkConditionsAndPanic(results) + m.middleware.post.find.run(&results) return results } q := utils.MergedQueryOrDefault(query) diff --git a/core/model_actions.go b/core/model_actions.go index bbc00c6..3d2ee89 100644 --- a/core/model_actions.go +++ b/core/model_actions.go @@ -2,8 +2,6 @@ package elemental import ( "context" - "reflect" - "github.com/elcengine/elemental/utils" "github.com/samber/lo" @@ -31,8 +29,7 @@ func (m Model[T]) Ping(ctx ...context.Context) error { // Creates or updates the indexes for this model. This method will only create the indexes if they do not exist. func (m Model[T]) SyncIndexes(ctx ...context.Context) { - var sample [0]T - m.Schema.syncIndexes(reflect.TypeOf(sample).Elem(), lo.FromPtr(m.temporaryDatabase), lo.FromPtr(m.temporaryConnection), lo.FromPtr(m.temporaryCollection), ctx...) + m.Schema.syncIndexes(m.docReflectType, lo.FromPtr(m.temporaryDatabase), lo.FromPtr(m.temporaryConnection), lo.FromPtr(m.temporaryCollection), ctx...) } // Drops all indexes for this model except the default `_id` index. diff --git a/core/model_audit.go b/core/model_audit.go index 394960a..40c4af9 100644 --- a/core/model_audit.go +++ b/core/model_audit.go @@ -2,11 +2,13 @@ package elemental import ( "context" - "github.com/elcengine/elemental/utils" - "github.com/samber/lo" "reflect" "time" + "github.com/elcengine/elemental/utils" + "github.com/samber/lo" + + "go.mongodb.org/mongo-driver/bson" "go.mongodb.org/mongo-driver/bson/primitive" ) @@ -68,7 +70,7 @@ func (m Model[T]) EnableAuditing(ctx ...context.Context) { execWithModelOpts(AuditModel.Create(Audit{ Entity: m.Name, Type: AuditTypeInsert, - Document: *utils.ToBSONDoc(doc), + Document: utils.CastBSON[bson.M](doc), User: lo.CoalesceOrEmpty(utils.Cast[string](context.Value(CtxUser)), userFallback), CreatedAt: time.Now(), })) @@ -77,7 +79,7 @@ func (m Model[T]) EnableAuditing(ctx ...context.Context) { execWithModelOpts(AuditModel.Create(Audit{ Entity: m.Name, Type: AuditTypeUpdate, - Document: *utils.ToBSONDoc(doc), + Document: utils.CastBSON[bson.M](doc), User: lo.CoalesceOrEmpty(utils.Cast[string](context.Value(CtxUser)), userFallback), CreatedAt: time.Now(), })) diff --git a/core/model_middleware.go b/core/model_middleware.go index a0ef8f1..ac41d0c 100644 --- a/core/model_middleware.go +++ b/core/model_middleware.go @@ -3,6 +3,7 @@ package elemental import ( "github.com/elcengine/elemental/utils" + "go.mongodb.org/mongo-driver/bson" "go.mongodb.org/mongo-driver/bson/primitive" "go.mongodb.org/mongo-driver/mongo" ) @@ -14,12 +15,13 @@ type listener[T any] struct { } type pre[T any] struct { - save listener[T] - updateOne listener[T] - deleteOne listener[T] - deleteMany listener[T] - findOneAndUpdate listener[T] - findOneAndDelete listener[T] + save listener[T] + updateOne listener[T] + deleteOne listener[T] + deleteMany listener[T] + findOneAndUpdate listener[T] + findOneAndDelete listener[T] + findOneAndReplace listener[T] } type post[T any] struct { @@ -51,15 +53,15 @@ func (l listener[T]) run(args ...any) { } } -func (m Model[T]) PreSave(f func(doc T) bool) { +func (m Model[T]) PreSave(f func(doc *bson.M) bool) { m.middleware.pre.save.functions = append(m.middleware.pre.save.functions, func(args ...any) bool { - return f(args[0].(T)) + return f(args[0].(*bson.M)) }) } -func (m Model[T]) PostSave(f func(doc T) bool) { +func (m Model[T]) PostSave(f func(doc *bson.M) bool) { m.middleware.post.save.functions = append(m.middleware.post.save.functions, func(args ...any) bool { - return f(args[0].(T)) + return f(args[0].(*bson.M)) }) } @@ -75,9 +77,9 @@ func (m Model[T]) PostUpdateOne(f func(result *mongo.UpdateResult, err error) bo }) } -func (m Model[T]) PreDeleteOne(f func(filters primitive.M) bool) { +func (m Model[T]) PreDeleteOne(f func(filters *primitive.M) bool) { m.middleware.pre.deleteOne.functions = append(m.middleware.pre.deleteOne.functions, func(args ...any) bool { - return f(args[0].(primitive.M)) + return f(args[0].(*primitive.M)) }) } @@ -87,9 +89,9 @@ func (m Model[T]) PostDeleteOne(f func(result *mongo.DeleteResult, err error) bo }) } -func (m Model[T]) PreDeleteMany(f func(filters primitive.M) bool) { +func (m Model[T]) PreDeleteMany(f func(filters *primitive.M) bool) { m.middleware.pre.deleteMany.functions = append(m.middleware.pre.deleteMany.functions, func(args ...any) bool { - return f(args[0].(primitive.M)) + return f(args[0].(*primitive.M)) }) } @@ -99,27 +101,27 @@ func (m Model[T]) PostDeleteMany(f func(result *mongo.DeleteResult, err error) b }) } -func (m Model[T]) PostFind(f func(doc []T) bool) { +func (m Model[T]) PostFind(f func(doc *[]T) bool) { m.middleware.post.find.functions = append(m.middleware.post.find.functions, func(args ...any) bool { - return f(args[0].([]T)) + return f(args[0].(*[]T)) }) } -func (m Model[T]) PostFindOneAndUpdate(f func(doc *T) bool) { - m.middleware.post.findOneAndUpdate.functions = append(m.middleware.post.findOneAndUpdate.functions, func(args ...any) bool { - return f(args[0].(*T)) +func (m Model[T]) PreFindOneAndUpdate(f func(filters *primitive.M, doc any) bool) { + m.middleware.pre.findOneAndUpdate.functions = append(m.middleware.pre.findOneAndUpdate.functions, func(args ...any) bool { + return f(args[0].(*primitive.M), args[1]) }) } -func (m Model[T]) PreFindOneAndUpdate(f func(filters primitive.M) bool) { - m.middleware.pre.findOneAndUpdate.functions = append(m.middleware.pre.findOneAndUpdate.functions, func(args ...any) bool { - return f(args[0].(primitive.M)) +func (m Model[T]) PostFindOneAndUpdate(f func(doc *T) bool) { + m.middleware.post.findOneAndUpdate.functions = append(m.middleware.post.findOneAndUpdate.functions, func(args ...any) bool { + return f(args[0].(*T)) }) } -func (m Model[T]) PreFindOneAndDelete(f func(filters primitive.M) bool) { +func (m Model[T]) PreFindOneAndDelete(f func(filters *primitive.M) bool) { m.middleware.pre.findOneAndDelete.functions = append(m.middleware.pre.findOneAndDelete.functions, func(args ...any) bool { - return f(args[0].(primitive.M)) + return f(args[0].(*primitive.M)) }) } @@ -129,6 +131,12 @@ func (m Model[T]) PostFindOneAndDelete(f func(doc *T) bool) { }) } +func (m Model[T]) PreFindOneAndReplace(f func(filters *primitive.M, doc any) bool) { + m.middleware.pre.findOneAndReplace.functions = append(m.middleware.pre.findOneAndReplace.functions, func(args ...any) bool { + return f(args[0].(*primitive.M), args[1]) + }) +} + func (m Model[T]) PostFindOneAndReplace(f func(doc *T) bool) { m.middleware.post.findOneAndReplace.functions = append(m.middleware.post.findOneAndReplace.functions, func(args ...any) bool { return f(args[0].(*T)) diff --git a/core/model_populate.go b/core/model_populate.go index e85c437..d54b5a5 100644 --- a/core/model_populate.go +++ b/core/model_populate.go @@ -22,10 +22,8 @@ func (m Model[T]) populate(value any) Model[T] { path = utils.Cast[string](value) } if path != "" { - var sample [0]T // Slice of zero length to get the type of T - modelType := reflect.TypeOf(sample).Elem() - for i := range modelType.NumField() { - field := modelType.Field(i) + for i := range m.docReflectType.NumField() { + field := m.docReflectType.Field(i) if cleanTag(field.Tag.Get("bson")) == path { fieldname = field.Name break diff --git a/core/model_query_delete.go b/core/model_query_delete.go index 5bfdd8e..bc4ceb7 100644 --- a/core/model_query_delete.go +++ b/core/model_query_delete.go @@ -23,11 +23,11 @@ func (m Model[T]) FindOneAndDelete(query ...primitive.M) Model[T] { } else { m.executor = func(m Model[T], ctx context.Context) any { var doc T - m.middleware.pre.findOneAndDelete.run(q) + m.middleware.pre.findOneAndDelete.run(&q) result := m.Collection().FindOneAndDelete(ctx, q) - m.middleware.post.findOneAndDelete.run(&doc) m.checkConditionsAndPanicForSingleResult(result) lo.Must0(result.Decode(&doc)) + m.middleware.post.findOneAndDelete.run(&doc) return doc } } @@ -59,10 +59,10 @@ func (m Model[T]) DeleteOne(query ...primitive.M) Model[T] { m = m.UpdateOne(&q, m.softDeletePayload()) } else { m.executor = func(m Model[T], ctx context.Context) any { - m.middleware.pre.deleteOne.run(q) + m.middleware.pre.deleteOne.run(&q) result, err := m.Collection().DeleteOne(ctx, q) - m.middleware.post.deleteOne.run(result, err) m.checkConditionsAndPanicForErr(err) + m.middleware.post.deleteOne.run(result, err) return result } } @@ -110,7 +110,7 @@ func (m Model[T]) DeleteMany(query ...primitive.M) Model[T] { m = m.UpdateMany(&q, m.softDeletePayload()) } else { m.executor = func(m Model[T], ctx context.Context) any { - m.middleware.pre.deleteMany.run(q) + m.middleware.pre.deleteMany.run(&q) result, err := m.Collection().DeleteMany(ctx, q) m.checkConditionsAndPanicForErr(err) m.middleware.post.deleteMany.run(result, err) diff --git a/core/model_query_update.go b/core/model_query_update.go index 7482f3b..3695fad 100644 --- a/core/model_query_update.go +++ b/core/model_query_update.go @@ -2,12 +2,11 @@ package elemental import ( "context" - "reflect" - "maps" "github.com/elcengine/elemental/utils" "github.com/samber/lo" + "go.mongodb.org/mongo-driver/bson" "go.mongodb.org/mongo-driver/bson/primitive" "go.mongodb.org/mongo-driver/mongo/options" ) @@ -17,18 +16,16 @@ import ( // It updates only the first document that matches the query. func (m Model[T]) FindOneAndUpdate(query *primitive.M, doc any, opts ...*options.FindOneAndUpdateOptions) Model[T] { m.executor = func(m Model[T], ctx context.Context) any { - m.middleware.pre.findOneAndUpdate.run(doc) - return (func() any { - var resultDoc T - filters := lo.FromPtr(query) - maps.Copy(filters, m.findMatchStage()) - result := m.Collection().FindOneAndUpdate(ctx, filters, - primitive.M{"$set": m.parseDocument(doc)}, parseUpdateOptions(m, opts)...) - m.middleware.post.findOneAndUpdate.run(&resultDoc) - m.checkConditionsAndPanicForSingleResult(result) - lo.Must0(result.Decode(&resultDoc)) - return resultDoc - })() + var resultDoc T + filters := lo.FromPtr(query) + maps.Copy(filters, m.findMatchStage()) + m.middleware.pre.findOneAndUpdate.run(&filters, &doc) + result := m.Collection().FindOneAndUpdate(ctx, filters, + primitive.M{"$set": m.parseDocument(doc)}, parseUpdateOptions(m, opts)...) + m.checkConditionsAndPanicForSingleResult(result) + lo.Must0(result.Decode(&resultDoc)) + m.middleware.post.findOneAndUpdate.run(&resultDoc) + return resultDoc } return m } @@ -57,7 +54,7 @@ func (m Model[T]) UpdateOne(query *primitive.M, doc any, opts ...*options.Update filters = lo.FromPtr(query) } maps.Copy(filters, m.findMatchStage()) - m.middleware.pre.updateOne.run(doc) + m.middleware.pre.updateOne.run(&doc) result, err := m.Collection().UpdateOne(ctx, filters, primitive.M{"$set": m.parseDocument(doc)}, parseUpdateOptions(m, opts)...) m.middleware.post.updateOne.run(result, err) @@ -80,13 +77,18 @@ func (m Model[T]) UpdateByID(id any, doc any, opts ...*options.UpdateOptions) Mo return m } -// Extends the query with an update operation matching the id of the given document +// Extends the query with an upsert operation matching the id of the given document func (m Model[T]) Save(doc T) Model[T] { m.executor = func(m Model[T], ctx context.Context) any { - m.middleware.pre.save.run(doc) - m.UpdateByID(reflect.ValueOf(doc).FieldByName("ID").Interface().(primitive.ObjectID), doc).Exec(ctx) //nolint:contextcheck - m.middleware.post.save.run(doc) - return doc + parsedDoc := m.parseDocument(doc) + var resultDoc bson.M + m.middleware.pre.save.run(&parsedDoc) + result := m.Collection().FindOneAndUpdate(ctx, &primitive.M{"_id": parsedDoc["_id"]}, + primitive.M{"$set": parsedDoc}, options.FindOneAndUpdate().SetUpsert(true)) + m.checkConditionsAndPanicForSingleResult(result) + lo.Must0(result.Decode(&resultDoc)) + m.middleware.post.save.run(&resultDoc) + return utils.CastBSON[T](resultDoc) } return m } @@ -149,10 +151,11 @@ func (m Model[T]) FindOneAndReplace(query *primitive.M, doc any, opts ...*option filters = lo.FromPtr(query) } maps.Copy(filters, m.findMatchStage()) + m.middleware.pre.findOneAndReplace.run(&filters, &doc) res := m.Collection().FindOneAndReplace(ctx, filters, m.parseDocument(doc), opts...) - m.middleware.post.findOneAndReplace.run(&resultDoc) m.checkConditionsAndPanicForSingleResult(res) lo.Must0(res.Decode(&resultDoc)) + m.middleware.post.findOneAndReplace.run(&resultDoc) return resultDoc } return m diff --git a/core/model_utils.go b/core/model_utils.go index 91bd323..8838be4 100644 --- a/core/model_utils.go +++ b/core/model_utils.go @@ -121,15 +121,12 @@ func (m Model[T]) findMatchStage() bson.M { return bson.M{} } -func (m Model[T]) parseDocument(doc any) primitive.M { +func (m Model[T]) parseDocument(doc any) bson.M { docType := reflect.TypeOf(doc).Kind() if docType == reflect.Ptr { doc = reflect.ValueOf(doc).Elem().Interface() } - if docType == reflect.Map { - return utils.Cast[primitive.M](doc) - } - result := *utils.ToBSONDoc(doc) + result := utils.CastBSON[bson.M](doc) for k, v := range result { fieldValue := reflect.ValueOf(v) if !fieldValue.IsValid() || fieldValue.IsZero() { @@ -171,3 +168,9 @@ func (m Model[T]) setUpdateOperator(operator string, doc any) Model[T] { } return m } + +// Computes and stores some expensive operations. Invoked at the time of model creation. +func (m *Model[T]) preprocess() { + var sample [0]T // Slice of zero length to get the type of T + m.docReflectType = reflect.TypeOf(sample).Elem() +} diff --git a/core/schema_reflect_types.go b/core/schema_reflect_types.go index 1b67722..1c7c749 100644 --- a/core/schema_reflect_types.go +++ b/core/schema_reflect_types.go @@ -1,8 +1,10 @@ package elemental import ( - "go.mongodb.org/mongo-driver/bson/primitive" "reflect" + "time" + + "go.mongodb.org/mongo-driver/bson/primitive" ) type FieldType interface { @@ -21,8 +23,6 @@ var Int = reflect.Int var Int32 = reflect.Int32 var Int64 = reflect.Int64 var Uint = reflect.Uint -var Uint8 = reflect.Uint8 -var Uint16 = reflect.Uint16 var Uint32 = reflect.Uint32 var Uint64 = reflect.Uint64 var Float32 = reflect.Float32 @@ -31,4 +31,26 @@ var String = reflect.String // Custom types +var Time = reflect.TypeOf(time.Time{}) var ObjectID = reflect.TypeOf(primitive.NilObjectID) +var ObjectIDSlice = reflect.TypeOf([]primitive.ObjectID{}) +var StringSlice = reflect.TypeOf([]string{}) +var StringMap = reflect.TypeOf(map[string]string{}) +var IntSlice = reflect.TypeOf([]int{}) +var IntMap = reflect.TypeOf(map[string]int{}) +var BoolSlice = reflect.TypeOf([]bool{}) +var BoolMap = reflect.TypeOf(map[string]bool{}) +var Int32Slice = reflect.TypeOf([]int32{}) +var Int32Map = reflect.TypeOf(map[string]int32{}) +var Int64Slice = reflect.TypeOf([]int64{}) +var Int64Map = reflect.TypeOf(map[string]int64{}) +var UintSlice = reflect.TypeOf([]uint{}) +var UintMap = reflect.TypeOf(map[string]uint{}) +var Uint32Slice = reflect.TypeOf([]uint32{}) +var Uint32Map = reflect.TypeOf(map[string]uint32{}) +var Uint64Slice = reflect.TypeOf([]uint64{}) +var Uint64Map = reflect.TypeOf(map[string]uint64{}) +var Float32Slice = reflect.TypeOf([]float32{}) +var Float64Slice = reflect.TypeOf([]float64{}) +var Float32Map = reflect.TypeOf(map[string]float32{}) +var Float64Map = reflect.TypeOf(map[string]float64{}) diff --git a/core/schema_validator.go b/core/schema_validator.go index ec84a62..3b8f0e3 100644 --- a/core/schema_validator.go +++ b/core/schema_validator.go @@ -2,7 +2,6 @@ package elemental import ( "fmt" - "maps" "reflect" "strings" @@ -16,14 +15,14 @@ import ( "go.mongodb.org/mongo-driver/bson/primitive" ) -func enforceSchema[T any](schema Schema, doc *T, reflectedEntityType *reflect.Type, defaults ...bool) (bson.M, bson.M) { +func enforceSchema[T any](schema Schema, doc *T, reflectedEntityType *reflect.Type, defaults ...bool) bson.M { var entityToInsert bson.M documentElement := reflect.TypeOf(doc).Elem() // Fast return when bypass schema enforcement or value is not a struct if doc != nil && (documentElement.Kind() != reflect.Struct || schema.Options.BypassSchemaEnforcement) { - entityToInsert = *utils.ToBSONDoc(doc) - return entityToInsert, entityToInsert + entityToInsert = utils.CastBSON[bson.M](doc) + return entityToInsert } if reflectedEntityType != nil { @@ -32,7 +31,7 @@ func enforceSchema[T any](schema Schema, doc *T, reflectedEntityType *reflect.Ty entityToInsert = make(bson.M) } } else { - entityToInsert = *utils.ToBSONDoc(doc) + entityToInsert = utils.CastBSON[bson.M](doc) reflectedEntityType = &documentElement } @@ -52,9 +51,6 @@ func enforceSchema[T any](schema Schema, doc *T, reflectedEntityType *reflect.Ty } } - detailedEntity := make(bson.M) - maps.Copy(detailedEntity, entityToInsert) - for field, definition := range schema.Definitions { reflectedField, ok := (*reflectedEntityType).FieldByName(field) if !ok { @@ -70,7 +66,6 @@ func enforceSchema[T any](schema Schema, doc *T, reflectedEntityType *reflect.Ty } if definition.Default != nil { entityToInsert[fieldBsonName] = definition.Default - detailedEntity[fieldBsonName] = definition.Default val = definition.Default } } @@ -82,23 +77,17 @@ func enforceSchema[T any](schema Schema, doc *T, reflectedEntityType *reflect.Ty if actualType.Kind() == reflect.Ptr { actualType = actualType.Elem() } - if actualType.String() != definition.Type.String() && !hasRef { + if actualType.String() != definition.Type.String() && actualType.Kind().String() != definition.Type.String() && !hasRef { panic(fmt.Errorf("field %s has an invalid type. It must be of type %s", field, definition.Type.String())) } // Nested schema validation if definition.Type == reflect.Struct && definition.Schema != nil { subdocumentField := reflectedField - entityToInsert[fieldBsonName], detailedEntity[fieldBsonName] = - enforceSchema(*definition.Schema, utils.Cast[*bson.M](val), &subdocumentField.Type, false) + entityToInsert[fieldBsonName] = enforceSchema(*definition.Schema, utils.Cast[*bson.M](val), &subdocumentField.Type, false) continue } - // Nested ref validation - if hasRef { - - } - if hasRef && val != nil { subdocumentField := reflectedField if subdocumentIDField, ok := subdocumentField.Type.FieldByName("ID"); ok { @@ -133,7 +122,7 @@ func enforceSchema[T any](schema Schema, doc *T, reflectedEntityType *reflect.Ty } } } - return entityToInsert, detailedEntity + return entityToInsert } func cleanTag(tag string) string { diff --git a/tests/core_middleware_test.go b/tests/core_middleware_test.go index a2b7cfc..bc20eb5 100644 --- a/tests/core_middleware_test.go +++ b/tests/core_middleware_test.go @@ -7,6 +7,7 @@ import ( ts "github.com/elcengine/elemental/tests/fixtures/setup" . "github.com/smartystreets/goconvey/convey" + "go.mongodb.org/mongo-driver/bson" "go.mongodb.org/mongo-driver/bson/primitive" "go.mongodb.org/mongo-driver/mongo" ) @@ -25,22 +26,22 @@ func TestCoreMiddleware(t *testing.T) { }, })).SetDatabase(t.Name()) - CastleModel.PreSave(func(castle Castle) bool { + CastleModel.PreSave(func(castle *bson.M) bool { invokedHooks["preSave"] = true return true }) - CastleModel.PostSave(func(castle Castle) bool { + CastleModel.PostSave(func(castle *bson.M) bool { invokedHooks["postSave"] = true return true }) - CastleModel.PostSave(func(castle Castle) bool { + CastleModel.PostSave(func(castle *bson.M) bool { invokedHooks["postSaveSecond"] = true return false }) - CastleModel.PostSave(func(castle Castle) bool { + CastleModel.PostSave(func(castle *bson.M) bool { invokedHooks["postSaveThird"] = true return true }) @@ -55,7 +56,7 @@ func TestCoreMiddleware(t *testing.T) { return true }) - CastleModel.PreDeleteOne(func(filters primitive.M) bool { + CastleModel.PreDeleteOne(func(filters *primitive.M) bool { invokedHooks["preDeleteOne"] = true return true }) @@ -65,7 +66,7 @@ func TestCoreMiddleware(t *testing.T) { return true }) - CastleModel.PreDeleteMany(func(filters primitive.M) bool { + CastleModel.PreDeleteMany(func(filters *primitive.M) bool { invokedHooks["preDeleteMany"] = true return true }) @@ -75,12 +76,12 @@ func TestCoreMiddleware(t *testing.T) { return true }) - CastleModel.PostFind(func(castle []Castle) bool { + CastleModel.PostFind(func(castle *[]Castle) bool { invokedHooks["postFind"] = true return true }) - CastleModel.PreFindOneAndUpdate(func(filter primitive.M) bool { + CastleModel.PreFindOneAndUpdate(func(filter *primitive.M, doc any) bool { invokedHooks["preFindOneAndUpdate"] = true return true }) @@ -90,7 +91,7 @@ func TestCoreMiddleware(t *testing.T) { return true }) - CastleModel.PreFindOneAndDelete(func(filters primitive.M) bool { + CastleModel.PreFindOneAndDelete(func(filters *primitive.M) bool { invokedHooks["preFindOneAndDelete"] = true return true }) @@ -100,6 +101,11 @@ func TestCoreMiddleware(t *testing.T) { return true }) + CastleModel.PreFindOneAndReplace(func(castle *primitive.M, doc any) bool { + invokedHooks["preFindOneAndReplace"] = true + return true + }) + CastleModel.PostFindOneAndReplace(func(castle *Castle) bool { invokedHooks["postFindOneAndReplace"] = true return true @@ -144,6 +150,9 @@ func TestCoreMiddleware(t *testing.T) { Convey("FindOneAndDelete", func() { So(invokedHooks["preFindOneAndDelete"], ShouldBeTrue) }) + Convey("FindOneAndReplace", func() { + So(invokedHooks["preFindOneAndReplace"], ShouldBeTrue) + }) }) Convey("Post hooks", t, func() { diff --git a/utils/caster.go b/utils/caster.go index 1fd58b4..5f93e46 100644 --- a/utils/caster.go +++ b/utils/caster.go @@ -40,13 +40,3 @@ func FromBSON[T any](bytes []byte) T { bson.Unmarshal(bytes, &v) return v } - -// Converts an interface to a bson document -func ToBSONDoc(v any) (doc *bson.M) { - data, err := bson.Marshal(v) - if err != nil { - return nil - } - bson.Unmarshal(data, &doc) - return doc -} From e545214d42885d2456cc8ccd1660282fa6e840e5 Mon Sep 17 00:00:00 2001 From: Akalanka Perera Date: Mon, 26 May 2025 20:08:42 +0530 Subject: [PATCH 04/22] fix: schema validation err with populate --- core/schema_validator.go | 36 +++++++++++++++++++----------------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/core/schema_validator.go b/core/schema_validator.go index 3b8f0e3..f49b591 100644 --- a/core/schema_validator.go +++ b/core/schema_validator.go @@ -81,24 +81,26 @@ func enforceSchema[T any](schema Schema, doc *T, reflectedEntityType *reflect.Ty panic(fmt.Errorf("field %s has an invalid type. It must be of type %s", field, definition.Type.String())) } - // Nested schema validation - if definition.Type == reflect.Struct && definition.Schema != nil { - subdocumentField := reflectedField - entityToInsert[fieldBsonName] = enforceSchema(*definition.Schema, utils.Cast[*bson.M](val), &subdocumentField.Type, false) - continue - } - - if hasRef && val != nil { - subdocumentField := reflectedField - if subdocumentIDField, ok := subdocumentField.Type.FieldByName("ID"); ok { - entityToInsert = lo.Assign( - entityToInsert, - bson.M{ - fieldBsonName: val.(primitive.M)[subdocumentIDField.Tag.Get("bson")], - }, - ) + if definition.Type == reflect.Struct { + // Nested schema validation + if definition.Schema != nil { + subdocumentField := reflectedField + entityToInsert[fieldBsonName] = enforceSchema(*definition.Schema, utils.Cast[*bson.M](val), &subdocumentField.Type, false) + continue + } + // Extract subdocument ID if it exists for ObjectID references + if hasRef && val != nil && actualType.Kind() == reflect.Struct { + subdocumentField := reflectedField + if subdocumentIDField, ok := subdocumentField.Type.FieldByName("ID"); ok { + entityToInsert = lo.Assign( + entityToInsert, + bson.M{ + fieldBsonName: val.(primitive.M)[subdocumentIDField.Tag.Get("bson")], + }, + ) + } + continue } - continue } if definition.Min != 0 { From d863d830210a6481771130079cf826ec74539899 Mon Sep 17 00:00:00 2001 From: Akalanka Perera Date: Thu, 5 Jun 2025 22:06:05 +0530 Subject: [PATCH 05/22] fix: population issues --- core/model_populate.go | 8 ++++++ core/model_query.go | 8 ++++++ core/model_query_delete.go | 2 +- core/model_query_update.go | 10 +++---- core/model_utils.go | 23 +++++++++------- core/schema_validator.go | 10 ++++--- tests/core_read_populate_test.go | 46 +++++++++++++++++-------------- tests/plugin_filter_query_test.go | 3 +- 8 files changed, 68 insertions(+), 42 deletions(-) diff --git a/core/model_populate.go b/core/model_populate.go index d54b5a5..fe0f94b 100644 --- a/core/model_populate.go +++ b/core/model_populate.go @@ -1,6 +1,7 @@ package elemental import ( + "context" "reflect" "strings" @@ -85,5 +86,12 @@ func (m Model[T]) Populate(values ...any) Model[T] { for _, value := range values { m = m.populate(value) } + m.executor = func(m Model[T], ctx context.Context) any { + var results []bson.M + cursor := lo.Must(m.Collection().Aggregate(ctx, m.pipeline)) + lo.Must0(cursor.All(ctx, &results)) + m.checkConditionsAndPanic(results) + return results + } return m } diff --git a/core/model_query.go b/core/model_query.go index ec603c7..e373cca 100644 --- a/core/model_query.go +++ b/core/model_query.go @@ -7,6 +7,7 @@ import ( "github.com/elcengine/elemental/utils" "github.com/samber/lo" "github.com/spf13/cast" + "go.mongodb.org/mongo-driver/bson" ) // Extends the query with a where clause. The value of the clause if specified within this method itself @@ -131,3 +132,10 @@ func (m Model[T]) ExecSS(ctx ...context.Context) []string { result := m.Exec(ctx...) return cast.ToStringSlice(result) } + +// ExecInto is a specialized method that executes the query and unmarshals the result into the provided result variable. +// It is useful when you want to extract results into a custom struct other than the model type such as when you populate certain fields +func (m Model[T]) ExecInto(result any, ctx ...context.Context) { + rv, bytes := lo.Must2(bson.MarshalValue(m.Exec(ctx...))) + bson.UnmarshalValue(rv, bytes, result) +} diff --git a/core/model_query_delete.go b/core/model_query_delete.go index bc4ceb7..965063b 100644 --- a/core/model_query_delete.go +++ b/core/model_query_delete.go @@ -25,7 +25,7 @@ func (m Model[T]) FindOneAndDelete(query ...primitive.M) Model[T] { var doc T m.middleware.pre.findOneAndDelete.run(&q) result := m.Collection().FindOneAndDelete(ctx, q) - m.checkConditionsAndPanicForSingleResult(result) + m.checkConditionsAndPanic(result) lo.Must0(result.Decode(&doc)) m.middleware.post.findOneAndDelete.run(&doc) return doc diff --git a/core/model_query_update.go b/core/model_query_update.go index 3695fad..aa60b53 100644 --- a/core/model_query_update.go +++ b/core/model_query_update.go @@ -22,7 +22,7 @@ func (m Model[T]) FindOneAndUpdate(query *primitive.M, doc any, opts ...*options m.middleware.pre.findOneAndUpdate.run(&filters, &doc) result := m.Collection().FindOneAndUpdate(ctx, filters, primitive.M{"$set": m.parseDocument(doc)}, parseUpdateOptions(m, opts)...) - m.checkConditionsAndPanicForSingleResult(result) + m.checkConditionsAndPanic(result) lo.Must0(result.Decode(&resultDoc)) m.middleware.post.findOneAndUpdate.run(&resultDoc) return resultDoc @@ -37,7 +37,7 @@ func (m Model[T]) FindByIDAndUpdate(id any, doc any, opts ...*options.FindOneAnd var resultDoc T result := m.Collection().FindOneAndUpdate(ctx, primitive.M{"_id": utils.EnsureObjectID(id)}, primitive.M{"$set": m.parseDocument(doc)}, parseUpdateOptions(m, opts)...) - m.checkConditionsAndPanicForSingleResult(result) + m.checkConditionsAndPanic(result) lo.Must0(result.Decode(&resultDoc)) return resultDoc } @@ -85,7 +85,7 @@ func (m Model[T]) Save(doc T) Model[T] { m.middleware.pre.save.run(&parsedDoc) result := m.Collection().FindOneAndUpdate(ctx, &primitive.M{"_id": parsedDoc["_id"]}, primitive.M{"$set": parsedDoc}, options.FindOneAndUpdate().SetUpsert(true)) - m.checkConditionsAndPanicForSingleResult(result) + m.checkConditionsAndPanic(result) lo.Must0(result.Decode(&resultDoc)) m.middleware.post.save.run(&resultDoc) return utils.CastBSON[T](resultDoc) @@ -153,7 +153,7 @@ func (m Model[T]) FindOneAndReplace(query *primitive.M, doc any, opts ...*option maps.Copy(filters, m.findMatchStage()) m.middleware.pre.findOneAndReplace.run(&filters, &doc) res := m.Collection().FindOneAndReplace(ctx, filters, m.parseDocument(doc), opts...) - m.checkConditionsAndPanicForSingleResult(res) + m.checkConditionsAndPanic(res) lo.Must0(res.Decode(&resultDoc)) m.middleware.post.findOneAndReplace.run(&resultDoc) return resultDoc @@ -169,7 +169,7 @@ func (m Model[T]) FindByIDAndReplace(id any, doc any, opts ...*options.FindOneAn var resultDoc T res := m.Collection().FindOneAndReplace(ctx, primitive.M{"_id": utils.EnsureObjectID(id)}, m.parseDocument(doc), parseUpdateOptions(m, opts)...) - m.checkConditionsAndPanicForSingleResult(res) + m.checkConditionsAndPanic(res) lo.Must0(res.Decode(&resultDoc)) return resultDoc } diff --git a/core/model_utils.go b/core/model_utils.go index 8838be4..44330fc 100644 --- a/core/model_utils.go +++ b/core/model_utils.go @@ -88,18 +88,21 @@ func (m Model[T]) addToPipeline(stage, key string, value any) Model[T] { return m } -func (m Model[T]) checkConditionsAndPanic(results []T) { - if m.failWith != nil && len(results) == 0 { - panic(*m.failWith) - } -} - -func (m Model[T]) checkConditionsAndPanicForSingleResult(result *mongo.SingleResult) { - if result.Err() != nil { - if m.failWith != nil { +func (m Model[T]) checkConditionsAndPanic(result any) { + sliceT, okT := result.([]T) + if slice, ok := result.([]any); ok || okT { + if m.failWith != nil && (len(slice) == 0 || len(sliceT) == 0) { panic(*m.failWith) } - panic(result.Err()) + return + } + if singleResult, ok := result.(*mongo.SingleResult); ok { + if err := singleResult.Err(); err != nil { + if m.failWith != nil { + panic(*m.failWith) + } + panic(err) + } } } diff --git a/core/schema_validator.go b/core/schema_validator.go index f49b591..99daa4a 100644 --- a/core/schema_validator.go +++ b/core/schema_validator.go @@ -88,14 +88,16 @@ func enforceSchema[T any](schema Schema, doc *T, reflectedEntityType *reflect.Ty entityToInsert[fieldBsonName] = enforceSchema(*definition.Schema, utils.Cast[*bson.M](val), &subdocumentField.Type, false) continue } + } + + if definition.Type == reflect.Struct || definition.Type == ObjectID { // Extract subdocument ID if it exists for ObjectID references - if hasRef && val != nil && actualType.Kind() == reflect.Struct { - subdocumentField := reflectedField - if subdocumentIDField, ok := subdocumentField.Type.FieldByName("ID"); ok { + if hasRef && val != nil && (actualType.Kind() == reflect.Struct || actualType.Kind() == reflect.Interface) { + if id, ok := utils.CastBSON[bson.M](val)["_id"]; ok { entityToInsert = lo.Assign( entityToInsert, bson.M{ - fieldBsonName: val.(primitive.M)[subdocumentIDField.Tag.Get("bson")], + fieldBsonName: id, }, ) } diff --git a/tests/core_read_populate_test.go b/tests/core_read_populate_test.go index b41c4a7..4ea89ed 100644 --- a/tests/core_read_populate_test.go +++ b/tests/core_read_populate_test.go @@ -61,35 +61,39 @@ func TestCoreReadPopulate(t *testing.T) { Convey("Find with populated fields", t, func() { Convey("Populate a with multiple calls", func() { - bestiary := BestiaryModel.Find().Populate("monster").Populate("kingdom").Exec().([]DetailedBestiary) - So(bestiary, ShouldHaveLength, 3) - So(bestiary[0].Monster.Name, ShouldEqual, "Katakan") - So(bestiary[0].Monster.Category, ShouldEqual, "Vampire") - So(bestiary[0].Kingdom.Name, ShouldEqual, "Nilfgaard") + bestiaries := make([]DetailedBestiary, 0) + BestiaryModel.Find().Populate("monster").Populate("kingdom").ExecInto(&bestiaries) + So(bestiaries, ShouldHaveLength, 3) + So(bestiaries[0].Monster.Name, ShouldEqual, "Katakan") + So(bestiaries[0].Monster.Category, ShouldEqual, "Vampire") + So(bestiaries[0].Kingdom.Name, ShouldEqual, "Nilfgaard") }) Convey("Populate with a single call", func() { - bestiary := BestiaryModel.Find().Populate("monster", "kingdom").Exec().([]DetailedBestiary) - So(bestiary, ShouldHaveLength, 3) - So(bestiary[0].Monster.Name, ShouldEqual, "Katakan") - So(bestiary[0].Monster.Category, ShouldEqual, "Vampire") - So(bestiary[0].Kingdom.Name, ShouldEqual, "Nilfgaard") + var bestiaries []DetailedBestiary + BestiaryModel.Find().Populate("monster", "kingdom").ExecInto(&bestiaries) + So(bestiaries, ShouldHaveLength, 3) + So(bestiaries[0].Monster.Name, ShouldEqual, "Katakan") + So(bestiaries[0].Monster.Category, ShouldEqual, "Vampire") + So(bestiaries[0].Kingdom.Name, ShouldEqual, "Nilfgaard") }) Convey("Populate with a single call (Comma separated string)", func() { - bestiary := BestiaryModel.Find().Populate("monster kingdom").Exec().([]DetailedBestiary) - So(bestiary, ShouldHaveLength, 3) - So(bestiary[0].Monster.Name, ShouldEqual, "Katakan") - So(bestiary[0].Monster.Category, ShouldEqual, "Vampire") - So(bestiary[0].Kingdom.Name, ShouldEqual, "Nilfgaard") + var bestiaries []DetailedBestiary + BestiaryModel.Find().Populate("monster kingdom").ExecInto(&bestiaries) + So(bestiaries, ShouldHaveLength, 3) + So(bestiaries[0].Monster.Name, ShouldEqual, "Katakan") + So(bestiaries[0].Monster.Category, ShouldEqual, "Vampire") + So(bestiaries[0].Kingdom.Name, ShouldEqual, "Nilfgaard") }) Convey("Populate with select", func() { - bestiary := BestiaryModel.Find().Populate(primitive.M{ + var bestiaries []DetailedBestiary + BestiaryModel.Find().Populate(primitive.M{ "path": "monster", "select": primitive.M{"name": 1}, - }, "kingdom").Exec().([]DetailedBestiary) - So(bestiary, ShouldHaveLength, 3) - So(bestiary[0].Monster.Name, ShouldEqual, "Katakan") - So(bestiary[0].Monster.Category, ShouldEqual, "") - So(bestiary[0].Kingdom.Name, ShouldEqual, "Nilfgaard") + }, "kingdom").ExecInto(&bestiaries) + So(bestiaries, ShouldHaveLength, 3) + So(bestiaries[0].Monster.Name, ShouldEqual, "Katakan") + So(bestiaries[0].Monster.Category, ShouldEqual, "") + So(bestiaries[0].Kingdom.Name, ShouldEqual, "Nilfgaard") }) }) } diff --git a/tests/plugin_filter_query_test.go b/tests/plugin_filter_query_test.go index 61a8190..881ab74 100644 --- a/tests/plugin_filter_query_test.go +++ b/tests/plugin_filter_query_test.go @@ -272,7 +272,8 @@ func TestPluginFilterQuery(t *testing.T) { Kingdom: kingdom, }).Exec() - bestiaries := BestiaryModel.QS("include=monster,kingdom").Exec().([]DetailedBestiary) + var bestiaries []DetailedBestiary + BestiaryModel.QS("include=monster,kingdom").ExecInto(&bestiaries) So(bestiaries, ShouldHaveLength, 1) So(bestiaries[0].Monster.Name, ShouldEqual, monster.Name) So(bestiaries[0].Monster.Category, ShouldEqual, monster.Category) From a5120a94a4478c37ccd5c1afff2c807cabd4fc89 Mon Sep 17 00:00:00 2001 From: Akalanka Perera Date: Thu, 5 Jun 2025 22:39:54 +0530 Subject: [PATCH 06/22] fix: issue in update document parse util --- core/model_utils.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/core/model_utils.go b/core/model_utils.go index 44330fc..fb588e8 100644 --- a/core/model_utils.go +++ b/core/model_utils.go @@ -129,6 +129,9 @@ func (m Model[T]) parseDocument(doc any) bson.M { if docType == reflect.Ptr { doc = reflect.ValueOf(doc).Elem().Interface() } + if docType == reflect.Map { + return utils.Cast[bson.M](doc) + } result := utils.CastBSON[bson.M](doc) for k, v := range result { fieldValue := reflect.ValueOf(v) From 89ce24120f2ff6b79db2f4d7fee1641eaea1287c Mon Sep 17 00:00:00 2001 From: Akalanka Perera Date: Thu, 5 Jun 2025 22:41:45 +0530 Subject: [PATCH 07/22] perf: replace reflect with type assertion --- core/model_utils.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/core/model_utils.go b/core/model_utils.go index fb588e8..bd0b853 100644 --- a/core/model_utils.go +++ b/core/model_utils.go @@ -129,7 +129,8 @@ func (m Model[T]) parseDocument(doc any) bson.M { if docType == reflect.Ptr { doc = reflect.ValueOf(doc).Elem().Interface() } - if docType == reflect.Map { + switch doc.(type) { + case bson.M, map[string]any: return utils.Cast[bson.M](doc) } result := utils.CastBSON[bson.M](doc) From 37b2accd2b0d4821c35e956e587dbfec55083610 Mon Sep 17 00:00:00 2001 From: Akalanka Perera Date: Tue, 10 Jun 2025 09:56:53 +0530 Subject: [PATCH 08/22] test: added support for run arg in makefile script --- Makefile | 11 +++++++++-- lefthook.yml | 4 ++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index f2bc374..5c67b55 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,7 @@ format: test: PARALLEL_CONVEY=false make test-lightspeed test-lightspeed: - go test $(GO_TEST_ARGS) -v --count=1 ./tests/... + go test $(GO_TEST_ARGS) --run=${run} -v --count=1 ./tests/... test-coverage: @mkdir -p ./coverage make test GO_TEST_ARGS="--cover -coverpkg=./cmd/...,./core/...,./plugins/...,./utils/... --coverprofile=./coverage/coverage.out" @@ -30,9 +30,16 @@ install: go install github.com/evilmartians/lefthook@v1.11.12 lefthook install @echo "\033[0;32mLefthook installed and configured successfully.\033[0m" + go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 + @echo "\033[0;32mGolangCI-Lint installed successfully.\033[0m" @which npm > /dev/null && \ npm install -g @commitlint/config-conventional@17.6.5 @commitlint/cli@17.6.5 && \ echo "\033[0;32mCommitlint installed successfully.\033[0m" || \ echo "\033[0;31mNode is not installed. Please install Node.js to use commitlint.\033[0m" go mod tidy - @echo "\033[0;32mGo modules installed successfully.\033[0m" \ No newline at end of file + @echo "\033[0;32mGo modules installed successfully.\033[0m" +tidy: + @echo "\033[0;32mRunning go mod tidy...\033[0m" + @GOPRIVATE="github.com/clubpay*" go mod tidy -v + @echo "\033[0;32mVerifying packages...\033[0m" + @GOPRIVATE="github.com/clubpay*" go mod verify \ No newline at end of file diff --git a/lefthook.yml b/lefthook.yml index 65788d7..2700e8f 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -3,6 +3,10 @@ pre-commit: commands: format: run: make format && git add . + lint: + run: make lint-fix && git add . + tidy: + run: make tidy && git add . commit-msg: commands: From 0065f144446e3d9553e56849660040abab684973 Mon Sep 17 00:00:00 2001 From: Akalanka Perera Date: Tue, 10 Jun 2025 10:29:54 +0530 Subject: [PATCH 09/22] test: added more test cases --- core/model_query.go | 4 ++- tests/core_delete_test.go | 4 +-- tests/core_read_populate_test.go | 8 ++++++ tests/core_schema_test.go | 42 +++++++++++++++++++++++++++++++- tests/tests.go | 2 ++ 5 files changed, 56 insertions(+), 4 deletions(-) diff --git a/core/model_query.go b/core/model_query.go index e373cca..d6c8b4a 100644 --- a/core/model_query.go +++ b/core/model_query.go @@ -134,7 +134,9 @@ func (m Model[T]) ExecSS(ctx ...context.Context) []string { } // ExecInto is a specialized method that executes the query and unmarshals the result into the provided result variable. -// It is useful when you want to extract results into a custom struct other than the model type such as when you populate certain fields +// It is useful when you want to extract results into a custom struct other than the model type such as when you populate certain fields. +// +// The result variable must be a pointer to your desired type. func (m Model[T]) ExecInto(result any, ctx ...context.Context) { rv, bytes := lo.Must2(bson.MarshalValue(m.Exec(ctx...))) bson.UnmarshalValue(rv, bytes, result) diff --git a/tests/core_delete_test.go b/tests/core_delete_test.go index d6f8894..55d3de0 100644 --- a/tests/core_delete_test.go +++ b/tests/core_delete_test.go @@ -53,13 +53,13 @@ func TestCoreDelete(t *testing.T) { So(user.Name, ShouldEqual, mocks.Caranthir.Name) So(func() { - UserModel.SetConnection(uuid.NewString()).DeleteByID(user.ID).OrFail().Exec() + UserModel.SetConnection(uuid.NewString()).FindByIDAndDelete(user.ID).OrFail().Exec() }, ShouldPanicWith, errors.New("no results found matching the given query")) Convey("With custom error", func() { err := errors.New("some custom error") So(func() { - UserModel.SetConnection(uuid.NewString()).DeleteByID(user.ID).OrFail(err).Exec() + UserModel.SetConnection(uuid.NewString()).FindByIDAndDelete(user.ID).OrFail(err).Exec() }, ShouldPanicWith, err) }) }) diff --git a/tests/core_read_populate_test.go b/tests/core_read_populate_test.go index 4ea89ed..161fcf7 100644 --- a/tests/core_read_populate_test.go +++ b/tests/core_read_populate_test.go @@ -95,5 +95,13 @@ func TestCoreReadPopulate(t *testing.T) { So(bestiaries[0].Monster.Category, ShouldEqual, "") So(bestiaries[0].Kingdom.Name, ShouldEqual, "Nilfgaard") }) + Convey("Populate just one field", func() { + var bestiaries []GenericBestiary[Monster, primitive.ObjectID] + BestiaryModel.Find().Populate("monster").ExecInto(&bestiaries) + So(bestiaries, ShouldHaveLength, 3) + So(bestiaries[0].Monster.Name, ShouldEqual, "Katakan") + So(bestiaries[0].Monster.Category, ShouldEqual, "Vampire") + So(bestiaries[0].Kingdom, ShouldEqual, kingdoms[0].ID) + }) }) } diff --git a/tests/core_schema_test.go b/tests/core_schema_test.go index fa67b44..4029e17 100644 --- a/tests/core_schema_test.go +++ b/tests/core_schema_test.go @@ -8,6 +8,7 @@ import ( elemental "github.com/elcengine/elemental/core" ts "github.com/elcengine/elemental/tests/fixtures/setup" "github.com/google/uuid" + "github.com/samber/lo" . "github.com/smartystreets/goconvey/convey" "go.mongodb.org/mongo-driver/mongo/options" @@ -87,9 +88,35 @@ func TestCoreSchemaOptions(t *testing.T) { Model.Validate(User{}) }, ShouldPanicWith, fmt.Errorf("field Name is required")) So(func() { - UserModel.Validate(User{Name: "Geralt"}) + Model.Validate(User{Name: "Geralt"}) + }, ShouldNotPanic) + }) + Convey("Pointer type check", func() { + type User struct { + Name *string `json:"name"` + } + Model := elemental.NewModel[User](uuid.NewString(), elemental.NewSchema(map[string]elemental.Field{ + "Name": { + Type: elemental.String, + }, + })) + So(func() { + Model.Validate(User{Name: lo.ToPtr("Geralt")}) }, ShouldNotPanic) }) + Convey("Type check", func() { + type InvalidUser struct { + Name int64 `json:"name"` + } + Model := elemental.NewModel[InvalidUser](uuid.NewString(), elemental.NewSchema(map[string]elemental.Field{ + "Name": { + Type: elemental.String, + }, + })) + So(func() { + Model.Validate(InvalidUser{Name: 12345}) + }, ShouldPanicWith, fmt.Errorf("field Name has an invalid type. It must be of type string")) + }) Convey("Min check", func() { Model := elemental.NewModel[User](uuid.NewString(), elemental.NewSchema(map[string]elemental.Field{ "Age": { @@ -152,6 +179,19 @@ func TestCoreSchemaOptions(t *testing.T) { Model.Validate(User{Name: "GERALT"}) }, ShouldNotPanic) }) + Convey("Ignore non existing definitions", func() { + Model := elemental.NewModel[User](uuid.NewString(), elemental.NewSchema(map[string]elemental.Field{ + "Name": { + Type: elemental.String, + }, + "NonExistingField": { + Type: elemental.String, + }, + })) + So(func() { + Model.Validate(User{Name: "Geralt"}) + }, ShouldNotPanic) + }) }) }) } diff --git a/tests/tests.go b/tests/tests.go index f63f0dc..8e257e0 100644 --- a/tests/tests.go +++ b/tests/tests.go @@ -14,6 +14,8 @@ type Kingdom = fixtures.Kingdom type Monster = fixtures.Monster +type GenericBestiary[T any, Y any] = fixtures.GenericBestiary[T, Y] + type Bestiary = fixtures.Bestiary type DetailedBestiary = fixtures.DetailedBestiary From c49af807e01f61174513a2339faf841321e39ae6 Mon Sep 17 00:00:00 2001 From: Akalanka Perera Date: Tue, 10 Jun 2025 10:36:42 +0530 Subject: [PATCH 10/22] ci: enabled support for generic type alias in tests for go 1.23 --- .github/workflows/tests.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 356fc67..1f0b08c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -57,6 +57,7 @@ jobs: env: DEFAULT_DATASOURCE: mongodb://127.0.0.1:27017/elemental?replicaSet=rs0 SECONDARY_DATASOURCE: mongodb://127.0.0.1:27018/elemental?replicaSet=rs1 + GOEXPERIMENT: ${{ matrix.go-version == '1.23' && 'aliastypeparams' || '' }} - name: Upload coverage report if: github.event_name == 'pull_request' && github.base_ref == 'main' && matrix.go-version == '1.24' && matrix.mongo-version == '8.0' From 543373dd2ec1fe5a6bfe2a3499c377e7c8e54cd7 Mon Sep 17 00:00:00 2001 From: Akalanka Perera Date: Tue, 10 Jun 2025 10:44:29 +0530 Subject: [PATCH 11/22] ci: enabled support for generic type alias in tests for go 1.23 --- .github/workflows/tests.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1f0b08c..8917d39 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -57,7 +57,8 @@ jobs: env: DEFAULT_DATASOURCE: mongodb://127.0.0.1:27017/elemental?replicaSet=rs0 SECONDARY_DATASOURCE: mongodb://127.0.0.1:27018/elemental?replicaSet=rs1 - GOEXPERIMENT: ${{ matrix.go-version == '1.23' && 'aliastypeparams' || '' }} + GOEXPERIMENT: ${{ matrix.go-version == '1.23' && 'aliastypeparams' }} + GODEBUG: ${{ matrix.go-version == '1.23' && 'gotypesalias=1' }} - name: Upload coverage report if: github.event_name == 'pull_request' && github.base_ref == 'main' && matrix.go-version == '1.24' && matrix.mongo-version == '8.0' From 919a223333739132a1c19692348166501e0d3fe4 Mon Sep 17 00:00:00 2001 From: Akalanka Perera Date: Tue, 10 Jun 2025 10:46:55 +0530 Subject: [PATCH 12/22] ci: fixed syntax err in envs --- .github/workflows/tests.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 8917d39..6dcf5f3 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -57,8 +57,8 @@ jobs: env: DEFAULT_DATASOURCE: mongodb://127.0.0.1:27017/elemental?replicaSet=rs0 SECONDARY_DATASOURCE: mongodb://127.0.0.1:27018/elemental?replicaSet=rs1 - GOEXPERIMENT: ${{ matrix.go-version == '1.23' && 'aliastypeparams' }} - GODEBUG: ${{ matrix.go-version == '1.23' && 'gotypesalias=1' }} + GOEXPERIMENT: ${{ matrix.go-version == '1.23' && 'aliastypeparams' || '' }} + GODEBUG: ${{ matrix.go-version == '1.23' && 'gotypesalias=1' || '' }} - name: Upload coverage report if: github.event_name == 'pull_request' && github.base_ref == 'main' && matrix.go-version == '1.24' && matrix.mongo-version == '8.0' From ec0549e894764fb2e36793fca28e4a71f2a6c350 Mon Sep 17 00:00:00 2001 From: Akalanka Perera Date: Thu, 12 Jun 2025 21:25:06 +0530 Subject: [PATCH 13/22] test: covered a few more things --- .gitignore | 4 +++- tests/cmd_test.go | 3 +++ tests/core_middleware_test.go | 43 ++++++++++++++++++++++++++++------- tests/core_schema_test.go | 40 ++++++++++++++++++++++++++++++++ utils/query.go | 5 +--- utils/struct.go | 7 +----- 6 files changed, 83 insertions(+), 19 deletions(-) diff --git a/.gitignore b/.gitignore index 10de232..ea46773 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,6 @@ database .elemental -.vscode \ No newline at end of file +.vscode + +.env \ No newline at end of file diff --git a/tests/cmd_test.go b/tests/cmd_test.go index 3bffbb5..bcabb7a 100644 --- a/tests/cmd_test.go +++ b/tests/cmd_test.go @@ -45,6 +45,9 @@ func TestCmd(t *testing.T) { So(err, ShouldBeNil) So(cfg.ConnectionStr, ShouldEqual, mocks.DEFAULT_DATASOURCE) + + cmd.RootCmd.SetArgs([]string{"init", mocks.DEFAULT_DATASOURCE}) + cmd.Execute() // Should do nothing if the file already exists }) Convey("Migrations and seeds", t, func() { diff --git a/tests/core_middleware_test.go b/tests/core_middleware_test.go index bc20eb5..d1cae63 100644 --- a/tests/core_middleware_test.go +++ b/tests/core_middleware_test.go @@ -2,6 +2,7 @@ package tests import ( "testing" + "time" elemental "github.com/elcengine/elemental/core" ts "github.com/elcengine/elemental/tests/fixtures/setup" @@ -28,11 +29,15 @@ func TestCoreMiddleware(t *testing.T) { CastleModel.PreSave(func(castle *bson.M) bool { invokedHooks["preSave"] = true + (*castle)["created_at"] = time.Now().AddDate(-1, 0, 0) return true }) CastleModel.PostSave(func(castle *bson.M) bool { invokedHooks["postSave"] = true + if name, ok := (*castle)["name"].(string); ok { + (*castle)["name"] = "Created: " + name + } return true }) @@ -78,6 +83,9 @@ func TestCoreMiddleware(t *testing.T) { CastleModel.PostFind(func(castle *[]Castle) bool { invokedHooks["postFind"] = true + for i := range *castle { + (*castle)[i].Name = "Modified: " + (*castle)[i].Name + } return true }) @@ -88,6 +96,9 @@ func TestCoreMiddleware(t *testing.T) { CastleModel.PostFindOneAndUpdate(func(castle *Castle) bool { invokedHooks["postFindOneAndUpdate"] = true + if castle != nil { + castle.Name = "Updated: " + castle.Name + } return true }) @@ -98,6 +109,9 @@ func TestCoreMiddleware(t *testing.T) { CastleModel.PostFindOneAndDelete(func(castle *Castle) bool { invokedHooks["postFindOneAndDelete"] = true + if castle != nil { + castle.Name = "Deleted: " + castle.Name + } return true }) @@ -108,32 +122,37 @@ func TestCoreMiddleware(t *testing.T) { CastleModel.PostFindOneAndReplace(func(castle *Castle) bool { invokedHooks["postFindOneAndReplace"] = true + if castle != nil { + castle.Name = "Replaced: " + castle.Name + } return true }) CastleModel.Create(Castle{Name: "Aretuza"}).Exec() - CastleModel.Create(Castle{Name: "Maverick"}).Exec() + CastleModel.Create(Castle{Name: "Rozrog"}).Exec() - CastleModel.Create(Castle{Name: "Robert"}).Exec() + createdCastle := CastleModel.Create(Castle{Name: "Drakenborg"}).ExecT() - CastleModel.FindOneAndReplace(&primitive.M{"name": "Robert"}, Castle{Name: "Jack"}).Exec() + replacedCastle := CastleModel.FindOneAndReplace(&primitive.M{"name": "Drakenborg"}, Castle{Name: "Tesham Mutna"}).ExecPtr() CastleModel.UpdateOne(&primitive.M{"name": "Aretuza"}, Castle{Name: "Kaer Morhen"}).Exec() - CastleModel.Find().Exec() - - CastleModel.FindOneAndUpdate(&primitive.M{"name": "Maverick"}, primitive.M{"name": "Maverickk"}).Exec() + updatedCastle := CastleModel.FindOneAndUpdate(&primitive.M{"name": "Rozrog"}, primitive.M{"name": "Rozrog Ruins"}).ExecPtr() CastleModel.DeleteOne(primitive.M{"name": "Kaer Morhen"}).Exec() - CastleModel.FindOneAndDelete(primitive.M{"name": "Jack"}).Exec() + deletedCastle := CastleModel.FindOneAndDelete(primitive.M{"name": "Tesham Mutna"}).ExecPtr() + + CastleModel.DeleteMany(primitive.M{"name": primitive.M{"$in": []string{"Aretuza", "Rozrog"}}}).Exec() - CastleModel.DeleteMany(primitive.M{"name": primitive.M{"$in": []string{"Aretuza", "Maverick"}}}).Exec() + castles := CastleModel.Find().ExecTT() Convey("Pre hooks", t, func() { Convey("Save", func() { So(invokedHooks["preSave"], ShouldBeTrue) + firstInsertedCastle := CastleModel.FindOne().ExecT() + So(firstInsertedCastle.CreatedAt.Year(), ShouldEqual, time.Now().AddDate(-1, 0, 0).Year()) }) Convey("UpdateOne", func() { So(invokedHooks["preUpdateOne"], ShouldBeTrue) @@ -160,6 +179,7 @@ func TestCoreMiddleware(t *testing.T) { So(invokedHooks["postSave"], ShouldBeTrue) So(invokedHooks["postSaveSecond"], ShouldBeTrue) So(invokedHooks["postSaveThird"], ShouldBeFalse) + So(createdCastle.Name, ShouldEqual, "Created: Drakenborg") }) Convey("UpdateOne", func() { So(invokedHooks["postUpdateOne"], ShouldBeTrue) @@ -172,15 +192,22 @@ func TestCoreMiddleware(t *testing.T) { }) Convey("Find", func() { So(invokedHooks["postFind"], ShouldBeTrue) + So(castles, ShouldNotBeEmpty) + for _, castle := range castles { + So(castle.Name, ShouldStartWith, "Modified: ") + } }) Convey("FindOneAndUpdate", func() { So(invokedHooks["postFindOneAndUpdate"], ShouldBeTrue) + So(updatedCastle.Name, ShouldEqual, "Updated: Rozrog") }) Convey("FindOneAndDelete", func() { So(invokedHooks["postFindOneAndDelete"], ShouldBeTrue) + So(deletedCastle.Name, ShouldEqual, "Deleted: Tesham Mutna") }) Convey("FindOneAndReplace", func() { So(invokedHooks["postFindOneAndReplace"], ShouldBeTrue) + So(replacedCastle.Name, ShouldEqual, "Replaced: Drakenborg") }) }) } diff --git a/tests/core_schema_test.go b/tests/core_schema_test.go index 4029e17..cd0edc6 100644 --- a/tests/core_schema_test.go +++ b/tests/core_schema_test.go @@ -193,5 +193,45 @@ func TestCoreSchemaOptions(t *testing.T) { }, ShouldNotPanic) }) }) + Convey("Should use default values when provided", func() { + Convey("Default value of a primitive", func() { + Model := elemental.NewModel[User](uuid.NewString(), elemental.NewSchema(map[string]elemental.Field{ + "Name": { + Type: elemental.String, + }, + "Age": { + Type: elemental.Int, + Default: 30, + }, + })) + user := Model.Create(User{Name: uuid.NewString()}).ExecT() + So(user.Age, ShouldEqual, 30) + }) + Convey("Default value of a struct", func() { + type UserPreferences struct { + Language string `json:"language" bson:"language"` + Theme string `json:"theme" bson:"theme"` + } + type User struct { + Name string `json:"name" bson:"name"` + Preferences *UserPreferences `json:"preferences" bson:"preferences"` + } + Model := elemental.NewModel[User](uuid.NewString(), elemental.NewSchema(map[string]elemental.Field{ + "Name": { + Type: elemental.String, + }, + "Preferences": { + Type: elemental.Struct, + Default: UserPreferences{ + Language: "en", + Theme: "light", + }, + }, + })) + user := Model.Create(User{Name: uuid.NewString()}).ExecT() + So(user.Preferences.Language, ShouldEqual, "en") + So(user.Preferences.Theme, ShouldEqual, "light") + }) + }) }) } diff --git a/utils/query.go b/utils/query.go index 4699018..181f543 100644 --- a/utils/query.go +++ b/utils/query.go @@ -14,10 +14,7 @@ func MergedQueryOrDefault(query []primitive.M) primitive.M { func EnsureObjectID(id any) primitive.ObjectID { if idStr, ok := id.(string); ok { - parsed, err := primitive.ObjectIDFromHex(idStr) - if err != nil { - return primitive.NilObjectID - } + parsed, _ := primitive.ObjectIDFromHex(idStr) return parsed } else if idRaw, ok := id.(primitive.ObjectID); ok { return idRaw diff --git a/utils/struct.go b/utils/struct.go index 642d213..0273a71 100644 --- a/utils/struct.go +++ b/utils/struct.go @@ -1,19 +1,14 @@ package utils import ( - "reflect" - - "github.com/samber/lo" "go.mongodb.org/mongo-driver/bson/primitive" + "reflect" ) func IsEmpty(value any) bool { if value == nil { return true } - if lo.IsEmpty(value) { - return true - } if dt, ok := value.(primitive.DateTime); ok { return dt.Time().IsZero() } From 0b8465a15ac8ce477031dd28fe202cf3edee73e8 Mon Sep 17 00:00:00 2001 From: Akalanka Perera Date: Sun, 15 Jun 2025 18:27:38 +0530 Subject: [PATCH 14/22] feat: added support for sub pipelines in populate --- core/model_populate.go | 14 +++++-- tests/core_read_populate_test.go | 64 +++++++++++++++++++++++++++----- 2 files changed, 65 insertions(+), 13 deletions(-) diff --git a/core/model_populate.go b/core/model_populate.go index fe0f94b..48c4ed4 100644 --- a/core/model_populate.go +++ b/core/model_populate.go @@ -15,10 +15,12 @@ import ( func (m Model[T]) populate(value any) Model[T] { var path, fieldname string var selectField any + var subpipeline any switch v := value.(type) { case primitive.M: path = utils.Cast[string](v["path"]) selectField = v["select"] + subpipeline = v["pipeline"] default: path = utils.Cast[string](value) } @@ -34,20 +36,22 @@ func (m Model[T]) populate(value any) Model[T] { schemaField := m.Schema.Field(fieldname) if schemaField != nil { collection := schemaField.Collection - if lo.IsEmpty(collection) { - if !lo.IsEmpty(schemaField.Ref) { + if collection == "" { + if schemaField.Ref != "" { model := reflect.ValueOf(Models[schemaField.Ref]) collection = model.FieldByName("Schema").FieldByName("Options").FieldByName("Collection").String() } } - if !lo.IsEmpty(collection) { + if collection != "" { lookup := primitive.M{ "from": collection, "localField": path, "foreignField": "_id", "as": path, } - if selectField != nil { + if subpipeline != nil { + lookup["pipeline"] = subpipeline + } else if selectField != nil { lookup["pipeline"] = []primitive.M{ {"$project": selectField}, } @@ -71,6 +75,8 @@ func (m Model[T]) populate(value any) Model[T] { // Finds and attaches the referenced documents to the main document returned by the query. // The fields to populate must have a 'Collection' or 'Ref' property in their schema definition. +// +// It can accept a single string, a slice of strings, or a map with 'path' and optionally a 'select' or a 'pipeline' key. func (m Model[T]) Populate(values ...any) Model[T] { if len(values) == 1 { if str, ok := values[0].(string); ok && (strings.Contains(str, ",") || strings.Contains(str, " ")) { diff --git a/tests/core_read_populate_test.go b/tests/core_read_populate_test.go index 161fcf7..9734962 100644 --- a/tests/core_read_populate_test.go +++ b/tests/core_read_populate_test.go @@ -3,6 +3,7 @@ package tests import ( "testing" + elemental "github.com/elcengine/elemental/core" ts "github.com/elcengine/elemental/tests/fixtures/setup" . "github.com/smartystreets/goconvey/convey" "go.mongodb.org/mongo-driver/bson/primitive" @@ -59,30 +60,43 @@ func TestCoreReadPopulate(t *testing.T) { }, }).Exec() + SoKatakan := func(bestiary DetailedBestiary) { + t.Helper() + So(bestiary.Monster.Name, ShouldEqual, "Katakan") + So(bestiary.Monster.Category, ShouldEqual, "Vampire") + So(bestiary.Kingdom.Name, ShouldEqual, "Nilfgaard") + So(bestiary.Kingdom.ID, ShouldEqual, kingdoms[0].ID) + } + + SoDrowner := func(bestiary DetailedBestiary) { + t.Helper() + So(bestiary.Monster.Name, ShouldEqual, "Drowner") + So(bestiary.Monster.Category, ShouldEqual, "Drowner") + So(bestiary.Kingdom.Name, ShouldEqual, "Redania") + So(bestiary.Kingdom.ID, ShouldEqual, kingdoms[1].ID) + } + Convey("Find with populated fields", t, func() { Convey("Populate a with multiple calls", func() { bestiaries := make([]DetailedBestiary, 0) BestiaryModel.Find().Populate("monster").Populate("kingdom").ExecInto(&bestiaries) So(bestiaries, ShouldHaveLength, 3) - So(bestiaries[0].Monster.Name, ShouldEqual, "Katakan") - So(bestiaries[0].Monster.Category, ShouldEqual, "Vampire") - So(bestiaries[0].Kingdom.Name, ShouldEqual, "Nilfgaard") + SoKatakan(bestiaries[0]) + SoDrowner(bestiaries[1]) }) Convey("Populate with a single call", func() { var bestiaries []DetailedBestiary BestiaryModel.Find().Populate("monster", "kingdom").ExecInto(&bestiaries) So(bestiaries, ShouldHaveLength, 3) - So(bestiaries[0].Monster.Name, ShouldEqual, "Katakan") - So(bestiaries[0].Monster.Category, ShouldEqual, "Vampire") - So(bestiaries[0].Kingdom.Name, ShouldEqual, "Nilfgaard") + SoKatakan(bestiaries[0]) + SoDrowner(bestiaries[1]) }) Convey("Populate with a single call (Comma separated string)", func() { var bestiaries []DetailedBestiary BestiaryModel.Find().Populate("monster kingdom").ExecInto(&bestiaries) So(bestiaries, ShouldHaveLength, 3) - So(bestiaries[0].Monster.Name, ShouldEqual, "Katakan") - So(bestiaries[0].Monster.Category, ShouldEqual, "Vampire") - So(bestiaries[0].Kingdom.Name, ShouldEqual, "Nilfgaard") + SoKatakan(bestiaries[0]) + SoDrowner(bestiaries[1]) }) Convey("Populate with select", func() { var bestiaries []DetailedBestiary @@ -95,6 +109,19 @@ func TestCoreReadPopulate(t *testing.T) { So(bestiaries[0].Monster.Category, ShouldEqual, "") So(bestiaries[0].Kingdom.Name, ShouldEqual, "Nilfgaard") }) + Convey("Populate with a pipeline", func() { + var bestiaries []DetailedBestiary + BestiaryModel.Find().Populate("kingdom", primitive.M{ + "path": "monster", + "pipeline": []primitive.M{ + {"$addFields": primitive.M{"name": primitive.M{"$concat": []string{"Rare ", "$name"}}}}, + }, + }).ExecInto(&bestiaries) + So(bestiaries, ShouldHaveLength, 3) + So(bestiaries[0].Monster.Name, ShouldEqual, "Rare Katakan") + So(bestiaries[0].Monster.Category, ShouldEqual, "Vampire") + So(bestiaries[0].Kingdom.Name, ShouldEqual, "Nilfgaard") + }) Convey("Populate just one field", func() { var bestiaries []GenericBestiary[Monster, primitive.ObjectID] BestiaryModel.Find().Populate("monster").ExecInto(&bestiaries) @@ -103,5 +130,24 @@ func TestCoreReadPopulate(t *testing.T) { So(bestiaries[0].Monster.Category, ShouldEqual, "Vampire") So(bestiaries[0].Kingdom, ShouldEqual, kingdoms[0].ID) }) + Convey("Populate a model with just collection references", func() { + BestiaryModel := elemental.NewModel[Bestiary]("Bestiary", elemental.NewSchema(map[string]elemental.Field{ + "Monster": { + Type: elemental.ObjectID, + Collection: "monsters", + }, + "Kingdom": { + Type: elemental.ObjectID, + Collection: "kingdoms", + }, + }, elemental.SchemaOptions{ + Collection: "bestiary", + })) + var bestiaries []GenericBestiary[Monster, Kingdom] + BestiaryModel.Find().Populate("monster", "kingdom").ExecInto(&bestiaries) + So(bestiaries, ShouldHaveLength, 3) + SoKatakan(bestiaries[0]) + SoDrowner(bestiaries[1]) + }) }) } From e1f4f1d8d1efa0b4d3947ea76f63d18c394bd97e Mon Sep 17 00:00:00 2001 From: Akalanka Perera Date: Sun, 15 Jun 2025 21:18:17 +0530 Subject: [PATCH 15/22] feat: added support to populate with model field name --- core/model_audit.go | 11 +++++---- core/model_populate.go | 6 ++++- tests/core_audit_test.go | 30 +++++++++++------------- tests/core_read_populate_test.go | 40 +++++++++++++++++++++++--------- 4 files changed, 53 insertions(+), 34 deletions(-) diff --git a/core/model_audit.go b/core/model_audit.go index 40c4af9..a846f28 100644 --- a/core/model_audit.go +++ b/core/model_audit.go @@ -7,6 +7,7 @@ import ( "github.com/elcengine/elemental/utils" "github.com/samber/lo" + "github.com/spf13/cast" "go.mongodb.org/mongo-driver/bson" "go.mongodb.org/mongo-driver/bson/primitive" @@ -22,6 +23,8 @@ const ( AuditTypeDelete AuditType = "delete" ) +const AuditUserFallback = "System" + type Audit struct { Entity string `json:"entity" bson:"entity"` // The name of the model that was audited. Type AuditType `json:"type" bson:"type"` // The type of operation that was performed (insert, update, delete). @@ -64,14 +67,12 @@ func (m Model[T]) EnableAuditing(ctx ...context.Context) { q.Exec(context) } - userFallback := "System" - m.OnInsert(func(doc T) { execWithModelOpts(AuditModel.Create(Audit{ Entity: m.Name, Type: AuditTypeInsert, Document: utils.CastBSON[bson.M](doc), - User: lo.CoalesceOrEmpty(utils.Cast[string](context.Value(CtxUser)), userFallback), + User: lo.CoalesceOrEmpty(cast.ToString(context.Value(CtxUser)), AuditUserFallback), CreatedAt: time.Now(), })) }, TriggerOptions{Context: &context, Filter: &primitive.M{"ns.coll": primitive.M{"$eq": m.Collection().Name()}}}) @@ -80,7 +81,7 @@ func (m Model[T]) EnableAuditing(ctx ...context.Context) { Entity: m.Name, Type: AuditTypeUpdate, Document: utils.CastBSON[bson.M](doc), - User: lo.CoalesceOrEmpty(utils.Cast[string](context.Value(CtxUser)), userFallback), + User: lo.CoalesceOrEmpty(cast.ToString(context.Value(CtxUser)), AuditUserFallback), CreatedAt: time.Now(), })) }, TriggerOptions{Context: &context, Filter: &primitive.M{"ns.coll": primitive.M{"$eq": m.Collection().Name()}}}) @@ -89,7 +90,7 @@ func (m Model[T]) EnableAuditing(ctx ...context.Context) { Entity: m.Name, Type: AuditTypeDelete, Document: map[string]any{"_id": id}, - User: lo.CoalesceOrEmpty(utils.Cast[string](context.Value(CtxUser)), userFallback), + User: lo.CoalesceOrEmpty(cast.ToString(context.Value(CtxUser)), AuditUserFallback), CreatedAt: time.Now(), })) }, TriggerOptions{Context: &context, Filter: &primitive.M{"ns.coll": primitive.M{"$eq": m.Collection().Name()}}}) diff --git a/core/model_populate.go b/core/model_populate.go index 48c4ed4..6f9a1c3 100644 --- a/core/model_populate.go +++ b/core/model_populate.go @@ -27,7 +27,11 @@ func (m Model[T]) populate(value any) Model[T] { if path != "" { for i := range m.docReflectType.NumField() { field := m.docReflectType.Field(i) - if cleanTag(field.Tag.Get("bson")) == path { + tag := cleanTag(field.Tag.Get("bson")) + if path == field.Name { + path = tag + } + if path == tag { fieldname = field.Name break } diff --git a/tests/core_audit_test.go b/tests/core_audit_test.go index 13116ac..b8f78d8 100644 --- a/tests/core_audit_test.go +++ b/tests/core_audit_test.go @@ -30,36 +30,32 @@ func TestCoreAudit(t *testing.T) { ParallelConvey, Wait := pc.New(t) ParallelConvey("Insert", t, func() { - KingdomModel.Create(Kingdom{Name: "Nilfgaard"}).Exec() - SoTimeout(t, func() (ok bool) { + kingdom := KingdomModel.Create(Kingdom{Name: "Nilfgaard"}).ExecT() + SoTimeout(t, func() bool { audit := AuditModel.FindOne(primitive.M{"entity": entity, "type": elemental.AuditTypeInsert}).ExecT() - if audit.Type != "" { - ok = true - } - return + return audit.Type == elemental.AuditTypeInsert && + audit.User == elemental.AuditUserFallback && audit.Entity == KingdomModel.Name && audit.Document["_id"] == kingdom.ID }) }) ParallelConvey("Update", t, func() { KingdomModel.UpdateOne(&primitive.M{"name": "Nilfgaard"}, Kingdom{Name: "Redania"}).Exec() - SoTimeout(t, func() (ok bool) { + SoTimeout(t, func() bool { audit := AuditModel.FindOne(primitive.M{"entity": entity, "type": elemental.AuditTypeUpdate}).ExecT() - if audit.Type != "" { - ok = true - } - return + return audit.Type == elemental.AuditTypeUpdate && + audit.User == elemental.AuditUserFallback && audit.Entity == KingdomModel.Name && + audit.Document["name"] == "Redania" }) }) ParallelConvey("Delete", t, func() { - KingdomModel.Create(Kingdom{Name: "Skellige"}).Exec() + kingdom := KingdomModel.Create(Kingdom{Name: "Skellige"}).ExecT() KingdomModel.DeleteOne(primitive.M{"name": "Skellige"}).Exec() - SoTimeout(t, func() (ok bool) { + SoTimeout(t, func() bool { audit := AuditModel.FindOne(primitive.M{"entity": entity, "type": elemental.AuditTypeDelete}).ExecT() - if audit.Type != "" { - ok = true - } - return + return audit.Type == elemental.AuditTypeDelete && + audit.User == elemental.AuditUserFallback && audit.Entity == KingdomModel.Name && + audit.Document["_id"] == kingdom.ID }) }) diff --git a/tests/core_read_populate_test.go b/tests/core_read_populate_test.go index 9734962..46298a4 100644 --- a/tests/core_read_populate_test.go +++ b/tests/core_read_populate_test.go @@ -77,19 +77,37 @@ func TestCoreReadPopulate(t *testing.T) { } Convey("Find with populated fields", t, func() { - Convey("Populate a with multiple calls", func() { - bestiaries := make([]DetailedBestiary, 0) - BestiaryModel.Find().Populate("monster").Populate("kingdom").ExecInto(&bestiaries) - So(bestiaries, ShouldHaveLength, 3) - SoKatakan(bestiaries[0]) - SoDrowner(bestiaries[1]) + Convey("Populate with multiple calls", func() { + Convey("With bson field name", func() { + bestiaries := make([]DetailedBestiary, 0) + BestiaryModel.Find().Populate("monster").Populate("kingdom").ExecInto(&bestiaries) + So(bestiaries, ShouldHaveLength, 3) + SoKatakan(bestiaries[0]) + SoDrowner(bestiaries[1]) + }) + Convey("With model field name", func() { + bestiaries := make([]DetailedBestiary, 0) + BestiaryModel.Find().Populate("Monster").Populate("Kingdom").ExecInto(&bestiaries) + So(bestiaries, ShouldHaveLength, 3) + SoKatakan(bestiaries[0]) + SoDrowner(bestiaries[1]) + }) }) Convey("Populate with a single call", func() { - var bestiaries []DetailedBestiary - BestiaryModel.Find().Populate("monster", "kingdom").ExecInto(&bestiaries) - So(bestiaries, ShouldHaveLength, 3) - SoKatakan(bestiaries[0]) - SoDrowner(bestiaries[1]) + Convey("With bson field name", func() { + var bestiaries []DetailedBestiary + BestiaryModel.Find().Populate("monster", "kingdom").ExecInto(&bestiaries) + So(bestiaries, ShouldHaveLength, 3) + SoKatakan(bestiaries[0]) + SoDrowner(bestiaries[1]) + }) + Convey("With model field name", func() { + var bestiaries []DetailedBestiary + BestiaryModel.Find().Populate("Monster", "Kingdom").ExecInto(&bestiaries) + So(bestiaries, ShouldHaveLength, 3) + SoKatakan(bestiaries[0]) + SoDrowner(bestiaries[1]) + }) }) Convey("Populate with a single call (Comma separated string)", func() { var bestiaries []DetailedBestiary From 172f7caffc1354f58021d808bbf2c79b61cf730f Mon Sep 17 00:00:00 2001 From: Akalanka Perera Date: Sun, 15 Jun 2025 21:21:42 +0530 Subject: [PATCH 16/22] test: fixed conflicting model --- tests/core_read_populate_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/core_read_populate_test.go b/tests/core_read_populate_test.go index 46298a4..04d78c1 100644 --- a/tests/core_read_populate_test.go +++ b/tests/core_read_populate_test.go @@ -5,6 +5,7 @@ import ( elemental "github.com/elcengine/elemental/core" ts "github.com/elcengine/elemental/tests/fixtures/setup" + "github.com/google/uuid" . "github.com/smartystreets/goconvey/convey" "go.mongodb.org/mongo-driver/bson/primitive" ) @@ -149,7 +150,7 @@ func TestCoreReadPopulate(t *testing.T) { So(bestiaries[0].Kingdom, ShouldEqual, kingdoms[0].ID) }) Convey("Populate a model with just collection references", func() { - BestiaryModel := elemental.NewModel[Bestiary]("Bestiary", elemental.NewSchema(map[string]elemental.Field{ + BestiaryModel := elemental.NewModel[Bestiary](uuid.NewString(), elemental.NewSchema(map[string]elemental.Field{ "Monster": { Type: elemental.ObjectID, Collection: "monsters", From 44df0df36eefc710a4a1c79298d72b2feaeda89a Mon Sep 17 00:00:00 2001 From: Akalanka Perera Date: Sun, 15 Jun 2025 21:48:17 +0530 Subject: [PATCH 17/22] test: fixed conflicting model --- tests/core_read_populate_test.go | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/tests/core_read_populate_test.go b/tests/core_read_populate_test.go index 04d78c1..0d3014f 100644 --- a/tests/core_read_populate_test.go +++ b/tests/core_read_populate_test.go @@ -32,7 +32,7 @@ func TestCoreReadPopulate(t *testing.T) { Name: "Nekker", Category: "Nekker", }, - }).Exec().([]Monster) + }).ExecTT() kingdoms := KingdomModel.InsertMany([]Kingdom{ { @@ -44,7 +44,7 @@ func TestCoreReadPopulate(t *testing.T) { { Name: "Skellige", }, - }).Exec().([]Kingdom) + }).ExecTT() BestiaryModel.InsertMany([]Bestiary{ { @@ -159,14 +159,14 @@ func TestCoreReadPopulate(t *testing.T) { Type: elemental.ObjectID, Collection: "kingdoms", }, - }, elemental.SchemaOptions{ - Collection: "bestiary", })) - var bestiaries []GenericBestiary[Monster, Kingdom] - BestiaryModel.Find().Populate("monster", "kingdom").ExecInto(&bestiaries) - So(bestiaries, ShouldHaveLength, 3) - SoKatakan(bestiaries[0]) - SoDrowner(bestiaries[1]) + BestiaryModel.Create(Bestiary{Monster: monsters[0], Kingdom: kingdoms[0]}).Exec() + var bestiaries []GenericBestiary[Monster, primitive.ObjectID] + BestiaryModel.Find().Populate("monster").ExecInto(&bestiaries) + So(bestiaries, ShouldHaveLength, 1) + So(bestiaries[0].Monster.Name, ShouldEqual, "Katakan") + So(bestiaries[0].Monster.Category, ShouldEqual, "Vampire") + So(bestiaries[0].Kingdom, ShouldEqual, kingdoms[0].ID) }) }) } From d4edbf25fff197396843011d72a5860e7c867f93 Mon Sep 17 00:00:00 2001 From: Akalanka Perera Date: Sun, 15 Jun 2025 21:55:34 +0530 Subject: [PATCH 18/22] test: fixed conflicting model --- tests/core_read_populate_test.go | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/core_read_populate_test.go b/tests/core_read_populate_test.go index 0d3014f..e042480 100644 --- a/tests/core_read_populate_test.go +++ b/tests/core_read_populate_test.go @@ -159,14 +159,14 @@ func TestCoreReadPopulate(t *testing.T) { Type: elemental.ObjectID, Collection: "kingdoms", }, + }, elemental.SchemaOptions{ + Collection: "bestiary", })) - BestiaryModel.Create(Bestiary{Monster: monsters[0], Kingdom: kingdoms[0]}).Exec() - var bestiaries []GenericBestiary[Monster, primitive.ObjectID] - BestiaryModel.Find().Populate("monster").ExecInto(&bestiaries) - So(bestiaries, ShouldHaveLength, 1) - So(bestiaries[0].Monster.Name, ShouldEqual, "Katakan") - So(bestiaries[0].Monster.Category, ShouldEqual, "Vampire") - So(bestiaries[0].Kingdom, ShouldEqual, kingdoms[0].ID) + var bestiaries []DetailedBestiary + BestiaryModel.Find().Populate("monster", "kingdom").ExecInto(&bestiaries) + So(bestiaries, ShouldHaveLength, 3) + SoKatakan(bestiaries[0]) + SoDrowner(bestiaries[1]) }) }) } From 3a6eb636d9d554d249c7f2d23d7fba13a8e1af31 Mon Sep 17 00:00:00 2001 From: Akalanka Perera Date: Mon, 16 Jun 2025 08:23:24 +0530 Subject: [PATCH 19/22] test: fixed failing test case --- Makefile | 2 +- tests/core_read_populate_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 5c67b55..41df9b2 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,7 @@ format: test: PARALLEL_CONVEY=false make test-lightspeed test-lightspeed: - go test $(GO_TEST_ARGS) --run=${run} -v --count=1 ./tests/... + go test $(GO_TEST_ARGS) --run='${run}' -v --count=1 ./tests/... test-coverage: @mkdir -p ./coverage make test GO_TEST_ARGS="--cover -coverpkg=./cmd/...,./core/...,./plugins/...,./utils/... --coverprofile=./coverage/coverage.out" diff --git a/tests/core_read_populate_test.go b/tests/core_read_populate_test.go index e042480..63a65a9 100644 --- a/tests/core_read_populate_test.go +++ b/tests/core_read_populate_test.go @@ -161,7 +161,7 @@ func TestCoreReadPopulate(t *testing.T) { }, }, elemental.SchemaOptions{ Collection: "bestiary", - })) + })).SetDatabase(t.Name()) var bestiaries []DetailedBestiary BestiaryModel.Find().Populate("monster", "kingdom").ExecInto(&bestiaries) So(bestiaries, ShouldHaveLength, 3) From c888824b12c6ebd6abdb0b2a6d74837a1dd695f2 Mon Sep 17 00:00:00 2001 From: Akalanka Perera Date: Mon, 16 Jun 2025 08:55:39 +0530 Subject: [PATCH 20/22] fix: tidy script --- Makefile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 41df9b2..235670d 100644 --- a/Makefile +++ b/Makefile @@ -40,6 +40,6 @@ install: @echo "\033[0;32mGo modules installed successfully.\033[0m" tidy: @echo "\033[0;32mRunning go mod tidy...\033[0m" - @GOPRIVATE="github.com/clubpay*" go mod tidy -v + go mod tidy -v @echo "\033[0;32mVerifying packages...\033[0m" - @GOPRIVATE="github.com/clubpay*" go mod verify \ No newline at end of file + go mod verify \ No newline at end of file From 3e04ca83b92e86e7ff24a1a864d742dd7d988252 Mon Sep 17 00:00:00 2001 From: Akalanka Perera Date: Mon, 16 Jun 2025 08:56:52 +0530 Subject: [PATCH 21/22] test: removed unwanted tags in test script --- Makefile | 1 - 1 file changed, 1 deletion(-) diff --git a/Makefile b/Makefile index 235670d..366e17e 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,3 @@ -GO_TEST_ARGS ?= -tags=unit GO_BENCH_ARGS ?= -benchtime=30s format: From 4a161d44f353457342faa2d2d16e3932b1eac119 Mon Sep 17 00:00:00 2001 From: Akalanka Perera Date: Mon, 16 Jun 2025 19:57:00 +0530 Subject: [PATCH 22/22] perf: solved duplicate marshal and unmarshal in populate calls --- core/model.go | 1 + core/model_populate.go | 14 +++++++------- core/model_query.go | 9 +++++++-- core/model_utils.go | 24 +++++++++++++----------- tests/core_read_populate_test.go | 9 ++++++++- tests/core_read_test.go | 5 +++++ 6 files changed, 41 insertions(+), 21 deletions(-) diff --git a/core/model.go b/core/model.go index d59b492..5c043a7 100644 --- a/core/model.go +++ b/core/model.go @@ -26,6 +26,7 @@ type Model[T any] struct { Cloned bool // Indicates if this model has been cloned at least once pipeline mongo.Pipeline executor func(m Model[T], ctx context.Context) any + result any // Pointer to the result of the last executed query. This is still not in full use. Only used for operations such as Populate for now. whereField string failWith *error orConditionActive bool diff --git a/core/model_populate.go b/core/model_populate.go index 6f9a1c3..6149f79 100644 --- a/core/model_populate.go +++ b/core/model_populate.go @@ -82,6 +82,13 @@ func (m Model[T]) populate(value any) Model[T] { // // It can accept a single string, a slice of strings, or a map with 'path' and optionally a 'select' or a 'pipeline' key. func (m Model[T]) Populate(values ...any) Model[T] { + m.setResult([]bson.M{}) + m.executor = func(m Model[T], ctx context.Context) any { + cursor := lo.Must(m.Collection().Aggregate(ctx, m.pipeline)) + lo.Must0(cursor.All(ctx, m.result)) + m.checkConditionsAndPanic(m.result) + return m.result + } if len(values) == 1 { if str, ok := values[0].(string); ok && (strings.Contains(str, ",") || strings.Contains(str, " ")) { parts := strings.FieldsFunc(str, func(r rune) bool { @@ -96,12 +103,5 @@ func (m Model[T]) Populate(values ...any) Model[T] { for _, value := range values { m = m.populate(value) } - m.executor = func(m Model[T], ctx context.Context) any { - var results []bson.M - cursor := lo.Must(m.Collection().Aggregate(ctx, m.pipeline)) - lo.Must0(cursor.All(ctx, &results)) - m.checkConditionsAndPanic(results) - return results - } return m } diff --git a/core/model_query.go b/core/model_query.go index d6c8b4a..29e9d30 100644 --- a/core/model_query.go +++ b/core/model_query.go @@ -138,6 +138,11 @@ func (m Model[T]) ExecSS(ctx ...context.Context) []string { // // The result variable must be a pointer to your desired type. func (m Model[T]) ExecInto(result any, ctx ...context.Context) { - rv, bytes := lo.Must2(bson.MarshalValue(m.Exec(ctx...))) - bson.UnmarshalValue(rv, bytes, result) + if m.result != nil { // Gradually will migrate everything to use this approach since the block below this is expensive to use. + m.result = result + m.Exec(ctx...) + } else { + rv, bytes := lo.Must2(bson.MarshalValue(m.Exec(ctx...))) + bson.UnmarshalValue(rv, bytes, result) + } } diff --git a/core/model_utils.go b/core/model_utils.go index bd0b853..518b708 100644 --- a/core/model_utils.go +++ b/core/model_utils.go @@ -89,20 +89,17 @@ func (m Model[T]) addToPipeline(stage, key string, value any) Model[T] { } func (m Model[T]) checkConditionsAndPanic(result any) { - sliceT, okT := result.([]T) - if slice, ok := result.([]any); ok || okT { - if m.failWith != nil && (len(slice) == 0 || len(sliceT) == 0) { + if m.failWith != nil { + val := reflect.ValueOf(result) + if val.Kind() == reflect.Ptr { + val = val.Elem() + } + if (val.Kind() == reflect.Slice || val.Kind() == reflect.Array) && val.Len() == 0 { panic(*m.failWith) } - return } - if singleResult, ok := result.(*mongo.SingleResult); ok { - if err := singleResult.Err(); err != nil { - if m.failWith != nil { - panic(*m.failWith) - } - panic(err) - } + if r, ok := result.(*mongo.SingleResult); ok { + m.checkConditionsAndPanicForErr(r.Err()) } } @@ -181,3 +178,8 @@ func (m *Model[T]) preprocess() { var sample [0]T // Slice of zero length to get the type of T m.docReflectType = reflect.TypeOf(sample).Elem() } + +// Sets the variable that will hold the result of the last executed query. +func (m *Model[T]) setResult(result any) { + m.result = &result +} diff --git a/tests/core_read_populate_test.go b/tests/core_read_populate_test.go index 63a65a9..dd082c5 100644 --- a/tests/core_read_populate_test.go +++ b/tests/core_read_populate_test.go @@ -1,6 +1,7 @@ package tests import ( + "errors" "testing" elemental "github.com/elcengine/elemental/core" @@ -93,6 +94,12 @@ func TestCoreReadPopulate(t *testing.T) { SoKatakan(bestiaries[0]) SoDrowner(bestiaries[1]) }) + Convey("With OrFail", func() { + bestiaries := make([]DetailedBestiary, 0) + So(func() { + BestiaryModel.Find(primitive.M{"monster": uuid.New()}).Populate("monster").Populate("kingdom").OrFail().ExecInto(&bestiaries) + }, ShouldPanicWith, errors.New("no results found matching the given query")) + }) }) Convey("Populate with a single call", func() { Convey("With bson field name", func() { @@ -110,7 +117,7 @@ func TestCoreReadPopulate(t *testing.T) { SoDrowner(bestiaries[1]) }) }) - Convey("Populate with a single call (Comma separated string)", func() { + Convey("Populate with a single call (Space separated string)", func() { var bestiaries []DetailedBestiary BestiaryModel.Find().Populate("monster kingdom").ExecInto(&bestiaries) So(bestiaries, ShouldHaveLength, 3) diff --git a/tests/core_read_test.go b/tests/core_read_test.go index a2d5942..9f5ab08 100644 --- a/tests/core_read_test.go +++ b/tests/core_read_test.go @@ -24,6 +24,11 @@ func TestCoreRead(t *testing.T) { users := UserModel.Find().ExecTT() So(users, ShouldHaveLength, len(mocks.Users)) }) + Convey("Find all users with ExecInto", func() { + var users []User + UserModel.Find().ExecInto(&users) + So(users, ShouldHaveLength, len(mocks.Users)) + }) Convey("Find all with a limit of 2", func() { users := UserModel.Find().Limit(2).ExecTT() So(users, ShouldHaveLength, 2)