├── .gitignore ├── LICENSE ├── README.md ├── cacher.go ├── cacher_test.go ├── caches.go ├── caches_test.go ├── easer.go ├── easer_test.go ├── go.mod ├── go.sum ├── identifier.go ├── identifier_test.go ├── query.go ├── query_task.go ├── query_task_test.go ├── query_test.go ├── reflection.go ├── reflection_test.go ├── task.go └── task_test.go /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023-NOW Kristian Tsivkov 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 | # Gorm Caches 2 | 3 | Gorm Caches plugin using database request reductions (easer), and response caching mechanism provide you an easy way to optimize database performance. 4 | 5 | ## Features 6 | 7 | - Database request reduction. If three identical requests are running at the same time, only the first one is going to be executed, and its response will be returned for all. 8 | - Database response caching. By implementing the Cacher interface, you can easily setup a caching mechanism for your database queries. 9 | - Supports all databases that are supported by gorm itself. 10 | 11 | ## Install 12 | 13 | ```bash 14 | go get -u github.com/go-gorm/caches/v4 15 | ``` 16 | 17 | ## Usage 18 | 19 | Configure the `easer`, and the `cacher`, and then load the plugin to gorm. 20 | 21 | ```go 22 | package main 23 | 24 | import ( 25 | "fmt" 26 | "sync" 27 | 28 | "github.com/go-gorm/caches/v4" 29 | "gorm.io/driver/mysql" 30 | "gorm.io/gorm" 31 | ) 32 | 33 | func main() { 34 | db, _ := gorm.Open( 35 | mysql.Open("DATABASE_DSN"), 36 | &gorm.Config{}, 37 | ) 38 | cachesPlugin := &caches.Caches{Conf: &caches.Config{ 39 | Easer: true, 40 | Cacher: &yourCacherImplementation{}, 41 | }} 42 | _ = db.Use(cachesPlugin) 43 | } 44 | ``` 45 | 46 | ## Easer Example 47 | 48 | ```go 49 | package main 50 | 51 | import ( 52 | "fmt" 53 | "sync" 54 | "time" 55 | 56 | "github.com/go-gorm/caches/v4" 57 | "gorm.io/driver/mysql" 58 | "gorm.io/gorm" 59 | ) 60 | 61 | type UserRoleModel struct { 62 | gorm.Model 63 | Name string `gorm:"unique"` 64 | } 65 | 66 | type UserModel struct { 67 | gorm.Model 68 | Name string 69 | RoleId uint 70 | Role *UserRoleModel `gorm:"foreignKey:role_id;references:id"` 71 | } 72 | 73 | func main() { 74 | db, _ := gorm.Open( 75 | mysql.Open("DATABASE_DSN"), 76 | &gorm.Config{}, 77 | ) 78 | 79 | cachesPlugin := &caches.Caches{Conf: &caches.Config{ 80 | Easer: true, 81 | }} 82 | 83 | _ = db.Use(cachesPlugin) 84 | 85 | _ = db.AutoMigrate(&UserRoleModel{}) 86 | 87 | _ = db.AutoMigrate(&UserModel{}) 88 | 89 | adminRole := &UserRoleModel{ 90 | Name: "Admin", 91 | } 92 | db.FirstOrCreate(adminRole, "Name = ?", "Admin") 93 | 94 | guestRole := &UserRoleModel{ 95 | Name: "Guest", 96 | } 97 | db.FirstOrCreate(guestRole, "Name = ?", "Guest") 98 | 99 | db.Save(&UserModel{ 100 | Name: "ktsivkov", 101 | Role: adminRole, 102 | }) 103 | db.Save(&UserModel{ 104 | Name: "anonymous", 105 | Role: guestRole, 106 | }) 107 | 108 | var ( 109 | q1Users []UserModel 110 | q2Users []UserModel 111 | ) 112 | wg := &sync.WaitGroup{} 113 | wg.Add(2) 114 | go func() { 115 | db.Model(&UserModel{}).Joins("Role").Find(&q1Users, "Role.Name = ? AND Sleep(1) = false", "Admin") 116 | wg.Done() 117 | }() 118 | go func() { 119 | time.Sleep(500 * time.Millisecond) 120 | db.Model(&UserModel{}).Joins("Role").Find(&q2Users, "Role.Name = ? AND Sleep(1) = false", "Admin") 121 | wg.Done() 122 | }() 123 | wg.Wait() 124 | 125 | fmt.Println(fmt.Sprintf("%+v", q1Users)) 126 | fmt.Println(fmt.Sprintf("%+v", q2Users)) 127 | } 128 | ``` 129 | 130 | ## Cacher Example (Redis) 131 | 132 | ```go 133 | package main 134 | 135 | import ( 136 | "context" 137 | "fmt" 138 | "time" 139 | 140 | "github.com/go-gorm/caches/v4" 141 | "github.com/redis/go-redis/v9" 142 | "gorm.io/driver/sqlite" 143 | "gorm.io/gorm" 144 | ) 145 | 146 | type UserRoleModel struct { 147 | gorm.Model 148 | Name string `gorm:"unique"` 149 | } 150 | 151 | type UserModel struct { 152 | gorm.Model 153 | Name string 154 | RoleId uint 155 | Role *UserRoleModel `gorm:"foreignKey:role_id;references:id"` 156 | } 157 | 158 | type redisCacher struct { 159 | rdb *redis.Client 160 | } 161 | 162 | func (c *redisCacher) Get(ctx context.Context, key string, q *caches.Query[any]) (*caches.Query[any], error) { 163 | res, err := c.rdb.Get(ctx, key).Result() 164 | if err == redis.Nil { 165 | return nil, nil 166 | } 167 | 168 | if err != nil { 169 | return nil, err 170 | } 171 | 172 | if err := q.Unmarshal([]byte(res)); err != nil { 173 | return nil, err 174 | } 175 | 176 | return q, nil 177 | } 178 | 179 | func (c *redisCacher) Store(ctx context.Context, key string, val *caches.Query[any]) error { 180 | res, err := val.Marshal() 181 | if err != nil { 182 | return err 183 | } 184 | 185 | c.rdb.Set(ctx, key, res, 300*time.Second) // Set proper cache time 186 | return nil 187 | } 188 | 189 | func (c *redisCacher) Invalidate(ctx context.Context) error { 190 | var ( 191 | cursor uint64 192 | keys []string 193 | ) 194 | for { 195 | var ( 196 | k []string 197 | err error 198 | ) 199 | k, cursor, err = c.rdb.Scan(ctx, cursor, fmt.Sprintf("%s*", caches.IdentifierPrefix), 0).Result() 200 | if err != nil { 201 | return err 202 | } 203 | keys = append(keys, k...) 204 | if cursor == 0 { 205 | break 206 | } 207 | } 208 | 209 | if len(keys) > 0 { 210 | if _, err := c.rdb.Del(ctx, keys...).Result(); err != nil { 211 | return err 212 | } 213 | } 214 | return nil 215 | } 216 | 217 | func main() { 218 | db, _ := gorm.Open(sqlite.Open("gorm.db"), &gorm.Config{ 219 | AllowGlobalUpdate: true, 220 | }) 221 | 222 | cachesPlugin := &caches.Caches{Conf: &caches.Config{ 223 | Cacher: &redisCacher{ 224 | rdb: redis.NewClient(&redis.Options{ 225 | Addr: "localhost:6379", 226 | Password: "", 227 | DB: 0, 228 | }), 229 | }, 230 | }} 231 | 232 | _ = db.Use(cachesPlugin) 233 | 234 | _ = db.AutoMigrate(&UserRoleModel{}) 235 | _ = db.AutoMigrate(&UserModel{}) 236 | 237 | db.Delete(&UserRoleModel{}) 238 | db.Delete(&UserModel{}) 239 | 240 | adminRole := &UserRoleModel{ 241 | Name: "Admin", 242 | } 243 | db.Save(adminRole) 244 | 245 | guestRole := &UserRoleModel{ 246 | Name: "Guest", 247 | } 248 | db.Save(guestRole) 249 | 250 | db.Save(&UserModel{ 251 | Name: "ktsivkov", 252 | Role: adminRole, 253 | }) 254 | 255 | db.Save(&UserModel{ 256 | Name: "anonymous", 257 | Role: guestRole, 258 | }) 259 | 260 | q1User := &UserModel{} 261 | db.WithContext(context.Background()).Find(q1User, "Name = ?", "ktsivkov") 262 | q2User := &UserModel{} 263 | db.WithContext(context.Background()).Find(q2User, "Name = ?", "ktsivkov") 264 | 265 | fmt.Println(fmt.Sprintf("%+v", q1User)) 266 | fmt.Println(fmt.Sprintf("%+v", q2User)) 267 | } 268 | ``` 269 | 270 | ## Cacher Example (Memory) 271 | 272 | ```go 273 | package main 274 | 275 | import ( 276 | "context" 277 | "fmt" 278 | "sync" 279 | 280 | "github.com/go-gorm/caches/v4" 281 | "gorm.io/driver/sqlite" 282 | "gorm.io/gorm" 283 | ) 284 | 285 | type UserRoleModel struct { 286 | gorm.Model 287 | Name string `gorm:"unique"` 288 | } 289 | 290 | type UserModel struct { 291 | gorm.Model 292 | Name string 293 | RoleId uint 294 | Role *UserRoleModel `gorm:"foreignKey:role_id;references:id"` 295 | } 296 | 297 | type memoryCacher struct { 298 | store *sync.Map 299 | } 300 | 301 | func (c *memoryCacher) init() { 302 | if c.store == nil { 303 | c.store = &sync.Map{} 304 | } 305 | } 306 | 307 | func (c *memoryCacher) Get(ctx context.Context, key string, q *caches.Query[any]) (*caches.Query[any], error) { 308 | c.init() 309 | val, ok := c.store.Load(key) 310 | if !ok { 311 | return nil, nil 312 | } 313 | 314 | if err := q.Unmarshal(val.([]byte)); err != nil { 315 | return nil, err 316 | } 317 | 318 | return q, nil 319 | } 320 | 321 | func (c *memoryCacher) Store(ctx context.Context, key string, val *caches.Query[any]) error { 322 | c.init() 323 | res, err := val.Marshal() 324 | if err != nil { 325 | return err 326 | } 327 | 328 | c.store.Store(key, res) 329 | return nil 330 | } 331 | 332 | func (c *memoryCacher) Invalidate(ctx context.Context) error { 333 | c.store = &sync.Map{} 334 | return nil 335 | } 336 | 337 | func main() { 338 | db, _ := gorm.Open(sqlite.Open("gorm.db"), &gorm.Config{ 339 | AllowGlobalUpdate: true, 340 | }) 341 | 342 | cachesPlugin := &caches.Caches{Conf: &caches.Config{ 343 | Cacher: &memoryCacher{}, 344 | }} 345 | 346 | _ = db.Use(cachesPlugin) 347 | 348 | _ = db.AutoMigrate(&UserRoleModel{}) 349 | _ = db.AutoMigrate(&UserModel{}) 350 | 351 | db.Delete(&UserRoleModel{}) 352 | db.Delete(&UserModel{}) 353 | 354 | adminRole := &UserRoleModel{ 355 | Name: "Admin", 356 | } 357 | db.Save(adminRole) 358 | 359 | guestRole := &UserRoleModel{ 360 | Name: "Guest", 361 | } 362 | db.Save(guestRole) 363 | 364 | db.Save(&UserModel{ 365 | Name: "ktsivkov", 366 | Role: adminRole, 367 | }) 368 | 369 | db.Save(&UserModel{ 370 | Name: "anonymous", 371 | Role: guestRole, 372 | }) 373 | 374 | q1User := &UserModel{} 375 | db.WithContext(context.Background()).Find(q1User, "Name = ?", "ktsivkov") 376 | q2User := &UserModel{} 377 | db.WithContext(context.Background()).Find(q2User, "Name = ?", "ktsivkov") 378 | 379 | fmt.Println(fmt.Sprintf("%+v", q1User)) 380 | fmt.Println(fmt.Sprintf("%+v", q2User)) 381 | } 382 | ``` 383 | 384 | ## License 385 | 386 | MIT license. 387 | 388 | ## Easer 389 | The easer is an adjusted version of the [ServantGo](https://github.com/ktsivkov/servantgo) library to fit the needs of this plugin. 390 | -------------------------------------------------------------------------------- /cacher.go: -------------------------------------------------------------------------------- 1 | package caches 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | type Cacher interface { 8 | // Get impl should check if a specific key exists in the cache and return its value 9 | // look at Query.Marshal 10 | Get(ctx context.Context, key string, q *Query[any]) (*Query[any], error) 11 | // Store impl should store a cached representation of the val param 12 | // look at Query.Unmarshal 13 | Store(ctx context.Context, key string, val *Query[any]) error 14 | // Invalidate impl should invalidate all cached values 15 | // It will be called when INSERT / UPDATE / DELETE queries are sent to the DB 16 | Invalidate(ctx context.Context) error 17 | } 18 | -------------------------------------------------------------------------------- /cacher_test.go: -------------------------------------------------------------------------------- 1 | package caches 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "sync" 7 | ) 8 | 9 | type cacherMock struct { 10 | store *sync.Map 11 | } 12 | 13 | func (c *cacherMock) init() { 14 | if c.store == nil { 15 | c.store = &sync.Map{} 16 | } 17 | } 18 | 19 | func (c *cacherMock) Get(_ context.Context, key string, _ *Query[any]) (*Query[any], error) { 20 | c.init() 21 | val, ok := c.store.Load(key) 22 | if !ok { 23 | return nil, nil 24 | } 25 | 26 | return val.(*Query[any]), nil 27 | } 28 | 29 | func (c *cacherMock) Store(_ context.Context, key string, val *Query[any]) error { 30 | c.init() 31 | c.store.Store(key, val) 32 | return nil 33 | } 34 | 35 | func (c *cacherMock) Invalidate(context.Context) error { 36 | return nil 37 | } 38 | 39 | type cacherStoreErrorMock struct{} 40 | 41 | func (c *cacherStoreErrorMock) Get(context.Context, string, *Query[any]) (*Query[any], error) { 42 | return nil, nil 43 | } 44 | 45 | func (c *cacherStoreErrorMock) Store(context.Context, string, *Query[any]) error { 46 | return errors.New("store-error") 47 | } 48 | 49 | func (c *cacherStoreErrorMock) Invalidate(context.Context) error { 50 | return nil 51 | } 52 | 53 | type cacherGetErrorMock struct{} 54 | 55 | func (c *cacherGetErrorMock) Get(context.Context, string, *Query[any]) (*Query[any], error) { 56 | return nil, errors.New("get-error") 57 | } 58 | 59 | func (c *cacherGetErrorMock) Store(context.Context, string, *Query[any]) error { 60 | return nil 61 | } 62 | 63 | func (c *cacherGetErrorMock) Invalidate(context.Context) error { 64 | return nil 65 | } 66 | -------------------------------------------------------------------------------- /caches.go: -------------------------------------------------------------------------------- 1 | package caches 2 | 3 | import ( 4 | "sync" 5 | 6 | "gorm.io/gorm" 7 | ) 8 | 9 | type Caches struct { 10 | callbacks map[queryType]func(db *gorm.DB) 11 | Conf *Config 12 | 13 | queue *sync.Map 14 | } 15 | 16 | type Config struct { 17 | Easer bool 18 | Cacher Cacher 19 | } 20 | 21 | func (c *Caches) Name() string { 22 | return "gorm:caches" 23 | } 24 | 25 | func (c *Caches) Initialize(db *gorm.DB) error { 26 | if c.Conf == nil { 27 | c.Conf = &Config{ 28 | Easer: false, 29 | Cacher: nil, 30 | } 31 | } 32 | 33 | if c.Conf.Easer { 34 | c.queue = &sync.Map{} 35 | } 36 | 37 | callbacks := make(map[queryType]func(db *gorm.DB), 4) 38 | callbacks[uponQuery] = db.Callback().Query().Get("gorm:query") 39 | callbacks[uponCreate] = db.Callback().Create().Get("gorm:query") 40 | callbacks[uponUpdate] = db.Callback().Update().Get("gorm:query") 41 | callbacks[uponDelete] = db.Callback().Delete().Get("gorm:query") 42 | c.callbacks = callbacks 43 | 44 | if err := db.Callback().Query().Replace("gorm:query", c.query); err != nil { 45 | return err 46 | } 47 | 48 | if err := db.Callback().Create().Replace("gorm:query", c.getMutatorCb(uponCreate)); err != nil { 49 | return err 50 | } 51 | 52 | if err := db.Callback().Update().Replace("gorm:query", c.getMutatorCb(uponUpdate)); err != nil { 53 | return err 54 | } 55 | 56 | if err := db.Callback().Delete().Replace("gorm:query", c.getMutatorCb(uponDelete)); err != nil { 57 | return err 58 | } 59 | 60 | return nil 61 | } 62 | 63 | // query is a decorator around the default "gorm:query" callback 64 | // it takes care to both ease database load and cache results 65 | func (c *Caches) query(db *gorm.DB) { 66 | if c.Conf.Easer == false && c.Conf.Cacher == nil { 67 | c.callbacks[uponQuery](db) 68 | return 69 | } 70 | 71 | identifier := buildIdentifier(db) 72 | 73 | if c.checkCache(db, identifier) { 74 | return 75 | } 76 | 77 | c.ease(db, identifier) 78 | if db.Error != nil { 79 | return 80 | } 81 | 82 | c.storeInCache(db, identifier) 83 | if db.Error != nil { 84 | return 85 | } 86 | } 87 | 88 | // getMutatorCb returns a decorator which calls the Cacher's Invalidate method 89 | func (c *Caches) getMutatorCb(typ queryType) func(db *gorm.DB) { 90 | return func(db *gorm.DB) { 91 | if c.Conf.Cacher != nil { 92 | if err := c.Conf.Cacher.Invalidate(db.Statement.Context); err != nil { 93 | _ = db.AddError(err) 94 | } 95 | } 96 | if cb := c.callbacks[typ]; cb != nil { // By default, gorm has no callbacks associated with mutating behaviors 97 | cb(db) 98 | } 99 | } 100 | } 101 | 102 | func (c *Caches) ease(db *gorm.DB, identifier string) { 103 | if c.Conf.Easer == false { 104 | c.callbacks[uponQuery](db) 105 | return 106 | } 107 | 108 | res := ease(&queryTask{ 109 | id: identifier, 110 | db: db, 111 | queryCb: c.callbacks[uponQuery], 112 | }, c.queue).(*queryTask) 113 | 114 | if db.Error != nil { 115 | return 116 | } 117 | 118 | if res.db.Statement.Dest == db.Statement.Dest { 119 | return 120 | } 121 | 122 | detachedQuery := &Query[any]{ 123 | Dest: db.Statement.Dest, 124 | RowsAffected: db.Statement.RowsAffected, 125 | } 126 | 127 | easedQuery := &Query[any]{ 128 | Dest: res.db.Statement.Dest, 129 | RowsAffected: res.db.Statement.RowsAffected, 130 | } 131 | if err := easedQuery.copyTo(detachedQuery); err != nil { 132 | _ = db.AddError(err) 133 | } 134 | 135 | detachedQuery.replaceOn(db) 136 | } 137 | 138 | func (c *Caches) checkCache(db *gorm.DB, identifier string) bool { 139 | if c.Conf.Cacher != nil { 140 | res, err := c.Conf.Cacher.Get(db.Statement.Context, identifier, &Query[any]{ 141 | Dest: db.Statement.Dest, 142 | RowsAffected: db.Statement.RowsAffected, 143 | }) 144 | if err != nil { 145 | _ = db.AddError(err) 146 | } 147 | 148 | if res != nil { 149 | res.replaceOn(db) 150 | return true 151 | } 152 | } 153 | return false 154 | } 155 | 156 | func (c *Caches) storeInCache(db *gorm.DB, identifier string) { 157 | if c.Conf.Cacher != nil { 158 | err := c.Conf.Cacher.Store(db.Statement.Context, identifier, &Query[any]{ 159 | Dest: db.Statement.Dest, 160 | RowsAffected: db.Statement.RowsAffected, 161 | }) 162 | if err != nil { 163 | _ = db.AddError(err) 164 | } 165 | } 166 | } 167 | 168 | // queryType is used to mark callbacks 169 | type queryType int 170 | 171 | const ( 172 | uponQuery queryType = iota 173 | uponCreate 174 | uponUpdate 175 | uponDelete 176 | ) 177 | -------------------------------------------------------------------------------- /caches_test.go: -------------------------------------------------------------------------------- 1 | package caches 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | "sync" 7 | "sync/atomic" 8 | "testing" 9 | "time" 10 | 11 | "gorm.io/gorm" 12 | "gorm.io/gorm/utils/tests" 13 | ) 14 | 15 | type mockDest struct { 16 | Result string 17 | } 18 | 19 | func TestCaches_Name(t *testing.T) { 20 | caches := &Caches{ 21 | Conf: &Config{ 22 | Easer: true, 23 | Cacher: nil, 24 | }, 25 | } 26 | expectedName := "gorm:caches" 27 | if act := caches.Name(); act != expectedName { 28 | t.Errorf("Name on caches did not return the expected value, expected: %s, actual: %s", 29 | expectedName, act) 30 | } 31 | } 32 | 33 | func TestCaches_Initialize(t *testing.T) { 34 | t.Run("empty config", func(t *testing.T) { 35 | caches := &Caches{} 36 | db, err := gorm.Open(tests.DummyDialector{}, &gorm.Config{}) 37 | if err != nil { 38 | t.Fatalf("gorm initialization resulted into an unexpected error, %s", err.Error()) 39 | } 40 | 41 | originalQueryCb := db.Callback().Query().Get("gorm:query") 42 | 43 | err = db.Use(caches) 44 | if err != nil { 45 | t.Fatalf("gorm:caches loading resulted into an unexpected error, %s", err.Error()) 46 | } 47 | 48 | newQueryCallback := db.Callback().Query().Get("gorm:query") 49 | 50 | if db.Callback().Create().Get("gorm:query") == nil { 51 | t.Errorf("loading of gorm:caches, expected to replace the `gorm:query` callback for Create") 52 | } 53 | if db.Callback().Update().Get("gorm:query") == nil { 54 | t.Errorf("loading of gorm:caches, expected to replace the `gorm:query` callback for Update") 55 | } 56 | if db.Callback().Delete().Get("gorm:query") == nil { 57 | t.Errorf("loading of gorm:caches, expected to replace the `gorm:query` callback for Delete") 58 | } 59 | if _, found := caches.callbacks[uponQuery]; !found { 60 | t.Errorf("loading of gorm:caches, expected to store the default Query `gorm:query` callback in the callbacks map") 61 | } 62 | if _, found := caches.callbacks[uponCreate]; !found { 63 | t.Errorf("loading of gorm:caches, expected to store the default Create `gorm:query` callback in the callbacks map") 64 | } 65 | if _, found := caches.callbacks[uponUpdate]; !found { 66 | t.Errorf("loading of gorm:caches, expected to store the default Update `gorm:query` callback in the callbacks map") 67 | } 68 | if _, found := caches.callbacks[uponDelete]; !found { 69 | t.Errorf("loading of gorm:caches, expected to store the default Delete `gorm:query` callback in the callbacks map") 70 | } 71 | if reflect.ValueOf(originalQueryCb).Pointer() == reflect.ValueOf(newQueryCallback).Pointer() { 72 | t.Errorf("loading of gorm:caches, expected to replace the `gorm:query` callback for Query") 73 | } 74 | if reflect.ValueOf(newQueryCallback).Pointer() != reflect.ValueOf(caches.query).Pointer() { 75 | t.Errorf("loading of gorm:caches, expected to replace the `gorm:query` callback, with caches.query") 76 | } 77 | }) 78 | t.Run("config - easer", func(t *testing.T) { 79 | caches := &Caches{ 80 | Conf: &Config{ 81 | Easer: true, 82 | Cacher: nil, 83 | }, 84 | } 85 | db, err := gorm.Open(tests.DummyDialector{}, &gorm.Config{}) 86 | if err != nil { 87 | t.Fatalf("gorm initialization resulted into an unexpected error, %s", err.Error()) 88 | } 89 | 90 | originalQueryCb := db.Callback().Query().Get("gorm:query") 91 | 92 | err = db.Use(caches) 93 | if err != nil { 94 | t.Fatalf("gorm:caches loading resulted into an unexpected error, %s", err.Error()) 95 | } 96 | 97 | newQueryCallback := db.Callback().Query().Get("gorm:query") 98 | 99 | if reflect.ValueOf(originalQueryCb).Pointer() == reflect.ValueOf(newQueryCallback).Pointer() { 100 | t.Errorf("loading of gorm:caches, expected to replace the `gorm:query` callback") 101 | } 102 | 103 | if reflect.ValueOf(newQueryCallback).Pointer() != reflect.ValueOf(caches.query).Pointer() { 104 | t.Errorf("loading of gorm:caches, expected to replace the `gorm:query` callback, with caches.query") 105 | } 106 | 107 | if reflect.ValueOf(originalQueryCb).Pointer() != reflect.ValueOf(caches.callbacks[uponQuery]).Pointer() { 108 | t.Errorf("loading of gorm:caches, expected to load original `gorm:query` callback, to caches.queryCb") 109 | } 110 | }) 111 | } 112 | 113 | func TestCaches_query(t *testing.T) { 114 | t.Run("nothing enabled", func(t *testing.T) { 115 | conf := &Config{ 116 | Easer: false, 117 | Cacher: nil, 118 | } 119 | db, _ := gorm.Open(tests.DummyDialector{}, &gorm.Config{}) 120 | db.Statement.Dest = &mockDest{} 121 | caches := &Caches{ 122 | Conf: conf, 123 | callbacks: map[queryType]func(db *gorm.DB){ 124 | uponQuery: func(db *gorm.DB) { 125 | db.Statement.Dest.(*mockDest).Result = db.Statement.SQL.String() 126 | }, 127 | }, 128 | } 129 | 130 | // Set the query SQL into something specific 131 | exampleQuery := "demo-query" 132 | db.Statement.SQL.WriteString(exampleQuery) 133 | 134 | caches.query(db) // Execute the query 135 | 136 | if db.Error != nil { 137 | t.Fatalf("an unexpected error has occurred, %v", db.Error) 138 | } 139 | 140 | if db.Statement.Dest == nil { 141 | t.Fatal("no query result was set after caches Query was executed") 142 | } 143 | 144 | if res := db.Statement.Dest.(*mockDest); res.Result != exampleQuery { 145 | t.Errorf("the execution of the Query expected a result of `%s`, got `%s`", exampleQuery, res) 146 | } 147 | }) 148 | 149 | t.Run("easer only", func(t *testing.T) { 150 | conf := &Config{ 151 | Easer: true, 152 | Cacher: nil, 153 | } 154 | 155 | t.Run("one query", func(t *testing.T) { 156 | db, _ := gorm.Open(tests.DummyDialector{}, &gorm.Config{}) 157 | db.Statement.Dest = &mockDest{} 158 | caches := &Caches{ 159 | Conf: conf, 160 | 161 | queue: &sync.Map{}, 162 | callbacks: map[queryType]func(db *gorm.DB){ 163 | uponQuery: func(db *gorm.DB) { 164 | db.Statement.Dest.(*mockDest).Result = db.Statement.SQL.String() 165 | }, 166 | }, 167 | } 168 | 169 | // Set the query SQL into something specific 170 | exampleQuery := "demo-query" 171 | db.Statement.SQL.WriteString(exampleQuery) 172 | 173 | caches.query(db) // Execute the query 174 | 175 | if db.Error != nil { 176 | t.Fatalf("an unexpected error has occurred, %v", db.Error) 177 | } 178 | 179 | if db.Statement.Dest == nil { 180 | t.Fatal("no query result was set after caches Query was executed") 181 | } 182 | 183 | if res := db.Statement.Dest.(*mockDest); res.Result != exampleQuery { 184 | t.Errorf("the execution of the Query expected a result of `%s`, got `%s`", exampleQuery, res) 185 | } 186 | }) 187 | 188 | t.Run("two identical queries", func(t *testing.T) { 189 | t.Run("without error", func(t *testing.T) { 190 | var incr int32 191 | db1, _ := gorm.Open(tests.DummyDialector{}, &gorm.Config{}) 192 | db1.Statement.Dest = &mockDest{} 193 | db2, _ := gorm.Open(tests.DummyDialector{}, &gorm.Config{}) 194 | db2.Statement.Dest = &mockDest{} 195 | 196 | caches := &Caches{ 197 | Conf: conf, 198 | 199 | queue: &sync.Map{}, 200 | callbacks: map[queryType]func(db *gorm.DB){ 201 | uponQuery: func(db *gorm.DB) { 202 | time.Sleep(1 * time.Second) 203 | atomic.AddInt32(&incr, 1) 204 | 205 | db.Statement.Dest.(*mockDest).Result = fmt.Sprintf("%d", atomic.LoadInt32(&incr)) 206 | }, 207 | }, 208 | } 209 | 210 | // Set the queries' SQL into something specific 211 | exampleQuery := "demo-query" 212 | db1.Statement.SQL.WriteString(exampleQuery) 213 | db2.Statement.SQL.WriteString(exampleQuery) 214 | 215 | wg := &sync.WaitGroup{} 216 | wg.Add(2) 217 | go func() { 218 | caches.query(db1) // Execute the query 219 | wg.Done() 220 | }() 221 | go func() { 222 | time.Sleep(500 * time.Millisecond) // Execute the second query half a second later 223 | caches.query(db2) // Execute the query 224 | wg.Done() 225 | }() 226 | wg.Wait() 227 | 228 | if db1.Error != nil { 229 | t.Fatalf("an unexpected error has occurred, %v", db1.Error) 230 | } 231 | 232 | if db2.Error != nil { 233 | t.Fatalf("an unexpected error has occurred, %v", db2.Error) 234 | } 235 | 236 | if act := atomic.LoadInt32(&incr); act != 1 { 237 | t.Errorf("when executing two identical queries, expected to run %d time, but %d", 1, act) 238 | } 239 | }) 240 | }) 241 | 242 | t.Run("two different queries", func(t *testing.T) { 243 | var incr int32 244 | db1, _ := gorm.Open(tests.DummyDialector{}, &gorm.Config{}) 245 | db1.Statement.Dest = &mockDest{} 246 | db2, _ := gorm.Open(tests.DummyDialector{}, &gorm.Config{}) 247 | db2.Statement.Dest = &mockDest{} 248 | 249 | caches := &Caches{ 250 | Conf: conf, 251 | 252 | queue: &sync.Map{}, 253 | callbacks: map[queryType]func(db *gorm.DB){ 254 | uponQuery: func(db *gorm.DB) { 255 | time.Sleep(1 * time.Second) 256 | atomic.AddInt32(&incr, 1) 257 | 258 | db.Statement.Dest.(*mockDest).Result = fmt.Sprintf("%d", atomic.LoadInt32(&incr)) 259 | }, 260 | }, 261 | } 262 | 263 | // Set the queries' SQL into something specific 264 | exampleQuery1 := "demo-query-1" 265 | db1.Statement.SQL.WriteString(exampleQuery1) 266 | exampleQuery2 := "demo-query-2" 267 | db2.Statement.SQL.WriteString(exampleQuery2) 268 | 269 | wg := &sync.WaitGroup{} 270 | wg.Add(2) 271 | go func() { 272 | caches.query(db1) // Execute the query 273 | wg.Done() 274 | }() 275 | go func() { 276 | time.Sleep(500 * time.Millisecond) // Execute the second query half a second later 277 | caches.query(db2) // Execute the query 278 | wg.Done() 279 | }() 280 | wg.Wait() 281 | 282 | if db1.Error != nil { 283 | t.Fatalf("an unexpected error has occurred, %v", db1.Error) 284 | } 285 | 286 | if db2.Error != nil { 287 | t.Fatalf("an unexpected error has occurred, %v", db2.Error) 288 | } 289 | 290 | if act := atomic.LoadInt32(&incr); act != 2 { 291 | t.Errorf("when executing two identical queries, expected to run %d times, but %d", 2, act) 292 | } 293 | }) 294 | }) 295 | 296 | t.Run("cacher only", func(t *testing.T) { 297 | t.Run("one query", func(t *testing.T) { 298 | t.Run("with error", func(t *testing.T) { 299 | t.Run("store", func(t *testing.T) { 300 | db, _ := gorm.Open(tests.DummyDialector{}, &gorm.Config{}) 301 | db.Statement.Dest = &mockDest{} 302 | 303 | caches := &Caches{ 304 | Conf: &Config{ 305 | Easer: false, 306 | Cacher: &cacherStoreErrorMock{}, 307 | }, 308 | 309 | queue: &sync.Map{}, 310 | callbacks: map[queryType]func(db *gorm.DB){ 311 | uponQuery: func(db *gorm.DB) { 312 | db.Statement.Dest.(*mockDest).Result = db.Statement.SQL.String() 313 | }, 314 | }, 315 | } 316 | 317 | // Set the query SQL into something specific 318 | exampleQuery := "demo-query" 319 | db.Statement.SQL.WriteString(exampleQuery) 320 | 321 | caches.query(db) // Execute the query 322 | 323 | if db.Error == nil { 324 | t.Error("an error was expected, got none") 325 | } 326 | }) 327 | t.Run("get", func(t *testing.T) { 328 | db, _ := gorm.Open(tests.DummyDialector{}, &gorm.Config{}) 329 | db.Statement.Dest = &mockDest{} 330 | 331 | caches := &Caches{ 332 | Conf: &Config{ 333 | Easer: false, 334 | Cacher: &cacherGetErrorMock{}, 335 | }, 336 | 337 | queue: &sync.Map{}, 338 | callbacks: map[queryType]func(db *gorm.DB){ 339 | uponQuery: func(db *gorm.DB) { 340 | db.Statement.Dest.(*mockDest).Result = db.Statement.SQL.String() 341 | }, 342 | }, 343 | } 344 | 345 | // Set the query SQL into something specific 346 | exampleQuery := "demo-query" 347 | db.Statement.SQL.WriteString(exampleQuery) 348 | 349 | caches.query(db) // Execute the query 350 | 351 | if db.Error == nil { 352 | t.Error("an error was expected, got none") 353 | } 354 | }) 355 | }) 356 | t.Run("without error", func(t *testing.T) { 357 | db, _ := gorm.Open(tests.DummyDialector{}, &gorm.Config{}) 358 | db.Statement.Dest = &mockDest{} 359 | 360 | caches := &Caches{ 361 | Conf: &Config{ 362 | Easer: false, 363 | Cacher: &cacherMock{}, 364 | }, 365 | 366 | queue: &sync.Map{}, 367 | callbacks: map[queryType]func(db *gorm.DB){ 368 | uponQuery: func(db *gorm.DB) { 369 | db.Statement.Dest.(*mockDest).Result = db.Statement.SQL.String() 370 | }, 371 | }, 372 | } 373 | 374 | // Set the query SQL into something specific 375 | exampleQuery := "demo-query" 376 | db.Statement.SQL.WriteString(exampleQuery) 377 | 378 | caches.query(db) // Execute the query 379 | 380 | if db.Error != nil { 381 | t.Fatalf("an unexpected error has occurred, %v", db.Error) 382 | } 383 | 384 | if db.Statement.Dest == nil { 385 | t.Fatal("no query result was set after caches Query was executed") 386 | } 387 | 388 | if res := db.Statement.Dest.(*mockDest); res.Result != exampleQuery { 389 | t.Errorf("the execution of the Query expected a result of `%s`, got `%s`", exampleQuery, res) 390 | } 391 | }) 392 | }) 393 | 394 | t.Run("two identical queries", func(t *testing.T) { 395 | var incr int32 396 | db1, _ := gorm.Open(tests.DummyDialector{}, &gorm.Config{}) 397 | db1.Statement.Dest = &mockDest{} 398 | db2, _ := gorm.Open(tests.DummyDialector{}, &gorm.Config{}) 399 | db2.Statement.Dest = &mockDest{} 400 | 401 | caches := &Caches{ 402 | Conf: &Config{ 403 | Easer: false, 404 | Cacher: &cacherMock{}, 405 | }, 406 | 407 | queue: &sync.Map{}, 408 | callbacks: map[queryType]func(db *gorm.DB){ 409 | uponQuery: func(db *gorm.DB) { 410 | time.Sleep(1 * time.Second) 411 | atomic.AddInt32(&incr, 1) 412 | 413 | db.Statement.Dest.(*mockDest).Result = fmt.Sprintf("%d", atomic.LoadInt32(&incr)) 414 | }, 415 | }, 416 | } 417 | 418 | // Set the queries' SQL into something specific 419 | exampleQuery := "demo-query" 420 | db1.Statement.SQL.WriteString(exampleQuery) 421 | db2.Statement.SQL.WriteString(exampleQuery) 422 | 423 | caches.query(db1) 424 | caches.query(db2) 425 | 426 | if db1.Error != nil { 427 | t.Fatalf("an unexpected error has occurred, %v", db1.Error) 428 | } 429 | 430 | if db2.Error != nil { 431 | t.Fatalf("an unexpected error has occurred, %v", db2.Error) 432 | } 433 | 434 | if act := atomic.LoadInt32(&incr); act != 1 { 435 | t.Errorf("when executing two identical queries, expected to run %d time, but %d", 1, act) 436 | } 437 | }) 438 | 439 | t.Run("two different queries", func(t *testing.T) { 440 | var incr int32 441 | db1, _ := gorm.Open(tests.DummyDialector{}, &gorm.Config{}) 442 | db1.Statement.Dest = &mockDest{} 443 | db2, _ := gorm.Open(tests.DummyDialector{}, &gorm.Config{}) 444 | db2.Statement.Dest = &mockDest{} 445 | 446 | caches := &Caches{ 447 | Conf: &Config{ 448 | Easer: false, 449 | Cacher: &cacherMock{}, 450 | }, 451 | 452 | queue: &sync.Map{}, 453 | callbacks: map[queryType]func(db *gorm.DB){ 454 | uponQuery: func(db *gorm.DB) { 455 | time.Sleep(1 * time.Second) 456 | atomic.AddInt32(&incr, 1) 457 | 458 | db.Statement.Dest.(*mockDest).Result = fmt.Sprintf("%d", atomic.LoadInt32(&incr)) 459 | }, 460 | }, 461 | } 462 | 463 | // Set the queries' SQL into something specific 464 | exampleQuery1 := "demo-query-1" 465 | db1.Statement.SQL.WriteString(exampleQuery1) 466 | exampleQuery2 := "demo-query-2" 467 | db2.Statement.SQL.WriteString(exampleQuery2) 468 | 469 | caches.query(db1) 470 | if db1.Error != nil { 471 | t.Fatalf("an unexpected error has occurred, %v", db1.Error) 472 | } 473 | 474 | caches.query(db2) 475 | if db2.Error != nil { 476 | t.Fatalf("an unexpected error has occurred, %v", db2.Error) 477 | } 478 | 479 | if act := atomic.LoadInt32(&incr); act != 2 { 480 | t.Errorf("when executing two identical queries, expected to run %d times, but %d", 2, act) 481 | } 482 | }) 483 | }) 484 | } 485 | 486 | func TestCaches_getMutatorCb(t *testing.T) { 487 | testCases := map[string]queryType{ 488 | "upon create": uponCreate, 489 | "upon update": uponUpdate, 490 | "upon delete": uponDelete, 491 | } 492 | 493 | for testName, qt := range testCases { 494 | t.Run(testName, func(t *testing.T) { 495 | expectedDb, err := gorm.Open(tests.DummyDialector{}, &gorm.Config{}) 496 | if err != nil { 497 | t.Fatalf("gorm initialization resulted into an unexpected error, %s", err.Error()) 498 | } 499 | caches := &Caches{ 500 | Conf: &Config{ 501 | Cacher: &cacherMock{}, 502 | }, 503 | callbacks: map[queryType]func(db *gorm.DB){ 504 | qt: func(db *gorm.DB) { 505 | if act, exp := reflect.ValueOf(db).Pointer(), reflect.ValueOf(expectedDb).Pointer(); exp != act { 506 | t.Errorf("the mutator did not get called with the same db instance as expected: expected %d, actual %d", exp, act) 507 | } 508 | }, 509 | }, 510 | } 511 | mutator := caches.getMutatorCb(qt) 512 | if mutator == nil { 513 | t.Errorf("loading of gorm:caches, expected generate mutator but it did not") 514 | } 515 | mutator(expectedDb) 516 | }) 517 | } 518 | } 519 | -------------------------------------------------------------------------------- /easer.go: -------------------------------------------------------------------------------- 1 | package caches 2 | 3 | import ( 4 | "sync" 5 | ) 6 | 7 | func ease(t task, queue *sync.Map) task { 8 | eq := &eased{ 9 | task: t, 10 | wg: &sync.WaitGroup{}, 11 | } 12 | eq.wg.Add(1) 13 | defer eq.wg.Done() 14 | 15 | runner, ok := queue.LoadOrStore(t.GetId(), eq) 16 | if ok { 17 | et := runner.(*eased) 18 | et.wg.Wait() 19 | 20 | return et.task 21 | } 22 | 23 | eq.task.Run() 24 | queue.Delete(t.GetId()) 25 | return eq.task 26 | } 27 | 28 | type eased struct { 29 | task task 30 | wg *sync.WaitGroup 31 | } 32 | -------------------------------------------------------------------------------- /easer_test.go: -------------------------------------------------------------------------------- 1 | package caches 2 | 3 | import ( 4 | "sync" 5 | "testing" 6 | "time" 7 | ) 8 | 9 | func TestEase(t *testing.T) { 10 | t.Run("same queries", func(t *testing.T) { 11 | queue := &sync.Map{} 12 | 13 | myTask := &mockTask{ 14 | delay: 1 * time.Second, 15 | expRes: "expect-this", 16 | id: "unique-id", 17 | } 18 | myDupTask := &mockTask{ 19 | delay: 1 * time.Second, 20 | expRes: "not-this", 21 | id: "unique-id", 22 | } 23 | 24 | wg := &sync.WaitGroup{} 25 | wg.Add(2) 26 | 27 | var ( 28 | myTaskRes *mockTask 29 | myDupTaskRes *mockTask 30 | ) 31 | 32 | // Both queries will run at the same time, the second one will run half a second later 33 | go func() { 34 | myTaskRes = ease(myTask, queue).(*mockTask) 35 | wg.Done() 36 | }() 37 | go func() { 38 | time.Sleep(500 * time.Millisecond) 39 | myDupTaskRes = ease(myDupTask, queue).(*mockTask) 40 | wg.Done() 41 | }() 42 | wg.Wait() 43 | 44 | if myTaskRes.actRes != myTaskRes.expRes { 45 | t.Error("expected first query to be executed") 46 | } 47 | 48 | if myTaskRes.actRes != myDupTaskRes.actRes { 49 | t.Errorf("expected same result from both tasks, expected: %s, actual: %s", 50 | myTaskRes.actRes, myDupTaskRes.actRes) 51 | } 52 | }) 53 | 54 | t.Run("different queries", func(t *testing.T) { 55 | queue := &sync.Map{} 56 | 57 | myTask := &mockTask{ 58 | delay: 1 * time.Second, 59 | expRes: "expect-this", 60 | id: "unique-id", 61 | } 62 | myDupTask := &mockTask{ 63 | delay: 1 * time.Second, 64 | expRes: "not-this", 65 | id: "other-unique-id", 66 | } 67 | 68 | wg := &sync.WaitGroup{} 69 | wg.Add(2) 70 | 71 | var ( 72 | myTaskRes *mockTask 73 | myDupTaskRes *mockTask 74 | ) 75 | 76 | // Both queries will run at the same time, the second one will run half a second later 77 | go func() { 78 | myTaskRes = ease(myTask, queue).(*mockTask) 79 | wg.Done() 80 | }() 81 | go func() { 82 | time.Sleep(500 * time.Millisecond) 83 | myDupTaskRes = ease(myDupTask, queue).(*mockTask) 84 | wg.Done() 85 | }() 86 | wg.Wait() 87 | 88 | if myTaskRes.actRes != myTaskRes.expRes { 89 | t.Errorf("expected first query to be executed, expected: %s, actual: %s", 90 | myTaskRes.actRes, myTaskRes.expRes) 91 | } 92 | 93 | if myTaskRes.actRes == myDupTaskRes.actRes { 94 | t.Errorf("expected different result from both tasks, expected: %s, actual: %s", 95 | myTaskRes.actRes, myDupTaskRes.actRes) 96 | } 97 | 98 | if myDupTaskRes.actRes != myDupTaskRes.expRes { 99 | t.Error("expected second query to be executed") 100 | } 101 | }) 102 | } 103 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/go-gorm/caches/v4 2 | 3 | go 1.18 4 | 5 | require gorm.io/gorm v1.25.0 6 | 7 | require ( 8 | github.com/jinzhu/inflection v1.0.0 // indirect 9 | github.com/jinzhu/now v1.1.5 // indirect 10 | ) 11 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= 2 | github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= 3 | github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= 4 | github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= 5 | gorm.io/gorm v1.25.0 h1:+KtYtb2roDz14EQe4bla8CbQlmb9dN3VejSai3lprfU= 6 | gorm.io/gorm v1.25.0/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k= 7 | -------------------------------------------------------------------------------- /identifier.go: -------------------------------------------------------------------------------- 1 | package caches 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | "strings" 7 | 8 | "gorm.io/gorm/callbacks" 9 | 10 | "gorm.io/gorm" 11 | ) 12 | 13 | const IdentifierPrefix = "gorm-caches::" 14 | 15 | func buildIdentifier(db *gorm.DB) string { 16 | // Build query identifier, 17 | // for that reason we need to compile all arguments into a string 18 | // and concat them with the SQL query itself 19 | callbacks.BuildQuerySQL(db) 20 | query := db.Statement.SQL.String() 21 | queryArgs := valueToString(db.Statement.Vars) 22 | identifier := fmt.Sprintf("%s%s-%s", IdentifierPrefix, query, queryArgs) 23 | return identifier 24 | } 25 | 26 | func valueToString(value interface{}) string { 27 | valueOf := reflect.ValueOf(value) 28 | switch valueOf.Kind() { 29 | case reflect.Ptr: 30 | if valueOf.IsNil() { 31 | return "" 32 | } 33 | return valueToString(valueOf.Elem().Interface()) 34 | case reflect.Map: 35 | var sb strings.Builder 36 | sb.WriteString("{") 37 | for i, key := range valueOf.MapKeys() { 38 | if i > 0 { 39 | sb.WriteString(", ") 40 | } 41 | sb.WriteString(fmt.Sprintf("%s: %s", valueToString(key.Interface()), valueToString(valueOf.MapIndex(key).Interface()))) 42 | } 43 | sb.WriteString("}") 44 | return sb.String() 45 | case reflect.Slice: 46 | valueSlice := make([]interface{}, valueOf.Len()) 47 | for i := range valueSlice { 48 | valueSlice[i] = valueToString(valueOf.Index(i).Interface()) 49 | } 50 | return fmt.Sprintf("%v", valueSlice) 51 | default: 52 | return fmt.Sprintf("%v", value) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /identifier_test.go: -------------------------------------------------------------------------------- 1 | package caches 2 | 3 | import ( 4 | "testing" 5 | 6 | "gorm.io/gorm" 7 | ) 8 | 9 | func Test_buildIdentifier(t *testing.T) { 10 | db := &gorm.DB{} 11 | db.Statement = &gorm.Statement{} 12 | db.Statement.SQL.WriteString("TEST-SQL") 13 | db.Statement.Vars = append(db.Statement.Vars, "test", 123, 12.3, true, false, []string{"test", "me"}) 14 | 15 | actual := buildIdentifier(db) 16 | expected := "gorm-caches::TEST-SQL-[test 123 12.3 true false [test me]]" 17 | if actual != expected { 18 | t.Errorf("buildIdentifier expected to return `%s` but got `%s`", expected, actual) 19 | } 20 | } 21 | 22 | func Test_sliceToString(t *testing.T) { 23 | expected := "[test-val test-val 1 1 true true [test-val] [1] [true] [test-val] [1] [true] [test-val] [1] [true] [test-val] [1] [true] {test-val: test-val} {1: 1} {true: true} {test-val: test-val} {1: 1} {true: true} {test-val: test-val} {1: 1} {true: true} {test-val: test-val} {1: 1} {true: true}]" 24 | 25 | strVal := "test-val" 26 | intVal := 1 27 | boolVal := true 28 | sliceOfStr := []string{strVal} 29 | sliceOfInt := []int{intVal} 30 | sliceOfBool := []bool{boolVal} 31 | sliceOfPointerStr := []*string{&strVal} 32 | sliceOfPointerInt := []*int{&intVal} 33 | sliceOfPointerBool := []*bool{&boolVal} 34 | mapOfStr := map[string]string{strVal: strVal} 35 | mapOfInt := map[int]int{intVal: intVal} 36 | mapOfBool := map[bool]bool{boolVal: boolVal} 37 | mapOfPointerStr := map[*string]*string{&strVal: &strVal} 38 | mapOfPointerInt := map[*int]*int{&intVal: &intVal} 39 | mapOfPointerBool := map[*bool]*bool{&boolVal: &boolVal} 40 | actual := valueToString([]interface{}{ 41 | strVal, &strVal, intVal, // Primitives 42 | &intVal, boolVal, &boolVal, // Pointer passed primitives 43 | sliceOfStr, sliceOfInt, sliceOfBool, // Slices of primitives 44 | &sliceOfStr, &sliceOfInt, &sliceOfBool, // Pointer passed slices of primitives 45 | sliceOfPointerStr, sliceOfPointerInt, sliceOfPointerBool, // Slices of pointer primitives 46 | &sliceOfPointerStr, &sliceOfPointerInt, &sliceOfPointerBool, // Pointer passed slices of pointer primitives 47 | mapOfStr, mapOfInt, mapOfBool, // Map of primitives 48 | mapOfPointerStr, mapOfPointerInt, mapOfPointerBool, // Map of pointer primitives 49 | &mapOfStr, &mapOfInt, &mapOfBool, // Map of primitives 50 | &mapOfPointerStr, &mapOfPointerInt, &mapOfPointerBool, // Map of pointer primitives 51 | }) 52 | if expected != actual { 53 | t.Errorf("sliceToString expected to return `%s` but got `%s`", expected, actual) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /query.go: -------------------------------------------------------------------------------- 1 | package caches 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "gorm.io/gorm" 7 | ) 8 | 9 | type Query[T any] struct { 10 | Dest T 11 | RowsAffected int64 12 | } 13 | 14 | func (q *Query[T]) Marshal() ([]byte, error) { 15 | return json.Marshal(q) 16 | } 17 | 18 | func (q *Query[T]) Unmarshal(bytes []byte) error { 19 | return json.Unmarshal(bytes, q) 20 | } 21 | 22 | func (q *Query[T]) copyTo(dst *Query[any]) error { 23 | bytes, err := q.Marshal() 24 | if err != nil { 25 | return err 26 | } 27 | 28 | return dst.Unmarshal(bytes) 29 | } 30 | 31 | func (q *Query[T]) replaceOn(db *gorm.DB) { 32 | SetPointedValue(db.Statement.Dest, q.Dest) 33 | SetPointedValue(&db.Statement.RowsAffected, &q.RowsAffected) 34 | } 35 | -------------------------------------------------------------------------------- /query_task.go: -------------------------------------------------------------------------------- 1 | package caches 2 | 3 | import "gorm.io/gorm" 4 | 5 | type queryTask struct { 6 | id string 7 | db *gorm.DB 8 | queryCb func(db *gorm.DB) 9 | } 10 | 11 | func (q *queryTask) GetId() string { 12 | return q.id 13 | } 14 | 15 | func (q *queryTask) Run() { 16 | q.queryCb(q.db) 17 | } 18 | -------------------------------------------------------------------------------- /query_task_test.go: -------------------------------------------------------------------------------- 1 | package caches 2 | 3 | import ( 4 | "sync/atomic" 5 | "testing" 6 | 7 | "gorm.io/gorm" 8 | ) 9 | 10 | func TestQueryTask_GetId(t *testing.T) { 11 | task := &queryTask{ 12 | id: "myId", 13 | db: nil, 14 | queryCb: func(db *gorm.DB) { 15 | }, 16 | } 17 | 18 | if task.GetId() != "myId" { 19 | t.Error("GetId on queryTask returned an unexpected value") 20 | } 21 | } 22 | 23 | func TestQueryTask_Run(t *testing.T) { 24 | var inc int32 25 | task := &queryTask{ 26 | id: "myId", 27 | db: nil, 28 | queryCb: func(db *gorm.DB) { 29 | atomic.AddInt32(&inc, 1) 30 | }, 31 | } 32 | 33 | task.Run() 34 | 35 | if atomic.LoadInt32(&inc) != 1 { 36 | t.Error("Run on queryTask was expected to execute the callback specified once") 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /query_test.go: -------------------------------------------------------------------------------- 1 | package caches 2 | 3 | import ( 4 | "encoding/json" 5 | "reflect" 6 | "testing" 7 | 8 | "gorm.io/gorm" 9 | ) 10 | 11 | func TestQuery(t *testing.T) { 12 | t.Run("replaceOn", func(t *testing.T) { 13 | type User struct { 14 | Name string 15 | gorm.Model 16 | } 17 | db := &gorm.DB{ 18 | Statement: &gorm.Statement{ 19 | DB: &gorm.DB{}, 20 | Dest: &User{}, 21 | }, 22 | } 23 | 24 | expectedDestValue := &User{ 25 | Name: "ktsivkov", 26 | } 27 | expectedAffectedRows := int64(2) 28 | 29 | query := Query[*User]{ 30 | Dest: expectedDestValue, 31 | RowsAffected: expectedAffectedRows, 32 | } 33 | query.replaceOn(db) 34 | 35 | if !reflect.DeepEqual(db.Statement.Dest, expectedDestValue) { 36 | t.Fatalf("replaceOn was expected to replace the destination value with the one contained inside the query.") 37 | } 38 | 39 | if !reflect.DeepEqual(db.Statement.RowsAffected, expectedAffectedRows) { 40 | t.Fatalf("replaceOn was expected to replace the affected rows value with the one contained inside the query.") 41 | } 42 | }) 43 | 44 | t.Run("Marshal", func(t *testing.T) { 45 | type User struct { 46 | Name string 47 | gorm.Model 48 | } 49 | query := Query[User]{ 50 | Dest: User{ 51 | Name: "ktsivkov", 52 | }, 53 | RowsAffected: 2, 54 | } 55 | res, err := query.Marshal() 56 | if err != nil { 57 | t.Fatalf("Marshal resulted to an unexpected error. %v", err) 58 | } 59 | 60 | if !json.Valid(res) { 61 | t.Fatalf("Marshal returned an invalid json result. %v", err) 62 | } 63 | }) 64 | t.Run("Unmarshal", func(t *testing.T) { 65 | type User struct { 66 | Name string 67 | gorm.Model 68 | } 69 | marshalled := "{\"Dest\":{\"Name\":\"ktsivkov\",\"ID\":0,\"CreatedAt\":\"0001-01-01T00:00:00Z\",\"UpdatedAt\":\"0001-01-01T00:00:00Z\",\"DeletedAt\":null},\"RowsAffected\":2}" 70 | expected := Query[User]{ 71 | Dest: User{ 72 | Name: "ktsivkov", 73 | }, 74 | RowsAffected: 2, 75 | } 76 | var q Query[User] 77 | err := q.Unmarshal([]byte(marshalled)) 78 | if err != nil { 79 | t.Fatalf("Unmarshal resulted to an unexpected error. %v", err) 80 | } 81 | 82 | if !reflect.DeepEqual(expected, q) { 83 | t.Fatalf("Unmarshal was expected to shape the query into the expected, but failed.") 84 | } 85 | }) 86 | } 87 | -------------------------------------------------------------------------------- /reflection.go: -------------------------------------------------------------------------------- 1 | package caches 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "reflect" 7 | 8 | "gorm.io/gorm/schema" 9 | ) 10 | 11 | func SetPointedValue(dest interface{}, src interface{}) { 12 | reflect.ValueOf(dest).Elem().Set(reflect.ValueOf(src).Elem()) 13 | } 14 | 15 | func deepCopy(src, dst interface{}) error { 16 | srcVal := reflect.ValueOf(src) 17 | dstVal := reflect.ValueOf(dst) 18 | 19 | if srcVal.Kind() == reflect.Ptr { 20 | srcVal = srcVal.Elem() 21 | } 22 | 23 | if srcVal.Type() != dstVal.Elem().Type() { 24 | return errors.New("src and dst must be of the same type") 25 | } 26 | 27 | return copyValue(srcVal, dstVal.Elem()) 28 | } 29 | 30 | func copyValue(src, dst reflect.Value) error { 31 | switch src.Kind() { 32 | case reflect.Ptr: 33 | src = src.Elem() 34 | dst.Set(reflect.New(src.Type())) 35 | err := copyValue(src, dst.Elem()) 36 | if err != nil { 37 | return err 38 | } 39 | 40 | case reflect.Struct: 41 | for i := 0; i < src.NumField(); i++ { 42 | if src.Type().Field(i).PkgPath != "" { 43 | return fmt.Errorf("%w: %+v", schema.ErrUnsupportedDataType, src.Type().Field(i).Name) 44 | } 45 | err := copyValue(src.Field(i), dst.Field(i)) 46 | if err != nil { 47 | return err 48 | } 49 | } 50 | 51 | case reflect.Slice: 52 | newSlice := reflect.MakeSlice(src.Type(), src.Len(), src.Cap()) 53 | for i := 0; i < src.Len(); i++ { 54 | err := copyValue(src.Index(i), newSlice.Index(i)) 55 | if err != nil { 56 | return err 57 | } 58 | } 59 | dst.Set(newSlice) 60 | 61 | case reflect.Map: 62 | newMap := reflect.MakeMapWithSize(src.Type(), src.Len()) 63 | for _, key := range src.MapKeys() { 64 | value := src.MapIndex(key) 65 | newValue := reflect.New(value.Type()).Elem() 66 | err := copyValue(value, newValue) 67 | if err != nil { 68 | return err 69 | } 70 | newMap.SetMapIndex(key, newValue) 71 | } 72 | dst.Set(newMap) 73 | 74 | default: 75 | dst.Set(src) 76 | } 77 | 78 | return nil 79 | } 80 | -------------------------------------------------------------------------------- /reflection_test.go: -------------------------------------------------------------------------------- 1 | package caches 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | ) 7 | 8 | type unsupportedMockStruct struct { 9 | ExportedField string 10 | unexportedField string 11 | ExportedSliceField []string 12 | unexportedSliceField []string 13 | ExportedMapField map[string]string 14 | unexportedMapField map[string]string 15 | } 16 | 17 | type supportedMockStruct struct { 18 | ExportedField string 19 | ExportedSliceField []string 20 | ExportedMapField map[string]string 21 | } 22 | 23 | func Test_SetPointedValue(t *testing.T) { 24 | src := &struct { 25 | Name string 26 | }{ 27 | Name: "Test", 28 | } 29 | 30 | dest := &struct { 31 | Name string 32 | }{} 33 | 34 | SetPointedValue(dest, src) 35 | 36 | if !reflect.DeepEqual(src, dest) { 37 | t.Error("SetPointedValue was expected to point the dest to the source") 38 | } 39 | 40 | if dest.Name != src.Name { 41 | t.Errorf("src and dest were expected to have the same name, src.Name `%s`, dest.Name `%s`", src.Name, dest.Name) 42 | } 43 | } 44 | 45 | func Test_deepCopy(t *testing.T) { 46 | t.Run("struct", func(t *testing.T) { 47 | t.Run("supported", func(t *testing.T) { 48 | srcStruct := supportedMockStruct{ 49 | ExportedField: "exported field", 50 | ExportedSliceField: []string{"1st elem of an exported slice field", "2nd elem of an exported slice field"}, 51 | ExportedMapField: map[string]string{ 52 | "key1": "exported map elem", 53 | "key2": "exported map elem", 54 | }, 55 | } 56 | dstStruct := supportedMockStruct{} 57 | 58 | if err := deepCopy(srcStruct, &dstStruct); err != nil { 59 | t.Errorf("deepCopy returned an unexpected error %+v", err) 60 | } 61 | 62 | if !reflect.DeepEqual(srcStruct, dstStruct) { 63 | t.Errorf("deepCopy failed to copy structure: got %+v, want %+v", dstStruct, srcStruct) 64 | } 65 | }) 66 | t.Run("unsupported", func(t *testing.T) { 67 | srcStruct := unsupportedMockStruct{ 68 | ExportedField: "exported field", 69 | unexportedField: "unexported field", 70 | ExportedSliceField: []string{"1st elem of an exported slice field", "2nd elem of an exported slice field"}, 71 | unexportedSliceField: []string{"1st elem of an unexported slice field", "2nd elem of an unexported slice field"}, 72 | ExportedMapField: map[string]string{ 73 | "key1": "exported map elem", 74 | "key2": "exported map elem", 75 | }, 76 | unexportedMapField: map[string]string{ 77 | "key1": "unexported map elem", 78 | "key2": "unexported map elem", 79 | }, 80 | } 81 | dstStruct := unsupportedMockStruct{} 82 | 83 | if err := deepCopy(srcStruct, &dstStruct); err == nil { 84 | t.Error("deepCopy was expected to fail copying an structure with unexported fields") 85 | } 86 | }) 87 | }) 88 | 89 | t.Run("map", func(t *testing.T) { 90 | t.Run("map[string]string", func(t *testing.T) { 91 | srcMap := map[string]string{ 92 | "key1": "value1", 93 | "key2": "value2", 94 | } 95 | dstMap := make(map[string]string) 96 | 97 | if err := deepCopy(srcMap, &dstMap); err != nil { 98 | t.Errorf("deepCopy returned an unexpected error %+v", err) 99 | } 100 | 101 | if !reflect.DeepEqual(srcMap, dstMap) { 102 | t.Errorf("deepCopy failed to copy map: got %+v, want %+v", dstMap, srcMap) 103 | } 104 | }) 105 | 106 | t.Run("map[string]struct", func(t *testing.T) { 107 | srcMap := map[string]supportedMockStruct{ 108 | "key1": { 109 | ExportedField: "exported field", 110 | ExportedSliceField: []string{"1st elem of an exported slice field", "2nd elem of an exported slice field"}, 111 | ExportedMapField: map[string]string{ 112 | "key1": "exported map elem", 113 | "key2": "exported map elem", 114 | }, 115 | }, 116 | "key2": { 117 | ExportedField: "exported field", 118 | ExportedSliceField: []string{"1st elem of an exported slice field", "2nd elem of an exported slice field"}, 119 | ExportedMapField: map[string]string{ 120 | "key1": "exported map elem", 121 | "key2": "exported map elem", 122 | }, 123 | }, 124 | } 125 | dstMap := make(map[string]supportedMockStruct) 126 | 127 | if err := deepCopy(srcMap, &dstMap); err != nil { 128 | t.Errorf("deepCopy returned an unexpected error %+v", err) 129 | } 130 | 131 | if !reflect.DeepEqual(srcMap, dstMap) { 132 | t.Errorf("deepCopy failed to copy map: got %+v, want %+v", dstMap, srcMap) 133 | } 134 | }) 135 | }) 136 | 137 | t.Run("slice", func(t *testing.T) { 138 | t.Run("[]string", func(t *testing.T) { 139 | srcSlice := []string{"A", "B", "C"} 140 | dstSlice := make([]string, len(srcSlice)) 141 | 142 | if err := deepCopy(srcSlice, &dstSlice); err != nil { 143 | t.Errorf("deepCopy returned an unexpected error %+v", err) 144 | } 145 | 146 | if !reflect.DeepEqual(srcSlice, dstSlice) { 147 | t.Errorf("deepCopy failed to copy slice: got %+v, want %+v", dstSlice, srcSlice) 148 | } 149 | }) 150 | t.Run("[]struct", func(t *testing.T) { 151 | srcSlice := []supportedMockStruct{ 152 | { 153 | ExportedField: "exported field", 154 | ExportedSliceField: []string{"1st elem of an exported slice field", "2nd elem of an exported slice field"}, 155 | ExportedMapField: map[string]string{ 156 | "key1": "exported map elem", 157 | "key2": "exported map elem", 158 | }, 159 | }, { 160 | ExportedField: "exported field", 161 | ExportedSliceField: []string{"1st elem of an exported slice field", "2nd elem of an exported slice field"}, 162 | ExportedMapField: map[string]string{ 163 | "key1": "exported map elem", 164 | "key2": "exported map elem", 165 | }, 166 | }, { 167 | ExportedField: "exported field", 168 | ExportedSliceField: []string{"1st elem of an exported slice field", "2nd elem of an exported slice field"}, 169 | ExportedMapField: map[string]string{ 170 | "key1": "exported map elem", 171 | "key2": "exported map elem", 172 | }, 173 | }, 174 | } 175 | dstSlice := make([]supportedMockStruct, len(srcSlice)) 176 | 177 | if err := deepCopy(srcSlice, &dstSlice); err != nil { 178 | t.Errorf("deepCopy returned an unexpected error %+v", err) 179 | } 180 | 181 | if !reflect.DeepEqual(srcSlice, dstSlice) { 182 | t.Errorf("deepCopy failed to copy slice: got %+v, want %+v", dstSlice, srcSlice) 183 | } 184 | }) 185 | }) 186 | 187 | t.Run("pointer", func(t *testing.T) { 188 | srcStruct := &supportedMockStruct{ 189 | ExportedField: "exported field", 190 | ExportedSliceField: []string{"1st elem of an exported slice field", "2nd elem of an exported slice field"}, 191 | ExportedMapField: map[string]string{ 192 | "key1": "exported map elem", 193 | "key2": "exported map elem", 194 | }, 195 | } 196 | dstStruct := &supportedMockStruct{} 197 | 198 | if err := deepCopy(srcStruct, dstStruct); err != nil { 199 | t.Errorf("deepCopy returned an unexpected error %+v", err) 200 | } 201 | 202 | if !reflect.DeepEqual(srcStruct, dstStruct) { 203 | t.Errorf("deepCopy failed to copy structure: got %+v, want %+v", dstStruct, srcStruct) 204 | } 205 | }) 206 | 207 | t.Run("mismatched", func(t *testing.T) { 208 | src := "a string" 209 | dst := 123 210 | 211 | if err := deepCopy(src, &dst); err == nil { 212 | t.Error("deepCopy did not return an error when provided mismatched types") 213 | } 214 | }) 215 | } 216 | -------------------------------------------------------------------------------- /task.go: -------------------------------------------------------------------------------- 1 | package caches 2 | 3 | type task interface { 4 | GetId() string 5 | Run() 6 | } 7 | -------------------------------------------------------------------------------- /task_test.go: -------------------------------------------------------------------------------- 1 | package caches 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | type mockTask struct { 8 | delay time.Duration 9 | actRes string 10 | expRes string 11 | id string 12 | } 13 | 14 | func (q *mockTask) GetId() string { 15 | return q.id 16 | } 17 | 18 | func (q *mockTask) Run() { 19 | time.Sleep(q.delay) 20 | q.actRes = q.expRes 21 | } 22 | --------------------------------------------------------------------------------