Add Audit cababilities. First try on deleteOne

This commit is contained in:
George Suntres
2026-04-23 13:19:20 -04:00
parent 49a1d24660
commit f0c0c55e89
11 changed files with 402 additions and 35 deletions

22
audit.go Normal file
View File

@@ -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

54
delete.go Normal file
View File

@@ -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
}

View File

@@ -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
}

234
delete_test.go Normal file
View File

@@ -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")
}
}

View File

@@ -21,8 +21,6 @@ func makeFilter(name string, value any) *Filter {
var v string var v string
log.Printf("TYPE %T", value)
switch value.(type) { switch value.(type) {
case string: case string:
op = "eq" op = "eq"
@@ -30,7 +28,6 @@ func makeFilter(name string, value any) *Filter {
case bson.M: case bson.M:
vMap := value.(bson.M) vMap := value.(bson.M)
log.Printf("GEO its map[string]any %v", vMap)
for kk, vv := range vMap { for kk, vv := range vMap {
op = kk op = kk
v = vv.(string) v = vv.(string)
@@ -41,7 +38,6 @@ func makeFilter(name string, value any) *Filter {
case map[string]any: case map[string]any:
vMap := value.(map[string]any) vMap := value.(map[string]any)
log.Printf("GEO its bson.M %v", vMap)
for kk, vv := range vMap { for kk, vv := range vMap {
op = kk op = kk
v = vv.(string) v = vv.(string)
@@ -59,8 +55,6 @@ func makeFilter(name string, value any) *Filter {
Value: v, Value: v,
} }
log.Printf("FILTER -> %#v", o)
return o return o
} }
@@ -69,7 +63,7 @@ func Mongofy(q *Query) map[string]any {
conditions := make([]map[string]interface{}, 0) conditions := make([]map[string]interface{}, 0)
logic := "and" logic := "and"
log.Printf("GEO q.Filter %#v", q.Filter)
for k, v := range q.Filter { for k, v := range q.Filter {
if k == "_logic" { if k == "_logic" {
logic = v.(string) logic = v.(string)

3
go.mod
View File

@@ -3,7 +3,8 @@ module git.gsuntres.com/general/mongo
go 1.25.0 go 1.25.0
require ( 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 git.gsuntres.com/general/sys v0.0.1
github.com/go-viper/mapstructure/v2 v2.5.0 github.com/go-viper/mapstructure/v2 v2.5.0
github.com/google/go-cmp v0.7.0 github.com/google/go-cmp v0.7.0

8
go.sum
View File

@@ -1,7 +1,11 @@
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= 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-20260422214453-1e9e43668f5e h1:UFlnWT0u8ywStd7VRPNHmooZw3bSdXqhe/MwOpgWmvI=
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/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 h1:JpGG6HCkJrTaCICR09kURhMTIc+/s8yb0lHQjo/TDVI=
git.gsuntres.com/general/sys v0.0.1/go.mod h1:OVs7w4/tJO1GT7cLIeEsb90LuZqH2xYIVQODI5P1GJs= 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= github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=

44
main.go
View File

@@ -17,6 +17,7 @@ import (
"git.gsuntres.com/general/commons" "git.gsuntres.com/general/commons"
"git.gsuntres.com/general/sys" "git.gsuntres.com/general/sys"
"git.gsuntres.com/general/events"
) )
func main() { func main() {
@@ -87,6 +88,14 @@ type MongoClient struct {
Registry map[string]*CollectionDefinition Registry map[string]*CollectionDefinition
// Relaxed if set to true will not enforce schema // Relaxed if set to true will not enforce schema
Relaxed bool 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() { func (c *MongoClient) SetRelaxed() {
@@ -250,6 +259,7 @@ var client *MongoClient = &MongoClient{
Limit: 10, Limit: 10,
Registry: make(map[string]*CollectionDefinition, 0), Registry: make(map[string]*CollectionDefinition, 0),
Relaxed: false, Relaxed: false,
IgnoreAudit: []string{"event"},
} }
func GetMongoClient() *MongoClient { func GetMongoClient() *MongoClient {
@@ -280,6 +290,9 @@ type MongoStartProps struct {
MongoPass string MongoPass string
MongoDebugQuery bool MongoDebugQuery bool
MongoDBPrefix string MongoDBPrefix string
ContextFields []string
WithAudit bool
OnAudit *OnAudit
AllowTruncatingDoubles bool AllowTruncatingDoubles bool
} }
@@ -314,18 +327,30 @@ func Start(props *MongoStartProps) error {
}). }).
SetRegistry(GetCustomRegistry()) SetRegistry(GetCustomRegistry())
colors := []string{
commons.EscapeGreen,
commons.EscapeRed,
commons.EscapeYellow,
commons.EscapeBlue,
commons.EscapeMagenta,
commons.EscapeCyan,
}
client.DebugQuery = props.MongoDebugQuery client.DebugQuery = props.MongoDebugQuery
if client.DebugQuery { if client.DebugQuery {
// Debug queries // Debug queries
monitor := &event.CommandMonitor{ monitor := &event.CommandMonitor{
Started: func(_ context.Context, e *event.CommandStartedEvent) { 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) { 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) { 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 // set DBPrefix
client.DBPrefix = props.MongoDBPrefix 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 { if err := cOptions.Validate(); err != nil {
log.Fatalf("Failed to validate mongo options: %+v", err.Error()) log.Fatalf("Failed to validate mongo options: %+v", err.Error())

View File

@@ -53,7 +53,9 @@ func TestMain(m *testing.M) {
MongoUri: endpoint, MongoUri: endpoint,
MongoUser: user, MongoUser: user,
MongoPass: pass, MongoPass: pass,
ContextFields: []string{"account", "store"},
MongoDebugQuery: mongoDebug, MongoDebugQuery: mongoDebug,
WithAudit: true,
}) })
// 3. Run tests // 3. Run tests

View File

@@ -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
}

5
options/lister.go Normal file
View File

@@ -0,0 +1,5 @@
package options
type Lister[T any] interface {
List() []func(*T) error
}