diff --git a/README.md b/README.md index 9f104b5..417a0fb 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,58 @@ It creates `change_logs` table in your database and writes to all loggable model ## Usage - 1. Register plugin using `loggable.Register(db)` - 2. Add `loggable.LoggableModel` to your GORM model - 3. If you want to set user, who makes changes, and place, where it happened, use `loggable.SetUserAndFrom("username", "London")`. +1. Register plugin using `loggable.Register(db)`. +```go +plugin, err := Register(database) // database is a *gorm.DB +if err != nil { + panic(err) +} +``` +2. Add (embed) `loggable.LoggableModel` to your GORM model. +```go +type User struct{ + Id uint + CreatedAt time.Time + // some other stuff... + + loggable.LoggableModel +} +``` +3. Changes after calling Create, Save, Update, Delete will be tracked. + +## Customization +You may add additional fields to change logs, that should be saved. +First, embed `loggable.LoggableModel` to your model wrapper or directly to GORM model. +```go +type CreatedByLog struct { + // Public field will be catches by GORM and will be saved to main table. + CreatedBy string + // Hided field because we do not want to write this to main table, + // only to change_logs. + createdByPass string + loggable.LoggableModel +} +``` +After that, shadow `LoggableModel`'s `Meta()` method by writing your realization, that should return structure with your information. +```go +type CreatedByLog struct { + CreatedBy string + createdByPass string + loggable.LoggableModel +} + +func (m CreatedByLog) Meta() interface{} { + return struct { // You may define special type for this purposes, here we use unnamed one. + CreatedBy string + CreatedByPass string // CreatedByPass is a public because we want to track this field. + }{ + CreatedBy: m.CreatedBy, + CreatedByPass: m.createdByPass, + } +} +``` + +## Options +#### LazyUpdate +Option `LazyUpdate` allows save changes only if they big enough to be saved. +Plugin compares the last saved object and the new one, but ignores changes was made in fields from provided list. diff --git a/callbacks.go b/callbacks.go index b1b0f7c..052d64b 100644 --- a/callbacks.go +++ b/callbacks.go @@ -2,145 +2,49 @@ package loggable import ( "encoding/json" - "reflect" - "sync" "github.com/jinzhu/gorm" "github.com/satori/go.uuid" ) -type LoggablePlugin interface { - // Deprecated: Use SetUserAndWhere instead. - SetUser(user string) *gorm.DB - // Deprecated: Use SetUserAndWhere instead. - SetWhere(from string) *gorm.DB - SetUserAndWhere(user, where string) *gorm.DB - GetRecords(objectId string) ([]*ChangeLog, error) -} - -type Option func(options *options) - -type plugin struct { - db *gorm.DB - mu sync.Mutex - opts options -} - -func Register(db *gorm.DB, opts ...Option) (LoggablePlugin, error) { - err := db.AutoMigrate(&ChangeLog{}).Error - if err != nil { - return nil, err - } - o := options{} - for _, option := range opts { - option(&o) - } - r := &plugin{db: db, opts: o} - callback := db.Callback() - callback.Create().After("gorm:after_create").Register("loggable:create", r.addCreated) - callback.Update().After("gorm:after_update").Register("loggable:update", r.addUpdated) - callback.Delete().After("gorm:after_delete").Register("loggable:delete", r.addDeleted) - return r, nil -} - -func (r *plugin) GetRecords(objectId string) ([]*ChangeLog, error) { - var changes []*ChangeLog - return changes, r.db.Where("object_id = ?", objectId).Find(&changes).Error -} - -func (r *plugin) getLastRecord(objectId string) (*ChangeLog, error) { - var change ChangeLog - return &change, r.db.Where("object_id = ?", objectId).Order("created_at DESC").Limit(1).Find(&change).Error -} - -// Deprecated: Use SetUserAndWhere instead. -func (r *plugin) SetUser(user string) *gorm.DB { - r.mu.Lock() - db := r.db.Set("loggable:user", user) - r.mu.Unlock() - return db -} - -// Deprecated: Use SetUserAndWhere instead. -func (r *plugin) SetWhere(where string) *gorm.DB { - r.mu.Lock() - db := r.db.Set("loggable:where", where) - r.mu.Unlock() - return db -} - -func (r *plugin) SetUserAndWhere(user, where string) *gorm.DB { - r.mu.Lock() - defer r.mu.Unlock() - return r.db.Set("loggable:user", user).Set("loggable:where", where) -} - -func (r *plugin) addRecord(scope *gorm.Scope, action string) error { - var jsonObject JSONB - j, err := json.Marshal(scope.Value) - if err != nil { - return err - } - err = jsonObject.Scan(j) - if err != nil { - return err - } - user, where := getUserAndWhere(scope) - - cl := ChangeLog{ - ID: uuid.NewV4().String(), - ChangedBy: user.(string), - ChangedWhere: where.(string), - Action: action, - ObjectID: scope.PrimaryKeyValue().(string), - ObjectType: scope.GetModelStruct().ModelType.Name(), - Object: jsonObject, - } - return scope.DB().Create(&cl).Error -} - -func getUserAndWhere(scope *gorm.Scope) (interface{}, interface{}) { - user, ok := scope.DB().Get("loggable:user") - if !ok { - user = "" - } - where, ok := scope.DB().Get("loggable:where") - if !ok { - where = "" - } - return user, where -} - -func isLoggable(scope *gorm.Scope) (isLoggable bool) { - if scope.GetModelStruct().ModelType == nil { - return false - } - _, isLoggable = reflect.New(scope.GetModelStruct().ModelType).Interface().(loggableInterface) - return -} - -func (r *plugin) addCreated(scope *gorm.Scope) { +func (p *Plugin) addCreated(scope *gorm.Scope) { if isLoggable(scope) { - r.addRecord(scope, "create") + addRecord(scope, "create") } } -func (r *plugin) addUpdated(scope *gorm.Scope) { +func (p *Plugin) addUpdated(scope *gorm.Scope) { if isLoggable(scope) { - if r.opts.lazyUpdate { - record, err := r.getLastRecord(scope.PrimaryKeyValue().(string)) + if p.opts.lazyUpdate { + record, err := p.GetLastRecord(interfaceToString(scope.PrimaryKeyValue()), false) if err == nil { - if isEqual(record.Object, scope.Value, r.opts.lazyUpdateFields...) { + if isEqual(record.RawObject, scope.Value, p.opts.lazyUpdateFields...) { return } } } - r.addRecord(scope, "update") + addRecord(scope, "update") } } -func (r *plugin) addDeleted(scope *gorm.Scope) { +func (p *Plugin) addDeleted(scope *gorm.Scope) { if isLoggable(scope) { - r.addRecord(scope, "delete") + addRecord(scope, "delete") } } + +func addRecord(scope *gorm.Scope, action string) error { + rawObject, err := json.Marshal(scope.Value) + if err != nil { + return err + } + cl := ChangeLog{ + ID: uuid.NewV4().String(), + Action: action, + ObjectID: interfaceToString(scope.PrimaryKeyValue()), + ObjectType: scope.GetModelStruct().ModelType.Name(), + RawObject: rawObject, + RawMeta: fetchChangeLogMeta(scope), + } + return scope.DB().Create(&cl).Error +} diff --git a/jsonb.go b/jsonb.go new file mode 100644 index 0000000..eafa619 --- /dev/null +++ b/jsonb.go @@ -0,0 +1,60 @@ +package loggable + +import ( + "bytes" + "database/sql/driver" + "encoding/json" + "errors" + "reflect" +) + +type JSONB []byte + +func (j JSONB) Value() (driver.Value, error) { + if j.IsNull() { + return nil, nil + } + return string(j), nil +} + +func (j *JSONB) Scan(value interface{}) error { + if value == nil { + *j = nil + return nil + } + s, ok := value.([]byte) + if !ok { + return errors.New("scan source is not bytes") + } + *j = append((*j)[0:0], s...) + return nil +} + +func (j JSONB) MarshalJSON() ([]byte, error) { + if j == nil { + return []byte("null"), nil + } + return j, nil +} + +func (j *JSONB) UnmarshalJSON(data []byte) error { + if j == nil { + return errors.New("json.RawMessage: UnmarshalJSON on nil pointer") + } + *j = append((*j)[0:0], data...) + return nil +} + +func (j JSONB) IsNull() bool { + return len(j) == 0 || string(j) == "null" +} + +func (j JSONB) Equals(j1 JSONB) bool { + return bytes.Equal([]byte(j), []byte(j1)) +} + +func (j JSONB) unmarshal(p reflect.Type) (interface{}, error) { + obj := reflect.New(p).Interface() + err := json.Unmarshal(j, obj) + return obj, err +} diff --git a/loggable.go b/loggable.go index 3fdf841..e2153d5 100644 --- a/loggable.go +++ b/loggable.go @@ -1,75 +1,74 @@ package loggable import ( - "bytes" - "database/sql/driver" - "errors" + "encoding/json" + "fmt" + "reflect" "time" + + "github.com/jinzhu/gorm" ) -type ChangeLog struct { - ID string `gorm:"type:uuid;primary_key;"` - CreatedAt time.Time `sql:"DEFAULT:current_timestamp"` - ChangedBy string `gorm:"index"` - ChangedWhere string `gorm:"index"` - Action string - ObjectID string `gorm:"index"` - ObjectType string `gorm:"index"` - Object JSONB `sql:"type:JSONB"` +// Interface is used to get metadata from your models. +type Interface interface { + // Meta should return structure, that can be converted to json. + Meta() interface{} + // lock makes available only embedding structures. + lock() } -type loggableInterface interface { - stubMethod() error -} +// LoggableModel is a root structure, which implement Interface. +// Embed LoggableModel to your model so that Plugin starts tracking changes. +type LoggableModel struct{} -type LoggableModel struct { -} +func (LoggableModel) Meta() interface{} { return nil } +func (LoggableModel) lock() {} -func (model LoggableModel) stubMethod() error { - return nil +// ChangeLog is a main entity, which used to log changes. +type ChangeLog struct { + ID string `gorm:"type:uuid;primary_key;"` + CreatedAt time.Time `sql:"DEFAULT:current_timestamp"` + Action string + ObjectID string `gorm:"index"` + ObjectType string `gorm:"index"` + RawObject JSONB `sql:"type:JSONB"` + RawMeta JSONB `sql:"type:JSONB"` + Object interface{} `sql:"-"` + Meta interface{} `sql:"-"` } -type JSONB []byte - -func (j JSONB) Value() (driver.Value, error) { - if j.IsNull() { - return nil, nil - } - return string(j), nil +func (l *ChangeLog) prepareObject(objType reflect.Type) (err error) { + l.Object, err = l.RawObject.unmarshal(objType) + return } -func (j *JSONB) Scan(value interface{}) error { - if value == nil { - *j = nil - return nil - } - s, ok := value.([]byte) - if !ok { - return errors.New("Scan source was not string") - } - *j = append((*j)[0:0], s...) - return nil +func (l *ChangeLog) prepareMeta(objType reflect.Type) (err error) { + l.Meta, err = l.RawMeta.unmarshal(objType) + return } -func (j JSONB) MarshalJSON() ([]byte, error) { - if j == nil { - return []byte("null"), nil +func interfaceToString(v interface{}) string { + switch val := v.(type) { + case string: + return val + default: + return fmt.Sprint(v) } - return j, nil } -func (j *JSONB) UnmarshalJSON(data []byte) error { - if j == nil { - return errors.New("json.RawMessage: UnmarshalJSON on nil pointer") +func fetchChangeLogMeta(scope *gorm.Scope) JSONB { + val, ok := scope.Value.(Interface) + if !ok { + return nil } - *j = append((*j)[0:0], data...) - return nil -} - -func (j JSONB) IsNull() bool { - return len(j) == 0 || string(j) == "null" + data, err := json.Marshal(val.Meta()) + if err != nil { + panic(err) + } + return data } -func (j JSONB) Equals(j1 JSONB) bool { - return bytes.Equal([]byte(j), []byte(j1)) +func isLoggable(scope *gorm.Scope) bool { + _, ok := scope.Value.(Interface) + return ok } diff --git a/loggable_test.go b/loggable_test.go new file mode 100644 index 0000000..12edd12 --- /dev/null +++ b/loggable_test.go @@ -0,0 +1,75 @@ +package loggable + +import ( + "fmt" + "testing" + "time" + + "github.com/jinzhu/gorm" + _ "github.com/jinzhu/gorm/dialects/postgres" +) + +var db *gorm.DB + +type SomeType struct { + gorm.Model + Source string + MetaModel +} + +type MetaModel struct { + createdBy string + LoggableModel +} + +func (m MetaModel) Meta() interface{} { + return struct { + CreatedBy string + }{CreatedBy: m.createdBy} +} + +func TestMain(m *testing.M) { + database, err := gorm.Open( + "postgres", + fmt.Sprintf( + "postgres://%s:%s@%s:%d/%s?sslmode=disable", + "root", + "keepitsimple", + "localhost", + 5432, + "loggable", + ), + ) + if err != nil { + fmt.Println(err) + panic(err) + } + database = database.LogMode(true) + _, err = Register(database) + if err != nil { + fmt.Println(err) + panic(err) + } + err = database.AutoMigrate(SomeType{}).Error + if err != nil { + fmt.Println(err) + panic(err) + } + db = database + m.Run() +} + +func TestTryModel(t *testing.T) { + newmodel := SomeType{Source: time.Now().Format(time.Stamp)} + newmodel.createdBy = "some user" + err := db.Create(&newmodel).Error + if err != nil { + t.Fatal(err) + } + fmt.Println(newmodel.ID) + newmodel.Source = "updated field" + err = db.Model(SomeType{}).Save(&newmodel).Error + if err != nil { + t.Fatal(err) + } +} diff --git a/options.go b/options.go index b074bdf..018db03 100644 --- a/options.go +++ b/options.go @@ -1,13 +1,31 @@ package loggable +import "reflect" + +type Option func(options *options) + type options struct { lazyUpdate bool lazyUpdateFields []string + metaTypes map[string]reflect.Type + objectTypes map[string]reflect.Type } -func LazyUpdateOption(fields ...string) func(options *options) { +func LazyUpdate(fields ...string) Option { return func(options *options) { options.lazyUpdate = true options.lazyUpdateFields = fields } } + +func RegObjectType(objectType string, objectStruct interface{}) Option { + return func(options *options) { + options.objectTypes[objectType] = reflect.Indirect(reflect.ValueOf(objectStruct)).Type() + } +} + +func RegMetaType(objectType string, metaType interface{}) Option { + return func(options *options) { + options.metaTypes[objectType] = reflect.Indirect(reflect.ValueOf(metaType)).Type() + } +} diff --git a/plugin.go b/plugin.go new file mode 100644 index 0000000..98e98be --- /dev/null +++ b/plugin.go @@ -0,0 +1,69 @@ +package loggable + +import ( + "github.com/jinzhu/gorm" +) + +type Plugin struct { + db *gorm.DB + opts options +} + +func Register(db *gorm.DB, opts ...Option) (Plugin, error) { + err := db.AutoMigrate(&ChangeLog{}).Error + if err != nil { + return Plugin{}, err + } + o := options{} + for _, option := range opts { + option(&o) + } + p := Plugin{db: db, opts: o} + callback := db.Callback() + callback.Create().After("gorm:after_create").Register("loggable:create", p.addCreated) + callback.Update().After("gorm:after_update").Register("loggable:update", p.addUpdated) + callback.Delete().After("gorm:after_delete").Register("loggable:delete", p.addDeleted) + return p, nil +} + +func (p *Plugin) GetRecords(objectId string, prepare bool) (changes []ChangeLog, err error) { + defer func() { + if prepare { + for i := range changes { + if t, ok := p.opts.metaTypes[changes[i].ObjectType]; ok { + err = changes[i].prepareMeta(t) + if err != nil { + return + } + } + if t, ok := p.opts.objectTypes[changes[i].ObjectType]; ok { + err = changes[i].prepareObject(t) + if err != nil { + return + } + } + } + } + }() + return changes, p.db.Where("object_id = ?", objectId).Find(&changes).Error +} + +func (p *Plugin) GetLastRecord(objectId string, prepare bool) (change ChangeLog, err error) { + defer func() { + if prepare { + if t, ok := p.opts.metaTypes[change.ObjectType]; ok { + err := change.prepareMeta(t) + if err != nil { + return + } + } + if t, ok := p.opts.objectTypes[change.ObjectType]; ok { + err := change.prepareObject(t) + if err != nil { + return + } + } + } + }() + return change, p.db.Where("object_id = ?", objectId).Order("created_at DESC").Limit(1).Find(&change).Error +} diff --git a/util.go b/util.go index 960ac2b..cc44587 100644 --- a/util.go +++ b/util.go @@ -51,15 +51,6 @@ func somethingToMapStringInterface(item interface{}) map[string]interface{} { return nil } -func isInStringSlice(what string, where []string) bool { - for i := range where { - if what == where[i] { - return true - } - } - return false -} - var ToSnakeCase = toSomeCase("_") func toSomeCase(sep string) func(string) string { @@ -91,3 +82,12 @@ func StringMap(strs []string, fn func(string) string) []string { } return res } + +func isInStringSlice(what string, where []string) bool { + for i := range where { + if what == where[i] { + return true + } + } + return false +}