├── .gitignore ├── LICENSE ├── README.md ├── callbacks.go ├── go.mod ├── go.sum ├── identity_manager.go ├── loggable.go ├── loggable_test.go ├── options.go ├── plugin.go └── util.go /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | vendor 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Alexander 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Loggable 2 | 3 | Loggable is used to helps tracking changes and history of your [GORM](https://github.com/jinzhu/gorm) models. 4 | 5 | It creates `change_logs` table in your database and writes to all loggable models when they are changed. 6 | 7 | More documentation is available in [godoc](https://godoc.org/github.com/sas1024/gorm-loggable). 8 | 9 | ## Usage 10 | 1. Register plugin using `loggable.Register(db)`. 11 | ```go 12 | plugin, err := Register(database) // database is a *gorm.DB 13 | if err != nil { 14 | panic(err) 15 | } 16 | ``` 17 | 2. Add (embed) `loggable.LoggableModel` to your GORM model. 18 | ```go 19 | type User struct{ 20 | Id uint 21 | CreatedAt time.Time 22 | // some other stuff... 23 | 24 | loggable.LoggableModel 25 | } 26 | ``` 27 | 3. Changes after calling Create, Save, Update, Delete will be tracked. 28 | 29 | ## Customization 30 | You may add additional fields to change logs, that should be saved. 31 | First, embed `loggable.LoggableModel` to your model wrapper or directly to GORM model. 32 | ```go 33 | type CreatedByLog struct { 34 | // Public field will be catches by GORM and will be saved to main table. 35 | CreatedBy string 36 | // Hided field because we do not want to write this to main table, 37 | // only to change_logs. 38 | createdByPass string 39 | loggable.LoggableModel 40 | } 41 | ``` 42 | After that, shadow `LoggableModel`'s `Meta()` method by writing your realization, that should return structure with your information. 43 | ```go 44 | type CreatedByLog struct { 45 | CreatedBy string 46 | createdByPass string 47 | loggable.LoggableModel 48 | } 49 | 50 | func (m CreatedByLog) Meta() interface{} { 51 | return struct { // You may define special type for this purposes, here we use unnamed one. 52 | CreatedBy string 53 | CreatedByPass string // CreatedByPass is a public because we want to track this field. 54 | }{ 55 | CreatedBy: m.CreatedBy, 56 | CreatedByPass: m.createdByPass, 57 | } 58 | } 59 | ``` 60 | 61 | ## Options 62 | #### LazyUpdate 63 | Option `LazyUpdate` allows save changes only if they big enough to be saved. 64 | Plugin compares the last saved object and the new one, but ignores changes was made in fields from provided list. 65 | 66 | #### ComputeDiff 67 | Option `ComputeDiff` allows to only save the changes into the RawDiff field. This options is only relevant during update 68 | operations. Only fields tagged with `gorm-loggable:true` will be taken in account. If the object does not have any field 69 | tagged with `gorm-loggable:true` then the column will always be `NULL`. 70 | 71 | e.g. 72 | 73 | ```go 74 | type Person struct { 75 | FirstName string `gorm-loggable:true` 76 | LastName string `gorm-loggable:true` 77 | Age int `gorm-loggable:true` 78 | } 79 | ``` 80 | 81 | Let's say you change person `FirstName` from `John` to `Jack` and its `Age` from 30 to 40. 82 | `ChangeLog.RawDiff` will be populated with the following: 83 | ```json 84 | { 85 | "FirstName": "Jack", 86 | "Age": 40, 87 | } 88 | ``` 89 | -------------------------------------------------------------------------------- /callbacks.go: -------------------------------------------------------------------------------- 1 | package loggable 2 | 3 | import ( 4 | "encoding/json" 5 | "reflect" 6 | 7 | "github.com/gofrs/uuid" 8 | "github.com/jinzhu/gorm" 9 | ) 10 | 11 | var im = newIdentityManager() 12 | 13 | const ( 14 | actionCreate = "create" 15 | actionUpdate = "update" 16 | actionDelete = "delete" 17 | ) 18 | 19 | type UpdateDiff map[string]interface{} 20 | 21 | // Hook for after_query. 22 | func (p *Plugin) trackEntity(scope *gorm.Scope) { 23 | if !isLoggable(scope.Value) || !isEnabled(scope.Value) { 24 | return 25 | } 26 | 27 | v := reflect.Indirect(reflect.ValueOf(scope.Value)) 28 | 29 | pkName := scope.PrimaryField().Name 30 | if v.Kind() == reflect.Slice { 31 | for i := 0; i < v.Len(); i++ { 32 | sv := reflect.Indirect(v.Index(i)) 33 | el := sv.Interface() 34 | if !isLoggable(el) { 35 | continue 36 | } 37 | 38 | im.save(el, sv.FieldByName(pkName)) 39 | } 40 | return 41 | } 42 | 43 | m := v.Interface() 44 | if !isLoggable(m) { 45 | return 46 | } 47 | 48 | im.save(scope.Value, scope.PrimaryKeyValue()) 49 | } 50 | 51 | // Hook for after_create. 52 | func (p *Plugin) addCreated(scope *gorm.Scope) { 53 | if isLoggable(scope.Value) && isEnabled(scope.Value) { 54 | _ = addRecord(scope, actionCreate) 55 | } 56 | } 57 | 58 | // Hook for after_update. 59 | func (p *Plugin) addUpdated(scope *gorm.Scope) { 60 | if !isLoggable(scope.Value) || !isEnabled(scope.Value) { 61 | return 62 | } 63 | 64 | if p.opts.lazyUpdate { 65 | record, err := p.GetLastRecord(interfaceToString(scope.PrimaryKeyValue()), false) 66 | if err == nil { 67 | if isEqual(record.RawObject, scope.Value, p.opts.lazyUpdateFields...) { 68 | return 69 | } 70 | } 71 | } 72 | 73 | _ = addUpdateRecord(scope, p.opts) 74 | } 75 | 76 | // Hook for after_delete. 77 | func (p *Plugin) addDeleted(scope *gorm.Scope) { 78 | if isLoggable(scope.Value) && isEnabled(scope.Value) { 79 | _ = addRecord(scope, actionDelete) 80 | } 81 | } 82 | 83 | func addUpdateRecord(scope *gorm.Scope, opts options) error { 84 | cl, err := newChangeLog(scope, actionUpdate) 85 | if err != nil { 86 | return err 87 | } 88 | 89 | if opts.computeDiff { 90 | diff := computeUpdateDiff(scope) 91 | 92 | if diff != nil { 93 | jd, err := json.Marshal(diff) 94 | if err != nil { 95 | return err 96 | } 97 | 98 | cl.RawDiff = string(jd) 99 | } 100 | } 101 | 102 | return scope.DB().Create(cl).Error 103 | } 104 | 105 | func newChangeLog(scope *gorm.Scope, action string) (*ChangeLog, error) { 106 | rawObject, err := json.Marshal(scope.Value) 107 | if err != nil { 108 | return nil, err 109 | } 110 | id, err := uuid.NewV4() 111 | if err != nil { 112 | return nil, err 113 | } 114 | 115 | return &ChangeLog{ 116 | ID: id, 117 | Action: action, 118 | ObjectID: interfaceToString(scope.PrimaryKeyValue()), 119 | ObjectType: scope.GetModelStruct().ModelType.Name(), 120 | RawObject: string(rawObject), 121 | RawMeta: string(fetchChangeLogMeta(scope)), 122 | RawDiff: "null", 123 | }, nil 124 | } 125 | 126 | // Writes new change log row to db. 127 | func addRecord(scope *gorm.Scope, action string) error { 128 | cl, err := newChangeLog(scope, action) 129 | if err != nil { 130 | return nil 131 | } 132 | 133 | return scope.DB().Create(cl).Error 134 | } 135 | 136 | func computeUpdateDiff(scope *gorm.Scope) UpdateDiff { 137 | old := im.get(scope.Value, scope.PrimaryKeyValue()) 138 | if old == nil { 139 | return nil 140 | } 141 | 142 | ov := reflect.ValueOf(old) 143 | nv := reflect.Indirect(reflect.ValueOf(scope.Value)) 144 | names := getLoggableFieldNames(old) 145 | 146 | diff := make(UpdateDiff) 147 | 148 | for _, name := range names { 149 | ofv := ov.FieldByName(name).Interface() 150 | nfv := nv.FieldByName(name).Interface() 151 | if ofv != nfv { 152 | diff[name] = nfv 153 | } 154 | } 155 | 156 | return diff 157 | } 158 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/sas1024/gorm-loggable 2 | 3 | require ( 4 | github.com/gofrs/uuid v3.1.0+incompatible 5 | github.com/jinzhu/copier v0.0.0-20180308034124-7e38e58719c3 6 | github.com/jinzhu/gorm v1.9.2 7 | github.com/jinzhu/inflection v0.0.0-20180308033659-04140366298a // indirect 8 | ) 9 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/gofrs/uuid v3.1.0+incompatible h1:q2rtkjaKT4YEr6E1kamy0Ha4RtepWlQBedyHx0uzKwA= 2 | github.com/gofrs/uuid v3.1.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= 3 | github.com/jinzhu/copier v0.0.0-20180308034124-7e38e58719c3 h1:sHsPfNMAG70QAvKbddQ0uScZCHQoZsT5NykGRCeeeIs= 4 | github.com/jinzhu/copier v0.0.0-20180308034124-7e38e58719c3/go.mod h1:yL958EeXv8Ylng6IfnvG4oflryUi3vgA3xPs9hmII1s= 5 | github.com/jinzhu/gorm v1.9.2 h1:lCvgEaqe/HVE+tjAR2mt4HbbHAZsQOv3XAZiEZV37iw= 6 | github.com/jinzhu/gorm v1.9.2/go.mod h1:Vla75njaFJ8clLU1W44h34PjIkijhjHIYnZxMqCdxqo= 7 | github.com/jinzhu/inflection v0.0.0-20180308033659-04140366298a h1:eeaG9XMUvRBYXJi4pg1ZKM7nxc5AfXfojeLLW7O5J3k= 8 | github.com/jinzhu/inflection v0.0.0-20180308033659-04140366298a/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= 9 | -------------------------------------------------------------------------------- /identity_manager.go: -------------------------------------------------------------------------------- 1 | package loggable 2 | 3 | import ( 4 | "crypto/md5" 5 | "encoding/hex" 6 | "fmt" 7 | "github.com/jinzhu/copier" 8 | "reflect" 9 | ) 10 | 11 | type identityMap map[string]interface{} 12 | 13 | // identityManager is used as cache. 14 | type identityManager struct { 15 | m identityMap 16 | } 17 | 18 | func newIdentityManager() *identityManager { 19 | return &identityManager{ 20 | m: make(identityMap), 21 | } 22 | } 23 | 24 | func (im *identityManager) save(value, pk interface{}) { 25 | t := reflect.TypeOf(value) 26 | newValue := reflect.New(t).Interface() 27 | err := copier.Copy(&newValue, value) 28 | if err != nil { 29 | panic(err) 30 | } 31 | 32 | im.m[genIdentityKey(t, pk)] = newValue 33 | } 34 | 35 | func (im identityManager) get(value, pk interface{}) interface{} { 36 | t := reflect.TypeOf(value) 37 | key := genIdentityKey(t, pk) 38 | m, ok := im.m[key] 39 | if !ok { 40 | return nil 41 | } 42 | 43 | return m 44 | } 45 | 46 | func genIdentityKey(t reflect.Type, pk interface{}) string { 47 | key := fmt.Sprintf("%v_%s", pk, t.Name()) 48 | b := md5.Sum([]byte(key)) 49 | 50 | return hex.EncodeToString(b[:]) 51 | } 52 | -------------------------------------------------------------------------------- /loggable.go: -------------------------------------------------------------------------------- 1 | package loggable 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "reflect" 7 | "time" 8 | 9 | "github.com/gofrs/uuid" 10 | "github.com/jinzhu/gorm" 11 | ) 12 | 13 | // Interface is used to get metadata from your models. 14 | type Interface interface { 15 | // Meta should return structure, that can be converted to json. 16 | Meta() interface{} 17 | // lock makes available only embedding structures. 18 | lock() 19 | // check if callback enabled 20 | isEnabled() bool 21 | // enable/disable loggable 22 | Enable(v bool) 23 | } 24 | 25 | // LoggableModel is a root structure, which implement Interface. 26 | // Embed LoggableModel to your model so that Plugin starts tracking changes. 27 | type LoggableModel struct { 28 | Disabled bool `sql:"-" json:"-"` 29 | } 30 | 31 | func (LoggableModel) Meta() interface{} { return nil } 32 | func (LoggableModel) lock() {} 33 | func (l LoggableModel) isEnabled() bool { return !l.Disabled } 34 | func (l LoggableModel) Enable(v bool) { l.Disabled = !v } 35 | 36 | // ChangeLog is a main entity, which used to log changes. 37 | // Commonly, ChangeLog is stored in 'change_logs' table. 38 | type ChangeLog struct { 39 | // Primary key of change logs. 40 | ID uuid.UUID `gorm:"primary_key;"` 41 | // Timestamp, when change log was created. 42 | CreatedAt time.Time `sql:"DEFAULT:current_timestamp"` 43 | // Action type. 44 | // On write, supports only 'create', 'update', 'delete', 45 | // but on read can be anything. 46 | Action string 47 | // ID of tracking object. 48 | // By this ID later you can find all object (database row) changes. 49 | ObjectID string `gorm:"index"` 50 | // Reflect name of tracking object. 51 | // It does not use package or module name, so 52 | // it may be not unique when use multiple types from different packages but with the same name. 53 | ObjectType string `gorm:"index"` 54 | // Raw representation of tracking object. 55 | // todo(@sas1024): Replace with []byte, to reduce allocations. Would be major version. 56 | RawObject string `sql:"type:JSON"` 57 | // Raw representation of tracking object's meta. 58 | // todo(@sas1024): Replace with []byte, to reduce allocations. Would be major version. 59 | RawMeta string `sql:"type:JSON"` 60 | // Raw representation of diff's. 61 | // todo(@sas1024): Replace with []byte, to reduce allocations. Would be major version. 62 | RawDiff string `sql:"type:JSON"` 63 | // Free field to store something you want, e.g. who creates change log. 64 | // Not used field in gorm-loggable, but gorm tracks this field. 65 | CreatedBy string `gorm:"index"` 66 | // Field Object would contain prepared structure, parsed from RawObject as json. 67 | // Use RegObjectType to register object types. 68 | Object interface{} `sql:"-"` 69 | // Field Meta would contain prepared structure, parsed from RawMeta as json. 70 | // Use RegMetaType to register object's meta types. 71 | Meta interface{} `sql:"-"` 72 | } 73 | 74 | func (l *ChangeLog) prepareObject(objType reflect.Type) error { 75 | // Allocate new and try to decode change logs field RawObject to Object. 76 | obj := reflect.New(objType).Interface() 77 | err := json.Unmarshal([]byte(l.RawObject), obj) 78 | l.Object = obj 79 | return err 80 | } 81 | 82 | func (l *ChangeLog) prepareMeta(objType reflect.Type) error { 83 | // Allocate new and try to decode change logs field RawObject to Object. 84 | obj := reflect.New(objType).Interface() 85 | err := json.Unmarshal([]byte(l.RawMeta), obj) 86 | l.Meta = obj 87 | return err 88 | } 89 | 90 | // Diff returns parsed to map[string]interface{} diff representation from field RawDiff. 91 | // To unmarshal diff to own structure, manually use field RawDiff. 92 | func (l ChangeLog) Diff() (UpdateDiff, error) { 93 | var diff UpdateDiff 94 | err := json.Unmarshal([]byte(l.RawDiff), &diff) 95 | if err != nil { 96 | return nil, err 97 | } 98 | 99 | return diff, nil 100 | } 101 | 102 | func interfaceToString(v interface{}) string { 103 | switch val := v.(type) { 104 | case string: 105 | return val 106 | default: 107 | return fmt.Sprint(v) 108 | } 109 | } 110 | 111 | func fetchChangeLogMeta(scope *gorm.Scope) []byte { 112 | val, ok := scope.Value.(Interface) 113 | if !ok { 114 | return nil 115 | } 116 | data, err := json.Marshal(val.Meta()) 117 | if err != nil { 118 | panic(err) 119 | } 120 | return data 121 | } 122 | 123 | func isLoggable(value interface{}) bool { 124 | _, ok := value.(Interface) 125 | return ok 126 | } 127 | 128 | func isEnabled(value interface{}) bool { 129 | v, ok := value.(Interface) 130 | return ok && v.isEnabled() 131 | } 132 | -------------------------------------------------------------------------------- /loggable_test.go: -------------------------------------------------------------------------------- 1 | package loggable 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "testing" 7 | "time" 8 | 9 | "github.com/jinzhu/gorm" 10 | _ "github.com/jinzhu/gorm/dialects/postgres" 11 | ) 12 | 13 | var db *gorm.DB 14 | 15 | type SomeType struct { 16 | gorm.Model 17 | Source string 18 | MetaModel 19 | } 20 | 21 | type MetaModel struct { 22 | createdBy string 23 | LoggableModel 24 | } 25 | 26 | func (m MetaModel) Meta() interface{} { 27 | return struct { 28 | CreatedBy string 29 | }{CreatedBy: m.createdBy} 30 | } 31 | 32 | func TestMain(m *testing.M) { 33 | database, err := gorm.Open( 34 | "postgres", 35 | fmt.Sprintf( 36 | "postgres://%s:%s@%s:%d/%s?sslmode=disable", 37 | "root", 38 | "keepitsimple", 39 | "localhost", 40 | 5432, 41 | "loggable", 42 | ), 43 | ) 44 | if err != nil { 45 | fmt.Println(err) 46 | panic(err) 47 | } 48 | database = database.LogMode(true) 49 | _, err = Register(database) 50 | if err != nil { 51 | fmt.Println(err) 52 | panic(err) 53 | } 54 | err = database.AutoMigrate(SomeType{}).Error 55 | if err != nil { 56 | fmt.Println(err) 57 | panic(err) 58 | } 59 | db = database 60 | os.Exit(m.Run()) 61 | } 62 | 63 | func TestTryModel(t *testing.T) { 64 | newmodel := SomeType{Source: time.Now().Format(time.Stamp)} 65 | newmodel.createdBy = "some user" 66 | err := db.Create(&newmodel).Error 67 | if err != nil { 68 | t.Fatal(err) 69 | } 70 | fmt.Println(newmodel.ID) 71 | newmodel.Source = "updated field" 72 | err = db.Model(SomeType{}).Save(&newmodel).Error 73 | if err != nil { 74 | t.Fatal(err) 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /options.go: -------------------------------------------------------------------------------- 1 | package loggable 2 | 3 | import ( 4 | "reflect" 5 | ) 6 | 7 | // Option is a generic options pattern. 8 | type Option func(options *options) 9 | 10 | type options struct { 11 | lazyUpdate bool 12 | lazyUpdateFields []string 13 | metaTypes map[string]reflect.Type 14 | objectTypes map[string]reflect.Type 15 | computeDiff bool 16 | } 17 | 18 | // Option ComputeDiff allows you also write differences between objects on update operations. 19 | // ComputeDiff not reads records from db, it used only as cache on plugin side. 20 | // So it does not track changes outside plugin. 21 | func ComputeDiff() Option { 22 | return func(options *options) { 23 | options.computeDiff = true 24 | } 25 | } 26 | 27 | // Option LazyUpdate allows you to skip update operations when nothing was changed. 28 | // Parameter 'fields' is list of sql field names that should be ignored on updates. 29 | func LazyUpdate(fields ...string) Option { 30 | return func(options *options) { 31 | options.lazyUpdate = true 32 | options.lazyUpdateFields = fields 33 | } 34 | } 35 | 36 | // RegObjectType maps object to type name, that is used in field Type of ChangeLog struct. 37 | // On read change log operations, if plugin finds registered object type, by its name from db, 38 | // it unmarshal field RawObject to Object field via json.Unmarshal. 39 | // 40 | // To access decoded object, e.g. `ReallyFunnyClient`, use type casting: `changeLog.Object.(ReallyFunnyClient)`. 41 | func RegObjectType(objectType string, objectStruct interface{}) Option { 42 | return func(options *options) { 43 | options.objectTypes[objectType] = reflect.Indirect(reflect.ValueOf(objectStruct)).Type() 44 | } 45 | } 46 | 47 | // RegMetaType works like RegObjectType, but for field RawMeta. 48 | // RegMetaType maps object to type name, that is used in field Type of ChangeLog struct. 49 | // On read change log operations, if plugin finds registered object type, by its name from db, 50 | // it unmarshal field RawMeta to Meta field via json.Unmarshal. 51 | // 52 | // To access decoded object, e.g. `MyClientMeta`, use type casting: `changeLog.Meta.(MyClientMeta)`. 53 | func RegMetaType(objectType string, metaType interface{}) Option { 54 | return func(options *options) { 55 | options.metaTypes[objectType] = reflect.Indirect(reflect.ValueOf(metaType)).Type() 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /plugin.go: -------------------------------------------------------------------------------- 1 | package loggable 2 | 3 | import ( 4 | "github.com/jinzhu/gorm" 5 | ) 6 | 7 | // Plugin is a hook for gorm. 8 | type Plugin struct { 9 | db *gorm.DB 10 | opts options 11 | } 12 | 13 | // Register initializes Plugin for provided gorm.DB. 14 | // There is also available some options, that should be passed there. 15 | // Options cannot be set after initialization. 16 | func Register(db *gorm.DB, opts ...Option) (Plugin, error) { 17 | err := db.AutoMigrate(&ChangeLog{}).Error 18 | if err != nil { 19 | return Plugin{}, err 20 | } 21 | o := options{} 22 | for _, option := range opts { 23 | option(&o) 24 | } 25 | p := Plugin{db: db, opts: o} 26 | callback := db.Callback() 27 | callback.Query().After("gorm:after_query").Register("loggable:query", p.trackEntity) 28 | callback.Create().After("gorm:after_create").Register("loggable:create", p.addCreated) 29 | callback.Update().After("gorm:after_update").Register("loggable:update", p.addUpdated) 30 | callback.Delete().After("gorm:after_delete").Register("loggable:delete", p.addDeleted) 31 | return p, nil 32 | } 33 | 34 | // GetRecords returns all records by objectId. 35 | // Flag prepare allows to decode content of Raw* fields to direct fields, e.g. RawObject to Object. 36 | func (p *Plugin) GetRecords(objectId string, prepare bool) (changes []ChangeLog, err error) { 37 | defer func() { 38 | if prepare { 39 | for i := range changes { 40 | if t, ok := p.opts.metaTypes[changes[i].ObjectType]; ok { 41 | err = changes[i].prepareMeta(t) 42 | if err != nil { 43 | return 44 | } 45 | } 46 | if t, ok := p.opts.objectTypes[changes[i].ObjectType]; ok { 47 | err = changes[i].prepareObject(t) 48 | if err != nil { 49 | return 50 | } 51 | } 52 | } 53 | } 54 | }() 55 | return changes, p.db.Where("object_id = ?", objectId).Find(&changes).Error 56 | } 57 | 58 | // GetLastRecord returns last by creation time (CreatedAt field) change log by provided object id. 59 | // Flag prepare allows to decode content of Raw* fields to direct fields, e.g. RawObject to Object. 60 | func (p *Plugin) GetLastRecord(objectId string, prepare bool) (change ChangeLog, err error) { 61 | defer func() { 62 | if prepare { 63 | if t, ok := p.opts.metaTypes[change.ObjectType]; ok { 64 | err := change.prepareMeta(t) 65 | if err != nil { 66 | return 67 | } 68 | } 69 | if t, ok := p.opts.objectTypes[change.ObjectType]; ok { 70 | err := change.prepareObject(t) 71 | if err != nil { 72 | return 73 | } 74 | } 75 | } 76 | }() 77 | return change, p.db.Where("object_id = ?", objectId).Order("created_at DESC").Limit(1).Find(&change).Error 78 | } 79 | -------------------------------------------------------------------------------- /util.go: -------------------------------------------------------------------------------- 1 | package loggable 2 | 3 | import ( 4 | "encoding/json" 5 | "reflect" 6 | "strings" 7 | "unicode" 8 | ) 9 | 10 | const loggableTag = "gorm-loggable" 11 | 12 | func isEqual(item1, item2 interface{}, except ...string) bool { 13 | except = StringMap(except, ToSnakeCase) 14 | m1, m2 := somethingToMapStringInterface(item1), somethingToMapStringInterface(item2) 15 | if len(m1) != len(m2) { 16 | return false 17 | } 18 | for k, v := range m1 { 19 | if isInStringSlice(ToSnakeCase(k), except) { 20 | continue 21 | } 22 | v2, ok := m2[k] 23 | if !ok || !reflect.DeepEqual(v, v2) { 24 | return false 25 | } 26 | } 27 | return true 28 | } 29 | 30 | func somethingToMapStringInterface(item interface{}) map[string]interface{} { 31 | if item == nil { 32 | return nil 33 | } 34 | switch raw := item.(type) { 35 | case string: 36 | return somethingToMapStringInterface([]byte(raw)) 37 | case []byte: 38 | var m map[string]interface{} 39 | err := json.Unmarshal(raw, &m) 40 | if err != nil { 41 | return nil 42 | } 43 | return m 44 | default: 45 | data, err := json.Marshal(item) 46 | if err != nil { 47 | return nil 48 | } 49 | return somethingToMapStringInterface(data) 50 | } 51 | return nil 52 | } 53 | 54 | var ToSnakeCase = toSomeCase("_") 55 | 56 | func toSomeCase(sep string) func(string) string { 57 | return func(s string) string { 58 | for i := range s { 59 | if unicode.IsUpper(rune(s[i])) { 60 | if i != 0 { 61 | s = strings.Join([]string{s[:i], ToLowerFirst(s[i:])}, sep) 62 | } else { 63 | s = ToLowerFirst(s) 64 | } 65 | } 66 | } 67 | return s 68 | } 69 | } 70 | 71 | func ToLowerFirst(s string) string { 72 | if len(s) == 0 { 73 | return "" 74 | } 75 | return strings.ToLower(string(s[0])) + s[1:] 76 | } 77 | 78 | func StringMap(strs []string, fn func(string) string) []string { 79 | res := make([]string, len(strs)) 80 | for i := range strs { 81 | res[i] = fn(strs[i]) 82 | } 83 | return res 84 | } 85 | 86 | func isInStringSlice(what string, where []string) bool { 87 | for i := range where { 88 | if what == where[i] { 89 | return true 90 | } 91 | } 92 | return false 93 | } 94 | 95 | func getLoggableFieldNames(value interface{}) []string { 96 | var names []string 97 | 98 | t := reflect.TypeOf(value) 99 | for i := 0; i < t.NumField(); i++ { 100 | field := t.Field(i) 101 | value, ok := field.Tag.Lookup(loggableTag) 102 | if !ok || value != "true" { 103 | continue 104 | } 105 | 106 | names = append(names, field.Name) 107 | } 108 | 109 | return names 110 | } 111 | --------------------------------------------------------------------------------