diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 356fc67..6dcf5f3 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -57,6 +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' || '' }} - name: Upload coverage report if: github.event_name == 'pull_request' && github.base_ref == 'main' && matrix.go-version == '1.24' && matrix.mongo-version == '8.0' 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/Makefile b/Makefile index f2bc374..366e17e 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,3 @@ -GO_TEST_ARGS ?= -tags=unit GO_BENCH_ARGS ?= -benchtime=30s format: @@ -6,7 +5,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 +29,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" + go mod tidy -v + @echo "\033[0;32mVerifying packages...\033[0m" + go mod verify \ No newline at end of file diff --git a/core/model.go b/core/model.go index 1b0df4f..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 @@ -40,6 +41,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 +66,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 +87,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 +106,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 +124,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..a846f28 100644 --- a/core/model_audit.go +++ b/core/model_audit.go @@ -2,11 +2,14 @@ package elemental import ( "context" - "github.com/elcengine/elemental/utils" - "github.com/samber/lo" "reflect" "time" + "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" ) @@ -20,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). @@ -62,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.ToBSONDoc(doc), - User: lo.CoalesceOrEmpty(utils.Cast[string](context.Value(CtxUser)), userFallback), + Document: utils.CastBSON[bson.M](doc), + 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()}}}) @@ -77,8 +80,8 @@ func (m Model[T]) EnableAuditing(ctx ...context.Context) { execWithModelOpts(AuditModel.Create(Audit{ Entity: m.Name, Type: AuditTypeUpdate, - Document: *utils.ToBSONDoc(doc), - User: lo.CoalesceOrEmpty(utils.Cast[string](context.Value(CtxUser)), userFallback), + Document: utils.CastBSON[bson.M](doc), + 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()}}}) @@ -87,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_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..6149f79 100644 --- a/core/model_populate.go +++ b/core/model_populate.go @@ -1,6 +1,7 @@ package elemental import ( + "context" "reflect" "strings" @@ -14,19 +15,23 @@ 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) } 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) - if cleanTag(field.Tag.Get("bson")) == path { + for i := range m.docReflectType.NumField() { + field := m.docReflectType.Field(i) + tag := cleanTag(field.Tag.Get("bson")) + if path == field.Name { + path = tag + } + if path == tag { fieldname = field.Name break } @@ -35,20 +40,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}, } @@ -72,7 +79,16 @@ 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] { + 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 { diff --git a/core/model_query.go b/core/model_query.go index ec603c7..29e9d30 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,17 @@ 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. +// +// The result variable must be a pointer to your desired type. +func (m Model[T]) ExecInto(result any, ctx ...context.Context) { + 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_query_delete.go b/core/model_query_delete.go index 5bfdd8e..965063b 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) + m.checkConditionsAndPanic(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..aa60b53 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.checkConditionsAndPanic(result) + lo.Must0(result.Decode(&resultDoc)) + m.middleware.post.findOneAndUpdate.run(&resultDoc) + return resultDoc } return m } @@ -40,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 } @@ -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.checkConditionsAndPanic(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) + m.checkConditionsAndPanic(res) lo.Must0(res.Decode(&resultDoc)) + m.middleware.post.findOneAndReplace.run(&resultDoc) return resultDoc } return m @@ -166,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 91bd323..518b708 100644 --- a/core/model_utils.go +++ b/core/model_utils.go @@ -88,18 +88,18 @@ 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) { + 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) } - panic(result.Err()) + } + if r, ok := result.(*mongo.SingleResult); ok { + m.checkConditionsAndPanicForErr(r.Err()) } } @@ -121,15 +121,16 @@ 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) + switch doc.(type) { + case bson.M, map[string]any: + return utils.Cast[bson.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 +172,14 @@ 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() +} + +// 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/core/schema_reflect_types.go b/core/schema_reflect_types.go index bd1b63e..1c7c749 100644 --- a/core/schema_reflect_types.go +++ b/core/schema_reflect_types.go @@ -1,10 +1,16 @@ package elemental import ( - "go.mongodb.org/mongo-driver/bson/primitive" "reflect" + "time" + + "go.mongodb.org/mongo-driver/bson/primitive" ) +type FieldType interface { + String() string +} + // Inbuilt types var Slice = reflect.Slice @@ -17,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 @@ -27,4 +31,26 @@ var String = reflect.String // Custom types -var ObjectID = reflect.TypeOf(primitive.NilObjectID).Kind() +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_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..99daa4a 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,42 +66,43 @@ 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 } } + 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() && 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) - continue + 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 + } } - // Reference/Collection - if definition.Type == reflect.Struct && (definition.Ref != "" || definition.Collection != "") && 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 || definition.Type == ObjectID { + // Extract subdocument ID if it exists for ObjectID references + 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: id, + }, + ) + } + continue } - continue } if definition.Min != 0 { @@ -129,7 +126,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/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: 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_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_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()) 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_middleware_test.go b/tests/core_middleware_test.go index a2b7cfc..d1cae63 100644 --- a/tests/core_middleware_test.go +++ b/tests/core_middleware_test.go @@ -2,11 +2,13 @@ package tests import ( "testing" + "time" 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" "go.mongodb.org/mongo-driver/bson/primitive" "go.mongodb.org/mongo-driver/mongo" ) @@ -25,22 +27,26 @@ func TestCoreMiddleware(t *testing.T) { }, })).SetDatabase(t.Name()) - CastleModel.PreSave(func(castle Castle) bool { + 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 Castle) bool { + CastleModel.PostSave(func(castle *bson.M) bool { invokedHooks["postSave"] = true + if name, ok := (*castle)["name"].(string); ok { + (*castle)["name"] = "Created: " + name + } 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 +61,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 +71,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,59 +81,78 @@ func TestCoreMiddleware(t *testing.T) { return true }) - CastleModel.PostFind(func(castle []Castle) bool { + CastleModel.PostFind(func(castle *[]Castle) bool { invokedHooks["postFind"] = true + for i := range *castle { + (*castle)[i].Name = "Modified: " + (*castle)[i].Name + } return true }) - CastleModel.PreFindOneAndUpdate(func(filter primitive.M) bool { + CastleModel.PreFindOneAndUpdate(func(filter *primitive.M, doc any) bool { invokedHooks["preFindOneAndUpdate"] = true return true }) CastleModel.PostFindOneAndUpdate(func(castle *Castle) bool { invokedHooks["postFindOneAndUpdate"] = true + if castle != nil { + castle.Name = "Updated: " + castle.Name + } return true }) - CastleModel.PreFindOneAndDelete(func(filters primitive.M) bool { + CastleModel.PreFindOneAndDelete(func(filters *primitive.M) bool { invokedHooks["preFindOneAndDelete"] = true return true }) CastleModel.PostFindOneAndDelete(func(castle *Castle) bool { invokedHooks["postFindOneAndDelete"] = true + if castle != nil { + castle.Name = "Deleted: " + castle.Name + } + 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 + 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", "Maverick"}}}).Exec() + CastleModel.DeleteMany(primitive.M{"name": primitive.M{"$in": []string{"Aretuza", "Rozrog"}}}).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) @@ -144,6 +169,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() { @@ -151,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) @@ -163,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_read_populate_test.go b/tests/core_read_populate_test.go index e14acaa..dd082c5 100644 --- a/tests/core_read_populate_test.go +++ b/tests/core_read_populate_test.go @@ -1,9 +1,12 @@ package tests import ( + "errors" "testing" + 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" ) @@ -30,7 +33,7 @@ func TestCoreReadPopulate(t *testing.T) { Name: "Nekker", Category: "Nekker", }, - }).Exec().([]Monster) + }).ExecTT() kingdoms := KingdomModel.InsertMany([]Kingdom{ { @@ -42,7 +45,7 @@ func TestCoreReadPopulate(t *testing.T) { { Name: "Skellige", }, - }).Exec().([]Kingdom) + }).ExecTT() BestiaryModel.InsertMany([]Bestiary{ { @@ -59,37 +62,118 @@ 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() { - bestiary := BestiaryModel.Find().Populate("monster").Populate("kingdom").ExecTT() - 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 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("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() { - bestiary := BestiaryModel.Find().Populate("monster", "kingdom").ExecTT() - 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("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() { - bestiary := BestiaryModel.Find().Populate("monster kingdom").ExecTT() - 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 (Space separated string)", func() { + var bestiaries []DetailedBestiary + BestiaryModel.Find().Populate("monster kingdom").ExecInto(&bestiaries) + So(bestiaries, ShouldHaveLength, 3) + SoKatakan(bestiaries[0]) + SoDrowner(bestiaries[1]) }) 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").ExecTT() - 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") + }) + 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) + 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) + }) + Convey("Populate a model with just collection references", func() { + BestiaryModel := elemental.NewModel[Bestiary](uuid.NewString(), elemental.NewSchema(map[string]elemental.Field{ + "Monster": { + Type: elemental.ObjectID, + Collection: "monsters", + }, + "Kingdom": { + Type: elemental.ObjectID, + Collection: "kingdoms", + }, + }, elemental.SchemaOptions{ + Collection: "bestiary", + })).SetDatabase(t.Name()) + var bestiaries []DetailedBestiary + BestiaryModel.Find().Populate("monster", "kingdom").ExecInto(&bestiaries) + So(bestiaries, ShouldHaveLength, 3) + SoKatakan(bestiaries[0]) + SoDrowner(bestiaries[1]) }) }) } 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) diff --git a/tests/core_schema_test.go b/tests/core_schema_test.go index fa67b44..cd0edc6 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,59 @@ 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) + }) + }) + 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/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..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").ExecTT() + 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) diff --git a/tests/tests.go b/tests/tests.go index c22a703..8e257e0 100644 --- a/tests/tests.go +++ b/tests/tests.go @@ -14,9 +14,11 @@ type Kingdom = fixtures.Kingdom type Monster = fixtures.Monster +type GenericBestiary[T any, Y any] = fixtures.GenericBestiary[T, Y] + type Bestiary = fixtures.Bestiary -type BestiaryWithID = fixtures.BestiaryWithID +type DetailedBestiary = fixtures.DetailedBestiary type MonsterWeakness = fixtures.MonsterWeakness @@ -30,8 +32,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. 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 -} 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() }