From f0c0c55e89f339d45c31de5fb469ca0c99b1c36c Mon Sep 17 00:00:00 2001 From: George Suntres Date: Thu, 23 Apr 2026 13:19:20 -0400 Subject: [PATCH] Add Audit cababilities. First try on deleteOne --- audit.go | 22 ++++ delete.go | 54 +++++++++ delete_one.go | 22 ---- delete_test.go | 234 ++++++++++++++++++++++++++++++++++++ filter.go | 8 +- go.mod | 3 +- go.sum | 8 +- main.go | 44 ++++++- main_test.go | 2 + options/deleteoneoptions.go | 35 ++++++ options/lister.go | 5 + 11 files changed, 402 insertions(+), 35 deletions(-) create mode 100644 audit.go create mode 100644 delete.go delete mode 100644 delete_one.go create mode 100644 delete_test.go create mode 100644 options/deleteoneoptions.go create mode 100644 options/lister.go diff --git a/audit.go b/audit.go new file mode 100644 index 0000000..c5c5aec --- /dev/null +++ b/audit.go @@ -0,0 +1,22 @@ +package mongo + +type Op string + +const ( + OpUpdate Op = "update" + OpDelete Op = "delete" + OpInsert Op = "insert" + OpArchive Op = "archive" + OpRestore Op = "restore" +) + +type AuditResult struct { + Op Op `bson:"op" json:"op"` + Entity string `bson:"entity" json:"entity"` + Data any `bson:"data" json:"data"` + Before any `bson:"before" json:"before"` + After any `bson:"after" json:"after"` + Context any `bson:"context" json:"context"` +} + +type OnAudit func(audit *AuditResult) error \ No newline at end of file diff --git a/delete.go b/delete.go new file mode 100644 index 0000000..9d527e9 --- /dev/null +++ b/delete.go @@ -0,0 +1,54 @@ +package mongo + +import ( + "context" + "slices" + + "go.mongodb.org/mongo-driver/v2/bson" + + "git.gsuntres.com/general/mongo/options" + "git.gsuntres.com/general/commons" +) + +// DeleteOne will delete the first document that matches the filter. +func (c *MongoClient) DeleteOne(ctx context.Context, database, name string, filter bson.M, opts ...options.Lister[options.DeleteOneOptions]) error { + var err error + + // 1. Prepare query. + collection := c.GetCollection(database, name) + + // 2. Check discriminator and mongofy + if err := c.DiscriminatorCheckAndApplyToFilter(ctx, name, filter); err != nil { + return err + } + + f := Mongofy(&Query{ Filter: filter }) + + var found bson.M + if c.WithAudit { + err = collection.FindOne(ctx, f).Decode(&found) + if err != nil { + return err + } + } + + // 3. Delete + _, err = collection.DeleteOne(ctx, f) + if err != nil { + return err + } + + if c.WithAudit && !slices.Contains(c.IgnoreAudit, name) { + contx := commons.ContextSerialize(ctx, c.ContextFields) + audit := &AuditResult { + Entity: name, + Op: OpDelete, + Before: found, + Context: contx, + } + + (*c.OnAudit)(audit) + } + + return nil +} diff --git a/delete_one.go b/delete_one.go deleted file mode 100644 index d8207ba..0000000 --- a/delete_one.go +++ /dev/null @@ -1,22 +0,0 @@ -package mongo - -import ( - "context" - - "go.mongodb.org/mongo-driver/v2/bson" -) - -// DeleteOne will delete the first document that matches the filter. -func (c *MongoClient) DeleteOne(ctx context.Context, database, name string, filter bson.M) error { - - // 1. Prepare query. - collection := c.GetCollection(database, name) - - // 2. Query - _, err := collection.DeleteOne(ctx, filter) - if err != nil { - return err - } - - return nil -} diff --git a/delete_test.go b/delete_test.go new file mode 100644 index 0000000..31004ec --- /dev/null +++ b/delete_test.go @@ -0,0 +1,234 @@ +package mongo + +import ( + "os" + "fmt" + "context" + "testing" + "encoding/json" + + "go.mongodb.org/mongo-driver/v2/bson" + + "git.gsuntres.com/general/commons" + "git.gsuntres.com/general/events" +) + +func TestDeleteOne(t *testing.T) { + client := GetMongoClient() + + data := map[string]any { + "_id": "su_123458", + "name": "MyNameTODelete", + "age": int32(25), + } + + o, err := client.InsertOne(context.Background(), "mydb", "mycollection", data) + if err != nil { t.Fatalf("Failed to insertOne %#v", err) } + + // now delete it + err = client.DeleteOne(context.Background(), "mydb", "mycollection", bson.M{"_id": o["_id"].(string)}) + if err != nil { t.Fatalf("Failed to deleteOne %#v", err) } + + // raw query + var results bson.M + filter := bson.M{ "name": "MyName" } + + c := client.Client.Database("mydb").Collection("mycollection") + c.FindOne(context.Background(), filter).Decode(&results) + + if results != nil { + t.Fatalf("Should have no results not %v", results) + } +} + +func TestDelete_Discriminator(t *testing.T) { + // 1. Register schemas + schemaStore, err := os.ReadFile("./.test/store.json") + if err != nil { t.Fatal(err) } + + schemaOffer, err := os.ReadFile("./.test/offer.json") + if err != nil { t.Fatal(err) } + + var store bson.M + if err := json.Unmarshal(schemaStore, &store); err != nil { + t.Fatalf("Length: %d, First bytes: %x\n", len(schemaStore), schemaStore[:4]) + } + + var offer bson.M + if err := json.Unmarshal(schemaOffer, &offer); err != nil { + t.Fatalf("Length: %d, First bytes: %x\n", len(schemaOffer), schemaOffer[:4]) + } + + client := GetMongoClient() + client.AddDefinition(store) + client.AddDefinition(offer) + + // Store str_1234 has BOOMRAW 1. + ctx1 := context.Background() + ctx1 = context.WithValue(ctx1, "store", "str_1234") + offer1 := map[string]any { "name": "BOOMRAW 1" } + _, err = client.InsertOne(ctx1, "mydb", "offer", offer1) + if err != nil { t.Fatalf("Failed to insertOne %#v", err) } + + // Store str_4321 has BOOMRAW 2 + ctx2 := context.Background() + ctx2 = context.WithValue(ctx2, "store", "str_4321") + offer2 := map[string]any { "name": "BOOMRAW 2" } + _, err = client.InsertOne(ctx2, "mydb", "offer", offer2) + if err != nil { t.Fatalf("Failed to insertOne %#v", err) } + + // Attempt to delete BOOMRAW2 in str_1234 + err = client.DeleteOne(ctx1, "mydb", "offer", bson.M{"name": "BOOMRAW2"}) + + // Within store str_4321 searching for BOOMRAW should return one result + filter := bson.M{"name": bson.M{"$regex": "BOOMRAW*"}} + findResult, err := client.Find(ctx2, "mydb", "offer", filter, &FindOptions{ Offset: int64(0) }) + if err != nil { t.Fatalf("Failed to find %#v", err) } + + dataAny, hasData := findResult["data"] + if !hasData { t.Fatal("no data") } + + data := dataAny.(bson.A) + + if len(data) != 1 { + t.Fatalf("Expected to return 1 document but got %d", len(data)) + } + + arr, _ := commons.BsonAToSlice(data) + + o := arr[0] + + name := o["name"] + + if name != "BOOMRAW 2" { + t.Fatalf("Expected BOOMRAW 2 not %s", name) + } +} + +func TestDeleteOne_WithAudit(t *testing.T) { + client := GetMongoClient() + + data := map[string]any { + "_id": "su_123458", + "name": "MyNameTODelete", + "age": int32(25), + } + + o, err := client.InsertOne(context.Background(), "mydb", "mycollection", data) + if err != nil { t.Fatalf("Failed to insertOne %#v", err) } + + // now delete it + + var ( + onAudit_calls int + onAudit_before any + onAudit_context any + ) + + events.Subscribe(func(audit *AuditResult) error { + onAudit_calls++ + onAudit_before = audit.Before + onAudit_context = audit.Context + + return nil + }) + + ctx := context.Background() + ctx = context.WithValue(ctx, "account", "xxxxxx") + ctx = context.WithValue(ctx, "store", "str_4321") + err = client.DeleteOne(ctx, "mydb", "mycollection", bson.M{"_id": o["_id"].(string)}) + if err != nil { t.Fatalf("Failed to deleteOne %#v", err) } + + // raw query + var results bson.M + filter := bson.M{ "name": "MyName" } + + c := client.Client.Database("mydb").Collection("mycollection") + c.FindOne(context.Background(), filter).Decode(&results) + + if results != nil { + t.Fatalf("Should have no results not %v", results) + } + + if onAudit_calls != 1 { + t.Fatalf("ondelete should have been called once, not %d", onAudit_calls) + } + + if onAudit_before != nil { + tp := fmt.Sprintf("%T", onAudit_before) + if tp != "bson.M" { + t.Fatalf("before has the wrong type %s", tp) + } + + before := onAudit_before.(bson.M) + + AssertSubset(t, before, o, "Should have been equal") + } + + if onAudit_context != nil { + ctx := onAudit_context.(map[string]any) + AssertSubset(t, ctx, map[string]any{"account": "xxxxxx", "store": "str_4321"}, "Should have been equal") + } +} + +func TestDeleteOne_WithAuditButIgnored(t *testing.T) { + client := GetMongoClient() + client.IgnoredAudit = []string{"mycollection"} + + data := map[string]any { + "_id": "su_123458", + "name": "MyNameTODelete", + "age": int32(25), + } + + o, err := client.InsertOne(context.Background(), "mydb", "mycollection", data) + if err != nil { t.Fatalf("Failed to insertOne %#v", err) } + + // now delete it + + var ( + onAudit_calls int + onAudit_before any + onAudit_context any + ) + + events.Subscribe(func(audit *AuditResult) error { + onAudit_calls++ + onAudit_before = audit.Before + onAudit_context = audit.Context + + return nil + }) + + ctx := context.Background() + ctx = context.WithValue(ctx, "account", "xxxxxx") + ctx = context.WithValue(ctx, "store", "str_4321") + err = client.DeleteOne(ctx, "mydb", "mycollection", bson.M{"_id": o["_id"].(string)}) + if err != nil { t.Fatalf("Failed to deleteOne %#v", err) } + + // raw query + var results bson.M + filter := bson.M{ "name": "MyName" } + + c := client.Client.Database("mydb").Collection("mycollection") + c.FindOne(context.Background(), filter).Decode(&results) + + if results != nil { + t.Fatalf("Should have no results not %v", results) + } + + if onAudit_calls != 1 { + t.Fatalf("ondelete should have been called once, not %d", onAudit_calls) + } + + if onAudit_before != nil { + tp := fmt.Sprintf("%T", onAudit_before) + if tp != "bson.M" { + t.Fatalf("before has the wrong type %s", tp) + } + + before := onAudit_before.(bson.M) + + AssertSubset(t, before, o, "Should have been equal") + } +} \ No newline at end of file diff --git a/filter.go b/filter.go index 0c365ce..d60f0d9 100644 --- a/filter.go +++ b/filter.go @@ -21,8 +21,6 @@ func makeFilter(name string, value any) *Filter { var v string - log.Printf("TYPE %T", value) - switch value.(type) { case string: op = "eq" @@ -30,7 +28,6 @@ func makeFilter(name string, value any) *Filter { case bson.M: vMap := value.(bson.M) - log.Printf("GEO its map[string]any %v", vMap) for kk, vv := range vMap { op = kk v = vv.(string) @@ -41,7 +38,6 @@ func makeFilter(name string, value any) *Filter { case map[string]any: vMap := value.(map[string]any) - log.Printf("GEO its bson.M %v", vMap) for kk, vv := range vMap { op = kk v = vv.(string) @@ -59,8 +55,6 @@ func makeFilter(name string, value any) *Filter { Value: v, } - log.Printf("FILTER -> %#v", o) - return o } @@ -69,7 +63,7 @@ func Mongofy(q *Query) map[string]any { conditions := make([]map[string]interface{}, 0) logic := "and" -log.Printf("GEO q.Filter %#v", q.Filter) + for k, v := range q.Filter { if k == "_logic" { logic = v.(string) diff --git a/go.mod b/go.mod index 4c82ac0..7cc837f 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,8 @@ module git.gsuntres.com/general/mongo go 1.25.0 require ( - git.gsuntres.com/general/commons v0.0.0-20260422140002-55de9c34e8c1 + git.gsuntres.com/general/commons v0.0.0-20260423171748-0ce3f3b5eb8c + git.gsuntres.com/general/events v0.0.0-20260423140000-1435849fb2c0 git.gsuntres.com/general/sys v0.0.1 github.com/go-viper/mapstructure/v2 v2.5.0 github.com/google/go-cmp v0.7.0 diff --git a/go.sum b/go.sum index 4093e2a..76114ab 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,11 @@ dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= -git.gsuntres.com/general/commons v0.0.0-20260422140002-55de9c34e8c1 h1:g96tzABUPqaAaftDBLbDkf/za21kBAUiex8LU//NsFE= -git.gsuntres.com/general/commons v0.0.0-20260422140002-55de9c34e8c1/go.mod h1:s774W5vN/53DLYKeY4iwFnwPOIHfSg8/V6Ft7sEdl9M= +git.gsuntres.com/general/commons v0.0.0-20260422214453-1e9e43668f5e h1:UFlnWT0u8ywStd7VRPNHmooZw3bSdXqhe/MwOpgWmvI= +git.gsuntres.com/general/commons v0.0.0-20260422214453-1e9e43668f5e/go.mod h1:s774W5vN/53DLYKeY4iwFnwPOIHfSg8/V6Ft7sEdl9M= +git.gsuntres.com/general/commons v0.0.0-20260423171748-0ce3f3b5eb8c h1:YZJWYDqqUC0x687yYrcCYzMIvSO40H25IsInBCl+g6A= +git.gsuntres.com/general/commons v0.0.0-20260423171748-0ce3f3b5eb8c/go.mod h1:S93xcBczrgN+gZU0JWkPRnTcAMvQuTp1ChyKkOd/I50= +git.gsuntres.com/general/events v0.0.0-20260423140000-1435849fb2c0 h1:j4qqM1K6BMIG9ydzbuAljcAeaR/XbImEfs7GF6jtHlY= +git.gsuntres.com/general/events v0.0.0-20260423140000-1435849fb2c0/go.mod h1:IQEt0/YT7vYHLTmRhjpqdHgHhagIHZNaay83fndui7s= git.gsuntres.com/general/sys v0.0.1 h1:JpGG6HCkJrTaCICR09kURhMTIc+/s8yb0lHQjo/TDVI= git.gsuntres.com/general/sys v0.0.1/go.mod h1:OVs7w4/tJO1GT7cLIeEsb90LuZqH2xYIVQODI5P1GJs= github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= diff --git a/main.go b/main.go index e8a31a3..972c5bb 100644 --- a/main.go +++ b/main.go @@ -17,6 +17,7 @@ import ( "git.gsuntres.com/general/commons" "git.gsuntres.com/general/sys" + "git.gsuntres.com/general/events" ) func main() { @@ -87,6 +88,14 @@ type MongoClient struct { Registry map[string]*CollectionDefinition // Relaxed if set to true will not enforce schema Relaxed bool + // Context fields to serialize when needed + ContextFields []string + // WithAudit enable auditing functionality. + WithAudit bool + // OnAudit callback called when audit is enabled. + OnAudit *OnAudit + // IgnoreAudit a list of entities to ignore + IgnoreAudit []string } func (c *MongoClient) SetRelaxed() { @@ -250,6 +259,7 @@ var client *MongoClient = &MongoClient{ Limit: 10, Registry: make(map[string]*CollectionDefinition, 0), Relaxed: false, + IgnoreAudit: []string{"event"}, } func GetMongoClient() *MongoClient { @@ -280,6 +290,9 @@ type MongoStartProps struct { MongoPass string MongoDebugQuery bool MongoDBPrefix string + ContextFields []string + WithAudit bool + OnAudit *OnAudit AllowTruncatingDoubles bool } @@ -314,18 +327,30 @@ func Start(props *MongoStartProps) error { }). SetRegistry(GetCustomRegistry()) + colors := []string{ + commons.EscapeGreen, + commons.EscapeRed, + commons.EscapeYellow, + commons.EscapeBlue, + commons.EscapeMagenta, + commons.EscapeCyan, + } + client.DebugQuery = props.MongoDebugQuery if client.DebugQuery { // Debug queries monitor := &event.CommandMonitor{ Started: func(_ context.Context, e *event.CommandStartedEvent) { - log.Printf("%d@Start %s#%s %s", e.RequestID, e.DatabaseName, e.CommandName, e.Command) + ecode := colors[e.RequestID % 6] + log.Printf("%s%d@Start%s %s#%s %s", ecode, e.RequestID, commons.EscapeReset, e.DatabaseName, e.CommandName, e.Command) }, Succeeded: func(_ context.Context, e *event.CommandSucceededEvent) { - log.Printf("%d@OK in %s", e.RequestID, e.Reply) + ecode := colors[e.RequestID % 6] + log.Printf("%s%d@OK%s in %s", ecode, e.RequestID, commons.EscapeReset, e.Reply) }, Failed: func(_ context.Context, e *event.CommandFailedEvent) { - log.Printf("%d@Fail in %s", e.RequestID, e.Failure) + ecode := colors[e.RequestID % 6] + log.Printf("%s%d@Fail%s in %s", ecode, e.RequestID, commons.EscapeReset, e.Failure) }, } @@ -335,6 +360,19 @@ func Start(props *MongoStartProps) error { // set DBPrefix client.DBPrefix = props.MongoDBPrefix + client.ContextFields = props.ContextFields + + client.WithAudit = props.WithAudit + + client.OnAudit = props.OnAudit + + if client.WithAudit { + var onAudit OnAudit = func(audit *AuditResult) error { + return events.Publish(audit) + } + client.OnAudit = &onAudit + } + if err := cOptions.Validate(); err != nil { log.Fatalf("Failed to validate mongo options: %+v", err.Error()) diff --git a/main_test.go b/main_test.go index 793c41b..b26621c 100644 --- a/main_test.go +++ b/main_test.go @@ -53,7 +53,9 @@ func TestMain(m *testing.M) { MongoUri: endpoint, MongoUser: user, MongoPass: pass, + ContextFields: []string{"account", "store"}, MongoDebugQuery: mongoDebug, + WithAudit: true, }) // 3. Run tests diff --git a/options/deleteoneoptions.go b/options/deleteoneoptions.go new file mode 100644 index 0000000..bd4d1fe --- /dev/null +++ b/options/deleteoneoptions.go @@ -0,0 +1,35 @@ +package options + +type DeleteAudit struct { + Id string + Before any + Context any +} + +type OnDeleteAudit func(audit *DeleteAudit) + +type DeleteOneOptions struct { + OnDeleteAudit *OnDeleteAudit +} + +type DeleteOneOptionsBuilder struct { + Opts []func(*DeleteOneOptions) error +} + +func DeleteOne() *DeleteOneOptionsBuilder{ + return &DeleteOneOptionsBuilder{} +} + +func (dao *DeleteOneOptionsBuilder) List() []func(*DeleteOneOptions) error { + return dao.Opts +} + +func (dao *DeleteOneOptionsBuilder) WithOnDeleteAudit(oa *OnDeleteAudit) *DeleteOneOptionsBuilder { + dao.Opts = append(dao.Opts, func(opts *DeleteOneOptions) error { + opts.OnDeleteAudit = oa + + return nil + }) + + return dao +} \ No newline at end of file diff --git a/options/lister.go b/options/lister.go new file mode 100644 index 0000000..f50fb05 --- /dev/null +++ b/options/lister.go @@ -0,0 +1,5 @@ +package options + +type Lister[T any] interface { + List() []func(*T) error +} \ No newline at end of file