├── .gitignore ├── .gitlab-ci.yml ├── LICENSE ├── README.md ├── api.go ├── cache.go ├── cache └── inMemoryCache.go ├── crud.go ├── errors.go ├── go.mod ├── go.sum ├── googlea2cff9e1091c15a0.html ├── main.go ├── mappers.go ├── request.go ├── resolver.go ├── tests ├── cache_test.go ├── integration_test.go └── setup.go └── utils.go /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, build with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | auth 15 | /.idea/.gitignore 16 | /.idea/go-firestorm.iml 17 | /.idea/misc.xml 18 | /.idea/modules.xml 19 | /.idea/vcs.xml 20 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | image: golang:1.11 2 | 3 | cache: 4 | paths: 5 | - .cache 6 | 7 | 8 | stages: 9 | - test 10 | - build 11 | 12 | before_script: 13 | - mkdir -p auth && touch auth/sn-prod.json 14 | - echo $FIREBASE_SERVICE_ACCOUNT > auth/sn-dev.json 15 | - mkdir -p .cache 16 | - export GOPATH="$CI_PROJECT_DIR/.cache" 17 | 18 | unit_tests: 19 | stage: test 20 | script: 21 | - go test ./... 22 | - go test -race -short ./... 23 | - go test -cover ./... 24 | 25 | build: 26 | stage: build 27 | script: 28 | - go build -i -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Jens Kjær Schødt 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 | [![pipeline status](https://gitlab.com/jens.schoedt/go-firestorm/badges/master/pipeline.svg)](https://gitlab.com/jens.schoedt/go-firestorm/commits/master) 2 | [![coverage report](https://gitlab.com/jens.schoedt/go-firestorm/badges/master/coverage.svg)](https://gitlab.com/jens.schoedt/go-firestorm/commits/master) 3 | [![Go Report Card](https://goreportcard.com/badge/github.com/jschoedt/go-firestorm)](https://goreportcard.com/report/github.com/jschoedt/go-firestorm) 4 | [![GoDoc](https://godoc.org/github.com/jschoedt/go-firestorm?status.svg)](https://godoc.org/github.com/jschoedt/go-firestorm) 5 | [![GitHub](https://img.shields.io/github/license/jschoedt/go-firestorm)](https://github.com/jschoedt/go-firestorm/blob/master/LICENSE) 6 | 7 | # go-firestorm 8 | Go ORM ([Object-relational mapping](https://en.wikipedia.org/wiki/Object-relational_mapping)) for [Google Cloud Firestore](https://cloud.google.com/firestore/). 9 | 10 | #### Goals 11 | 1. Easy to use 12 | 2. Non-intrusive 13 | 4. Non-exclusive 14 | 3. Fast 15 | 16 | #### Features 17 | - Basic CRUD operations 18 | - Search 19 | - Concurrent requests support (except when run in transactions) 20 | - Transactions 21 | - Nested transactions will reuse the first transaction (reads before writes as required by firestore) 22 | - Configurable auto load of references 23 | - Handles cyclic references 24 | - Sub collections 25 | - Supports embedded/anonymous structs 26 | - Supports unexported fields 27 | - Custom mappers between fields and types 28 | - Caching (session + second level) 29 | - Supports Google App Engine - 2. Gen (go version >= 1.11) 30 | 31 | 32 | ## Getting Started 33 | 34 | * [Prerequisites](#prerequisites) 35 | * [Basic CRUD example](#basic-crud-example) 36 | * [Search](#search) 37 | * [Concurrent requests](#concurrent-requests) 38 | * [Transactions](#transactions) 39 | * [Cache](#cache) 40 | * [Configurable auto load of references](#configurable-auto-load-of-references) 41 | * [Customize data mapping](#customize-data-mapping) 42 | * [Help](#help) 43 | 44 | 45 | #### Prerequisites 46 | 47 | This library only supports Firestore Native mode and not the old [Datastore](https://cloud.google.com/datastore/docs/firestore-or-datastore) mode. 48 | ``` 49 | go get -u github.com/jschoedt/go-firestorm 50 | ``` 51 | 52 | #### Setup 53 | 54 | 1. [Setup a Firestore client](https://firebase.google.com/docs/firestore/quickstart#set_up_your_development_environment) 55 | 2. Create a firestorm client and supply the names of the id and parent fields of your model structs. 56 | Parent is optional. The id field must be a string but can be called anything. 57 | ```go 58 | ... 59 | client, _ := app.Firestore(ctx) 60 | fsc := firestorm.New(client, "ID", "") 61 | ``` 62 | 3. Optional. For optimal caching to work consider adding the [CacheHandler](#cache). 63 | 64 | 65 | #### Basic CRUD example 66 | 67 | **Note:** Recursive Create/Delete is not supported and must be called on every entity. So to create an A->B relation. Create B first so the B.ID has been created and then create A. 68 | 69 | ```go 70 | type Car struct { 71 | ID string 72 | Make string 73 | Year time.Time 74 | } 75 | ``` 76 | ```go 77 | car := &Car{} 78 | car.Make = "Toyota" 79 | car.Year, _ = time.Parse(time.RFC3339, "2001-01-01T00:00:00.000Z") 80 | 81 | // Create the entity 82 | fsc.NewRequest().CreateEntities(ctx, car)() 83 | 84 | if car.ID == "" { 85 | t.Errorf("car should have an auto generated ID") 86 | } 87 | 88 | // Read the entity by ID 89 | otherCar := &Car{ID:car.ID} 90 | fsc.NewRequest().GetEntities(ctx, otherCar)() 91 | if otherCar.Make != "Toyota" { 92 | t.Errorf("car should have name: Toyota but was: %s", otherCar.Make) 93 | } 94 | if otherCar.Year != car.Year { 95 | t.Errorf("car should have same year: %s", otherCar.Year) 96 | } 97 | 98 | // Update the entity 99 | car.Make = "Jeep" 100 | fsc.NewRequest().UpdateEntities(ctx, car)() 101 | 102 | otherCar := &Car{ID:car.ID} 103 | fsc.NewRequest().GetEntities(ctx, otherCar)() 104 | if otherCar.Make != "Jeep" { 105 | t.Errorf("car should have name: Jeep but was: %s", otherCar.Make) 106 | } 107 | 108 | // Delete the entity 109 | fsc.NewRequest().DeleteEntities(ctx, car)() 110 | 111 | otherCar = &Car{ID:car.ID} 112 | if err := fsc.NewRequest().GetEntities(ctx, otherCar)(); err == nil { 113 | t.Errorf("We expect a NotFoundError") 114 | } 115 | ``` 116 | [More examples](https://github.com/jschoedt/go-firestorm/blob/master/tests/integration_test.go) 117 | 118 | #### Search 119 | Create a query using the firebase client 120 | 121 | ```go 122 | car := &Car{} 123 | car.ID = "testID" 124 | car.Make = "Toyota" 125 | 126 | fsc.NewRequest().CreateEntities(ctx, car)() 127 | 128 | query := fsc.Client.Collection("Car").Where("make", "==", "Toyota") 129 | 130 | result := make([]Car, 0) 131 | if err := fsc.NewRequest().QueryEntities(ctx, query, &result)(); err != nil { 132 | t.Errorf("car was not found by search: %v", car) 133 | } 134 | 135 | if result[0].ID != car.ID || result[0].Make != car.Make { 136 | t.Errorf("entity did not match original entity : %v", result) 137 | } 138 | ``` 139 | [More examples](https://github.com/jschoedt/go-firestorm/blob/master/tests/integration_test.go) 140 | 141 | #### Concurrent requests 142 | All CRUD operations are asynchronous and return a future func that when called will block until the operation is done. 143 | 144 | **NOTE:** the state of the entities is undefined until the future func returns. 145 | ```go 146 | car := &Car{Make:"Toyota"} 147 | 148 | // Create the entity which returns a future func 149 | future := fsc.NewRequest().CreateEntities(ctx, car) 150 | 151 | // ID is not set 152 | if car.ID != "" { 153 | t.Errorf("car ID should not have been set yet") 154 | } 155 | 156 | // do some more work 157 | 158 | // blocks and waits for the database to finish 159 | future() 160 | 161 | // now the car has been saved and the ID has been set 162 | if car.ID == "" { 163 | t.Errorf("car should have an auto generated ID now") 164 | } 165 | ``` 166 | [More examples](https://github.com/jschoedt/go-firestorm/blob/master/tests/integration_test.go) 167 | 168 | #### Transactions 169 | Transactions are simply done in a function using the transaction context 170 | 171 | ```go 172 | car := &Car{Make: "Toyota"} 173 | 174 | fsc.DoInTransaction(ctx, func(transCtx context.Context) error { 175 | 176 | // Create the entity in the transaction using the transCtx 177 | fsc.NewRequest().CreateEntities(transCtx, car)() 178 | 179 | // Using the transCtx we can load the entity as it is saved in the session context 180 | otherCar := &Car{ID:car.ID} 181 | fsc.NewRequest().GetEntities(transCtx, otherCar)() 182 | if otherCar.Make != car.Make { 183 | t.Errorf("The car should have been saved in the transaction context") 184 | } 185 | 186 | // Loading using an other context (request) will fail as the car is not created until the func returns successfully 187 | if err := fsc.NewRequest().GetEntities(ctx, &Car{ID:car.ID})(); err == nil { 188 | t.Errorf("We expect a NotFoundError") 189 | } 190 | }) 191 | 192 | // Now we can load the car as the transaction has been committed 193 | otherCar := &Car{ID:car.ID} 194 | fsc.NewRequest().GetEntities(ctx, otherCar)() 195 | if otherCar.Make != "Toyota" { 196 | t.Errorf("car should have name: Toyota but was: %s", otherCar.Make) 197 | } 198 | 199 | ``` 200 | 201 | [More examples](https://github.com/jschoedt/go-firestorm/blob/master/tests/integration_test.go) 202 | 203 | #### Cache 204 | Firestorm supports adding a session cache to the context. 205 | The session cache only caches entities that are loaded within the same request. 206 | ```go 207 | # add it to a single handler: 208 | http.HandleFunc("/", firestorm.CacheHandler(otherHandler)) 209 | # or add it to the routing chain (for gorilla/mux, go-chi etc.): 210 | r.Use(firestorm.CacheMiddleware) 211 | ``` 212 | 213 | To add a second level cache (such as Redis or memcache) the Cache interface needs to be implemented and added to the client: 214 | ```go 215 | fsc.SetCache(c) 216 | ``` 217 | 218 | Firestore will first try to fetch an entity from the session cache. If it is not found it will try the second level cache. 219 | 220 | #### Configurable auto load of references 221 | 222 | Use the ```req.SetLoadPaths("fieldName")``` to auto load a particular field or ```req.SetLoadPaths(firestorm.AllEntities)``` to load all fields. 223 | 224 | Load an entity path by adding multiple paths eg.: path->to->field 225 | 226 | ```go 227 | fsc.NewRequest().SetLoadPaths("path", "path.to", "path.to.field").GetEntities(ctx, car)() 228 | ``` 229 | 230 | [More examples](https://github.com/jschoedt/go-firestorm/blob/master/tests/integration_test.go) 231 | 232 | #### Customize data mapping 233 | This library uses [go-structmapper](https://github.com/jschoedt/go-structmapper) for mapping values between Firestore and structs. The mapping can be customized by setting the 234 | mappers: 235 | 236 | ```go 237 | fsc.MapToDB = mapper.New() 238 | fsc.MapFromDB = mapper.New() 239 | ``` 240 | 241 | #### Help 242 | 243 | Help is provided in the [go-firestorm User Group](https://groups.google.com/forum/?fromgroups#!forum/go-firestorm) 244 | -------------------------------------------------------------------------------- /api.go: -------------------------------------------------------------------------------- 1 | package firestorm 2 | 3 | import ( 4 | "cloud.google.com/go/firestore" 5 | "context" 6 | "errors" 7 | "log" 8 | "reflect" 9 | "strings" 10 | "sync" 11 | ) 12 | 13 | var transCacheKey = contextKey("transactionCache") 14 | 15 | // DoInTransaction wraps any updates that needs to run in a transaction. 16 | // Use the transaction context tctx for any calls that need to be part of the transaction. 17 | // Do reads before writes as required by firestore 18 | func (fsc *FSClient) DoInTransaction(ctx context.Context, f func(tctx context.Context) error) error { 19 | // if nested transaction - reuse existing transaction and cache 20 | if _, ok := getTransaction(ctx); ok { 21 | return f(ctx) 22 | } 23 | err := fsc.Client.RunTransaction(ctx, func(ctx context.Context, t *firestore.Transaction) error { 24 | // add a new cache to context 25 | cache := newDefaultCache() 26 | tctx := context.WithValue(ctx, transactionCtxKey, t) 27 | tctx = context.WithValue(tctx, SessionCacheKey, make(map[string]EntityMap)) 28 | tctx = context.WithValue(tctx, transCacheKey, newCacheWrapper(fsc.Client, cache, nil)) 29 | 30 | // do the updates 31 | if err := f(tctx); err != nil { 32 | return err 33 | } 34 | 35 | // update cache with transaction cache. For now we just delete all modified keys 36 | if err := fsc.getCache(ctx).SetMulti(ctx, cache.getSetRec(tctx)); err != nil { 37 | log.Printf("Could not set values in cache: %#v", err) 38 | } 39 | if err := fsc.getCache(ctx).DeleteMulti(ctx, cache.getDeleteRec(tctx)); err != nil { 40 | log.Printf("Could not delete keys from cache: %#v", err) 41 | } 42 | 43 | return nil 44 | }) 45 | return err 46 | } 47 | 48 | func (fsc *FSClient) getEntities(ctx context.Context, req *Request, sliceVal reflect.Value) func() ([]interface{}, error) { 49 | slice := sliceVal 50 | result := make([]interface{}, 0, slice.Len()) 51 | asyncFunc := func() error { 52 | var nfErr error 53 | refs := make([]*firestore.DocumentRef, slice.Len()) 54 | for i := 0; i < slice.Len(); i++ { 55 | refs[i] = req.ToRef(slice.Index(i).Interface()) 56 | } 57 | crefs, err := fsc.getCachedEntities(ctx, refs) 58 | if err != nil { 59 | return err 60 | } 61 | 62 | resolver := newResolver(fsc, req.loadPaths...) 63 | res, err := resolver.ResolveCacheRef(ctx, crefs) 64 | 65 | if err != nil { 66 | if err, ok := err.(NotFoundError); ok { 67 | nfErr = err 68 | } else { 69 | return err 70 | } 71 | } 72 | 73 | for i, v := range res { 74 | if len(v) > 0 { 75 | fsc.MapFromDB.MapToStruct(v, slice.Index(i).Interface()) 76 | result = append(result, slice.Index(i).Interface()) 77 | } 78 | } 79 | return nfErr 80 | } 81 | af := runAsync(ctx, asyncFunc) 82 | return func() (entities []interface{}, e error) { 83 | err := af() 84 | return result, err 85 | } 86 | } 87 | 88 | func (fsc *FSClient) getCachedEntities(ctx context.Context, refs []*firestore.DocumentRef) ([]cacheRef, error) { 89 | res := make([]cacheRef, len(refs)) 90 | load := make([]*firestore.DocumentRef, 0, len(refs)) 91 | 92 | // check cache and collect refs not loaded yet 93 | if getMulti, err := fsc.getCache(ctx).GetMulti(ctx, refs); err != nil { 94 | log.Printf("Cache error but continue: %+v", err) 95 | load = append(load, refs...) 96 | } else { 97 | for i, ref := range refs { 98 | if e, ok := getMulti[ref]; ok { 99 | res[i] = e // we found it 100 | } else { 101 | load = append(load, ref) 102 | } 103 | } 104 | } 105 | 106 | // get the unloaded refs 107 | docs, err := getAll(ctx, fsc.Client, load) 108 | if err != nil { 109 | return nil, err 110 | } 111 | 112 | // fill the res slice with the DB results 113 | i := 0 114 | multi := make(map[string]EntityMap, len(docs)) 115 | for _, doc := range docs { 116 | ref := newCacheRef(doc.Data(), doc.Ref) 117 | multi[doc.Ref.Path] = doc.Data() 118 | for _, v := range res[i:] { 119 | if v.Ref == nil { 120 | res[i] = ref 121 | i++ 122 | break 123 | } 124 | i++ 125 | } 126 | } 127 | if err = fsc.getCache(ctx).SetMulti(ctx, multi); err != nil { 128 | log.Printf("Cache error but continue: %+v", err) 129 | } 130 | return res, nil 131 | } 132 | 133 | func (fsc *FSClient) queryEntities(ctx context.Context, req *Request, p firestore.Query, toSlicePtr interface{}) FutureFunc { 134 | asyncFunc := func() error { 135 | docs, err := query(ctx, p) 136 | if err != nil { 137 | return err 138 | } 139 | multi := make(map[string]EntityMap, len(docs)) 140 | for _, doc := range docs { 141 | multi[doc.Ref.Path] = doc.Data() 142 | } 143 | if err = fsc.getCache(ctx).SetMulti(ctx, multi); err != nil { 144 | log.Printf("Cache error but continue: %+v", err) 145 | } 146 | resolver := newResolver(fsc, req.loadPaths...) 147 | res, err := resolver.ResolveDocs(ctx, docs) 148 | if err != nil { 149 | return err 150 | } 151 | return fsc.toEntities(ctx, res, toSlicePtr) 152 | } 153 | return runAsync(ctx, asyncFunc) 154 | } 155 | 156 | func (fsc *FSClient) createEntity(ctx context.Context, req *Request, entity interface{}) FutureFunc { 157 | asyncFunc := func() error { 158 | m, err := fsc.MapToDB.StructToMap(entity) 159 | if err != nil { 160 | return err 161 | } 162 | 163 | ref := req.ToRef(entity) 164 | // if we need a fixed ID use that 165 | if req.GetID(entity) == "" { 166 | ref = req.ToCollection(entity).NewDoc() // otherwise create new id 167 | req.SetID(entity, ref.ID) 168 | } 169 | req.mapperFunc(m) 170 | if err := create(ctx, ref, m); err != nil { 171 | return err 172 | } 173 | if err := fsc.getCache(ctx).Set(ctx, ref.Path, m); err != nil { 174 | log.Printf("Cache error but continue: %+v", err) 175 | } 176 | return nil 177 | } 178 | return runAsync(ctx, asyncFunc) 179 | } 180 | 181 | func (fsc *FSClient) createEntities(ctx context.Context, req *Request, sliceVal reflect.Value) FutureFunc { 182 | asyncFunc := func() error { 183 | slice := sliceVal 184 | futures := make([]FutureFunc, slice.Len()) 185 | var errs []string 186 | 187 | // kick off all updates and collect futures 188 | for i := 0; i < slice.Len(); i++ { 189 | futures[i] = fsc.createEntity(ctx, req, slice.Index(i).Interface()) 190 | } 191 | 192 | // wait for all futures to finish 193 | for _, f := range futures { 194 | if err := f(); err != nil { 195 | errs = append(errs, err.Error()) 196 | } 197 | } 198 | 199 | // check the errors 200 | if len(errs) > 0 { 201 | return errors.New(strings.Join(errs, "\n")) 202 | } 203 | return nil 204 | } 205 | return runAsync(ctx, asyncFunc) 206 | } 207 | 208 | func (fsc *FSClient) updateEntity(ctx context.Context, req *Request, entity interface{}) FutureFunc { 209 | asyncFunc := func() error { 210 | m, err := fsc.MapToDB.StructToMap(entity) 211 | if err != nil { 212 | return err 213 | } 214 | 215 | ref := req.ToRef(entity) 216 | req.mapperFunc(m) 217 | if err := set(ctx, ref, m); err != nil { 218 | return err 219 | } 220 | if err := fsc.getCache(ctx).Set(ctx, ref.Path, m); err != nil { 221 | log.Printf("Cache error but continue: %+v", err) 222 | } 223 | return nil 224 | } 225 | return runAsync(ctx, asyncFunc) 226 | } 227 | 228 | func (fsc *FSClient) updateEntities(ctx context.Context, req *Request, sliceVal reflect.Value) FutureFunc { 229 | asyncFunc := func() error { 230 | slice := sliceVal 231 | futures := make([]FutureFunc, slice.Len()) 232 | var errs []string 233 | 234 | // kick off all updates and collect futures 235 | for i := 0; i < slice.Len(); i++ { 236 | futures[i] = fsc.updateEntity(ctx, req, slice.Index(i).Interface()) 237 | } 238 | 239 | // wait for all futures to finish 240 | for _, f := range futures { 241 | if err := f(); err != nil { 242 | errs = append(errs, err.Error()) 243 | } 244 | } 245 | 246 | // check the errors 247 | if len(errs) > 0 { 248 | return errors.New(strings.Join(errs, "\n")) 249 | } 250 | return nil 251 | } 252 | return runAsync(ctx, asyncFunc) 253 | } 254 | 255 | func (fsc *FSClient) deleteEntity(ctx context.Context, req *Request, entity interface{}) FutureFunc { 256 | asyncFunc := func() error { 257 | ref := req.ToRef(entity) 258 | if err := del(ctx, ref); err != nil { 259 | return err 260 | } 261 | if err := fsc.getCache(ctx).Delete(ctx, ref.Path); err != nil { 262 | log.Printf("Cache error but continue: %+v", err) 263 | } 264 | return nil 265 | } 266 | return runAsync(ctx, asyncFunc) 267 | } 268 | 269 | func (fsc *FSClient) deleteEntities(ctx context.Context, req *Request, sliceVal reflect.Value) FutureFunc { 270 | asyncFunc := func() error { 271 | slice := sliceVal 272 | futures := make([]FutureFunc, slice.Len()) 273 | var errs []string 274 | 275 | // kick off all updates and collect futures 276 | for i := 0; i < slice.Len(); i++ { 277 | futures[i] = fsc.deleteEntity(ctx, req, slice.Index(i).Interface()) 278 | } 279 | 280 | // wait for all futures to finish 281 | for _, f := range futures { 282 | if err := f(); err != nil { 283 | errs = append(errs, err.Error()) 284 | } 285 | } 286 | 287 | // check the errors 288 | if len(errs) > 0 { 289 | return errors.New(strings.Join(errs, "\n")) 290 | } 291 | return nil 292 | } 293 | return runAsync(ctx, asyncFunc) 294 | } 295 | 296 | type asyncFunc func() error 297 | 298 | // FutureFunc is a function that when called blocks until the result is ready 299 | type FutureFunc func() error 300 | 301 | func runAsync(ctx context.Context, fun asyncFunc) FutureFunc { 302 | if _, ok := getTransaction(ctx); ok { 303 | // transactions are not thread safe so just execute the func 304 | //================== 305 | //WARNING: DATA RACE 306 | //Read at 0x00c0004bde90 by goroutine 99: 307 | // cloud.google.com/go/firestore.(*Transaction).addWrites() 308 | // /home/jens/go/pkg/mod/cloud.google.com/go@v0.28.0/firestore/transaction.go:270 +0x124 309 | return FutureFunc(fun) 310 | } 311 | 312 | var err error 313 | var wg sync.WaitGroup 314 | wg.Add(1) 315 | 316 | go func() { 317 | defer wg.Done() 318 | err = fun() 319 | }() 320 | 321 | return func() error { 322 | wg.Wait() 323 | return err 324 | } 325 | } 326 | -------------------------------------------------------------------------------- /cache.go: -------------------------------------------------------------------------------- 1 | package firestorm 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "log" 7 | "net/http" 8 | "reflect" 9 | "strings" 10 | "sync" 11 | 12 | "cloud.google.com/go/firestore" 13 | ) 14 | 15 | var ( 16 | // SessionCacheKey is the key for the session map in the context 17 | SessionCacheKey = contextKey("sessionCache") 18 | // ErrCacheMiss returned on a cache miss 19 | ErrCacheMiss = errors.New("not found in cache") 20 | logOnce sync.Once 21 | ) 22 | 23 | const cacheElement = "_cacheElement" 24 | const cacheSlice = "_cacheSlice" 25 | 26 | // CacheHandler should be used on the mux chain to support session cache. 27 | // So getting the same entity several times will only generate on DB hit 28 | func CacheHandler(next http.HandlerFunc) http.HandlerFunc { 29 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 30 | ctx := context.WithValue(r.Context(), SessionCacheKey, make(map[string]EntityMap)) 31 | next.ServeHTTP(w, r.WithContext(ctx)) 32 | }) 33 | } 34 | 35 | // CacheMiddleware can be used as a CacheHandler middleware for popular routing frameworks 36 | // e.g. gorilla/mux, go-chi, ... 37 | // r.Use(firestorm.CacheMiddleware) 38 | func CacheMiddleware(next http.Handler) http.Handler { 39 | return CacheHandler(next.ServeHTTP) 40 | } 41 | 42 | // Cache can be used to implement custom caching 43 | type Cache interface { 44 | Get(ctx context.Context, key string) (EntityMap, error) 45 | GetMulti(ctx context.Context, keys []string) (map[string]EntityMap, error) 46 | Set(ctx context.Context, key string, item EntityMap) error 47 | SetMulti(ctx context.Context, items map[string]EntityMap) error 48 | Delete(ctx context.Context, key string) error 49 | DeleteMulti(ctx context.Context, keys []string) error 50 | } 51 | 52 | type cacheWrapper struct { 53 | client *firestore.Client 54 | first Cache 55 | second Cache 56 | } 57 | 58 | func newCacheWrapper(client *firestore.Client, first Cache, second Cache) *cacheWrapper { 59 | cw := &cacheWrapper{} 60 | cw.client = client 61 | cw.first = first 62 | cw.second = second 63 | return cw 64 | } 65 | 66 | func (c *cacheWrapper) convertToCacheRef(m EntityMap, ref *firestore.DocumentRef) cacheRef { 67 | c.makeUnCachable(m) 68 | return newCacheRef(m, ref) 69 | } 70 | 71 | func (c *cacheWrapper) Get(ctx context.Context, ref *firestore.DocumentRef) (cacheRef, error) { 72 | m, err := c.first.Get(ctx, ref.Path) 73 | if err == ErrCacheMiss && c.second != nil { 74 | m, err = c.second.Get(ctx, ref.Path) 75 | } 76 | 77 | //log.Printf("Get: ID: %v - %+v\n", ref.Path, m) 78 | return c.convertToCacheRef(m, ref), err 79 | } 80 | 81 | func (c *cacheWrapper) GetMulti(ctx context.Context, refs []*firestore.DocumentRef) (map[*firestore.DocumentRef]cacheRef, error) { 82 | keys := make([]string, 0, len(refs)) 83 | keyToRef := make(map[string]*firestore.DocumentRef, len(refs)) 84 | result := make(map[*firestore.DocumentRef]cacheRef, len(refs)) 85 | 86 | for _, ref := range refs { 87 | keys = append(keys, ref.Path) 88 | keyToRef[ref.Path] = ref 89 | } 90 | 91 | first, err := c.first.GetMulti(ctx, keys) 92 | if err != nil { 93 | return nil, err 94 | } 95 | 96 | if c.second == nil { 97 | for key, val := range first { 98 | ref := keyToRef[key] 99 | result[ref] = c.convertToCacheRef(val, ref) // update result with first level 100 | } 101 | return result, nil 102 | } 103 | 104 | // deep && c.second != nil - find remaining 105 | var remaining []string 106 | for _, key := range keys { 107 | if val, ok := first[key]; ok { 108 | ref := keyToRef[key] 109 | result[ref] = c.convertToCacheRef(val, ref) // update result with first level 110 | } else { 111 | remaining = append(remaining, key) 112 | } 113 | } 114 | // get the diff list 115 | if second, err := c.second.GetMulti(ctx, remaining); err != nil { 116 | return nil, err 117 | } else { 118 | for key, elm := range second { 119 | ref := keyToRef[key] 120 | result[ref] = c.convertToCacheRef(elm, ref) // update result with first level 121 | } 122 | } 123 | 124 | return result, nil 125 | } 126 | 127 | func (c *cacheWrapper) Set(ctx context.Context, key string, item map[string]interface{}) error { 128 | //log.Printf("Set: ID: %v - %+v\n", key, item) 129 | c.makeCachable(item) 130 | if err := c.first.Set(ctx, key, item); err != nil { 131 | return err 132 | } 133 | if c.second != nil { 134 | return c.second.Set(ctx, key, item) 135 | } 136 | return nil 137 | } 138 | 139 | func (c *cacheWrapper) SetMulti(ctx context.Context, items map[string]EntityMap) error { 140 | if len(items) == 0 { 141 | return nil 142 | } 143 | //log.Printf("Set multi: %+v\n", items) 144 | cache := make(map[string]EntityMap, len(items)) 145 | for k, v := range items { 146 | c.makeCachable(v) 147 | cache[k] = v 148 | } 149 | if err := c.first.SetMulti(ctx, cache); err != nil { 150 | return err 151 | } 152 | if c.second != nil { 153 | return c.second.SetMulti(ctx, cache) 154 | } 155 | return nil 156 | } 157 | 158 | func (c *cacheWrapper) Delete(ctx context.Context, key string) error { 159 | if err := c.first.Delete(ctx, key); err != nil { 160 | return err 161 | } 162 | if c.second != nil { 163 | return c.second.Delete(ctx, key) 164 | } 165 | return nil 166 | } 167 | 168 | func (c *cacheWrapper) DeleteMulti(ctx context.Context, keys []string) error { 169 | if len(keys) == 0 { 170 | return nil 171 | } 172 | if err := c.first.DeleteMulti(ctx, keys); err != nil { 173 | return err 174 | } 175 | if c.second != nil { 176 | return c.second.DeleteMulti(ctx, keys) 177 | } 178 | return nil 179 | } 180 | 181 | func (c *cacheWrapper) makeCachable(m map[string]interface{}) { 182 | const sep = "/documents/" // for some reason Firestore cant use the full path so cut it 183 | for k, v := range m { 184 | switch val := v.(type) { 185 | case *firestore.DocumentRef: 186 | m[k+cacheElement] = strings.Split(val.Path, sep)[1] 187 | delete(m, k) 188 | default: 189 | valOf := reflect.ValueOf(v) 190 | switch valOf.Kind() { 191 | case reflect.Slice: 192 | if valOf.Len() > 0 { 193 | first := valOf.Index(0) 194 | // from firestore ref slices are interface type 195 | if first.Kind() == reflect.Interface && first.Elem().Type() == refType { 196 | refs := make([]string, valOf.Len()) 197 | for i := 0; i < valOf.Len(); i++ { 198 | fromEmlPtr := valOf.Index(i) 199 | refs[i] = strings.Split(fromEmlPtr.Interface().(*firestore.DocumentRef).Path, sep)[1] 200 | } 201 | m[k+cacheSlice] = refs 202 | delete(m, k) 203 | } 204 | } 205 | } 206 | } 207 | } 208 | } 209 | 210 | func (c *cacheWrapper) makeUnCachable(m map[string]interface{}) { 211 | for k, v := range m { 212 | if strings.HasSuffix(k, cacheElement) { 213 | m[strings.Replace(k, cacheElement, "", -1)] = c.client.Doc(v.(string)) 214 | delete(m, k) 215 | } else if strings.HasSuffix(k, cacheSlice) { 216 | // interface type to be consistent with firestorm arrays 217 | res := make([]interface{}, len(v.([]string))) 218 | for i, v := range v.([]string) { 219 | res[i] = c.client.Doc(v) 220 | } 221 | m[strings.Replace(k, cacheSlice, "", -1)] = res 222 | delete(m, k) 223 | } 224 | } 225 | } 226 | 227 | type defaultCache struct { 228 | sync.RWMutex 229 | } 230 | 231 | func newDefaultCache() *defaultCache { 232 | return &defaultCache{} 233 | } 234 | 235 | func (c *defaultCache) Get(ctx context.Context, key string) (EntityMap, error) { 236 | c.RLock() 237 | defer c.RUnlock() 238 | e, ok := getSessionCache(ctx)[key] 239 | if !ok { 240 | return nil, ErrCacheMiss 241 | } 242 | m := e.Copy() 243 | return m, nil 244 | } 245 | 246 | func (c *defaultCache) GetMulti(ctx context.Context, keys []string) (map[string]EntityMap, error) { 247 | c.RLock() 248 | defer c.RUnlock() 249 | result := make(map[string]EntityMap, len(keys)) 250 | for _, k := range keys { 251 | if e, ok := getSessionCache(ctx)[k]; ok { 252 | result[k] = e.Copy() 253 | } 254 | } 255 | return result, nil 256 | } 257 | 258 | func (c *defaultCache) Set(ctx context.Context, key string, item EntityMap) error { 259 | c.Lock() 260 | defer c.Unlock() 261 | getSessionCache(ctx)[key] = item 262 | return nil 263 | } 264 | 265 | func (c *defaultCache) SetMulti(ctx context.Context, items map[string]EntityMap) error { 266 | c.Lock() 267 | defer c.Unlock() 268 | for k, v := range items { 269 | getSessionCache(ctx)[k] = v 270 | } 271 | return nil 272 | } 273 | 274 | func (c *defaultCache) Delete(ctx context.Context, key string) error { 275 | return c.Set(ctx, key, nil) 276 | } 277 | 278 | func (c *defaultCache) DeleteMulti(ctx context.Context, keys []string) error { 279 | for _, key := range keys { 280 | c.Delete(ctx, key) 281 | } 282 | return nil 283 | } 284 | 285 | func (c *defaultCache) getSetRec(ctx context.Context) map[string]EntityMap { 286 | result := make(map[string]EntityMap) 287 | for key, elm := range getSessionCache(ctx) { 288 | if elm != nil { 289 | result[key] = elm 290 | } 291 | } 292 | return result 293 | } 294 | 295 | func (c *defaultCache) getDeleteRec(ctx context.Context) []string { 296 | var result []string 297 | for key, elm := range getSessionCache(ctx) { 298 | if elm == nil { 299 | result = append(result, key) 300 | } 301 | } 302 | return result 303 | } 304 | 305 | func getSessionCache(ctx context.Context) map[string]EntityMap { 306 | if c, ok := ctx.Value(SessionCacheKey).(map[string]EntityMap); ok { 307 | return c 308 | } 309 | logOnce.Do(func() { 310 | log.Println("Warning. Consider adding the CacheHandler middleware for the session cache to work") 311 | }) 312 | return make(map[string]EntityMap) 313 | } 314 | -------------------------------------------------------------------------------- /cache/inMemoryCache.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | import ( 4 | "context" 5 | "github.com/jschoedt/go-firestorm" 6 | "github.com/patrickmn/go-cache" 7 | "time" 8 | ) 9 | 10 | type InMemoryCache struct { 11 | c *cache.Cache 12 | } 13 | 14 | func NewMemoryCache(defaultExpiration, cleanupInterval time.Duration) *InMemoryCache { 15 | return &InMemoryCache{ 16 | c: cache.New(defaultExpiration, cleanupInterval), 17 | } 18 | } 19 | 20 | func (m *InMemoryCache) Get(ctx context.Context, key string) (firestorm.EntityMap, error) { 21 | if elm, ok := m.c.Get(key); ok { 22 | if m, ok := elm.(firestorm.EntityMap); ok { 23 | return m.Copy(), nil 24 | } 25 | } 26 | return nil, firestorm.ErrCacheMiss 27 | } 28 | 29 | func (m *InMemoryCache) GetMulti(ctx context.Context, keys []string) (map[string]firestorm.EntityMap, error) { 30 | result := make(map[string]firestorm.EntityMap, len(keys)) 31 | for _, k := range keys { 32 | if m, err := m.Get(ctx, k); err == nil { 33 | result[k] = m 34 | } 35 | } 36 | return result, nil 37 | 38 | } 39 | 40 | func (m *InMemoryCache) Set(ctx context.Context, key string, item firestorm.EntityMap) error { 41 | m.c.Set(key, item, cache.DefaultExpiration) 42 | return nil 43 | } 44 | 45 | func (m *InMemoryCache) SetMulti(ctx context.Context, items map[string]firestorm.EntityMap) error { 46 | for i, elm := range items { 47 | m.Set(ctx, i, elm) 48 | } 49 | return nil 50 | } 51 | 52 | func (m *InMemoryCache) Delete(ctx context.Context, key string) error { 53 | m.c.Delete(key) 54 | return nil 55 | } 56 | 57 | func (m *InMemoryCache) DeleteMulti(ctx context.Context, keys []string) error { 58 | for _, key := range keys { 59 | m.Delete(ctx, key) 60 | } 61 | return nil 62 | } 63 | -------------------------------------------------------------------------------- /crud.go: -------------------------------------------------------------------------------- 1 | package firestorm 2 | 3 | import ( 4 | "cloud.google.com/go/firestore" 5 | "context" 6 | ) 7 | 8 | type contextKey string 9 | 10 | func (c contextKey) String() string { 11 | return "context key " + string(c) 12 | } 13 | 14 | var ( 15 | transactionCtxKey = contextKey("transaction") 16 | ) 17 | 18 | func getTransaction(ctx context.Context) (*firestore.Transaction, bool) { 19 | t, ok := ctx.Value(transactionCtxKey).(*firestore.Transaction) 20 | return t, ok 21 | } 22 | 23 | func get(ctx context.Context, ref *firestore.DocumentRef) (*firestore.DocumentSnapshot, error) { 24 | if t, ok := getTransaction(ctx); ok { 25 | return t.Get(ref) 26 | } 27 | return ref.Get(ctx) 28 | } 29 | 30 | func getAll(ctx context.Context, client *firestore.Client, refs []*firestore.DocumentRef) ([]*firestore.DocumentSnapshot, error) { 31 | if len(refs) == 0 { 32 | return []*firestore.DocumentSnapshot{}, nil 33 | } 34 | if t, ok := getTransaction(ctx); ok { 35 | return t.GetAll(refs) 36 | } 37 | return client.GetAll(ctx, refs) 38 | } 39 | 40 | func query(ctx context.Context, query firestore.Query) ([]*firestore.DocumentSnapshot, error) { 41 | if t, ok := getTransaction(ctx); ok { 42 | return t.Documents(query).GetAll() 43 | } 44 | return query.Documents(ctx).GetAll() 45 | } 46 | 47 | func create(ctx context.Context, ref *firestore.DocumentRef, m map[string]interface{}) error { 48 | if t, ok := getTransaction(ctx); ok { 49 | return t.Create(ref, m) 50 | } 51 | _, err := ref.Create(ctx, m) 52 | return err 53 | } 54 | 55 | func set(ctx context.Context, ref *firestore.DocumentRef, m map[string]interface{}) error { 56 | if t, ok := getTransaction(ctx); ok { 57 | return t.Set(ref, m) 58 | } 59 | _, err := ref.Set(ctx, m) 60 | return err 61 | } 62 | 63 | func del(ctx context.Context, ref *firestore.DocumentRef) error { 64 | if t, ok := getTransaction(ctx); ok { 65 | return t.Delete(ref) 66 | } 67 | _, err := ref.Delete(ctx) 68 | return err 69 | } 70 | -------------------------------------------------------------------------------- /errors.go: -------------------------------------------------------------------------------- 1 | package firestorm 2 | 3 | import ( 4 | "cloud.google.com/go/firestore" 5 | "fmt" 6 | ) 7 | 8 | // NotFoundError is returned when any of the entities are not found in firestore 9 | // The error can be ignored if dangling references is not a problem 10 | type NotFoundError struct { 11 | // Refs contains the references not found 12 | Refs map[string]*firestore.DocumentRef 13 | } 14 | 15 | func newNotFoundError(refs map[string]*firestore.DocumentRef) NotFoundError { 16 | return NotFoundError{refs} 17 | } 18 | 19 | func (e NotFoundError) Error() string { 20 | return fmt.Sprintf("Not found error %v", e.Refs) 21 | } 22 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/jschoedt/go-firestorm 2 | 3 | require ( 4 | cloud.google.com/go v0.39.0 5 | firebase.google.com/go v3.7.0+incompatible 6 | github.com/google/go-cmp v0.3.1 7 | github.com/jschoedt/go-structmapper v0.0.0-20211213232249-19a5c78afaa6 8 | github.com/patrickmn/go-cache v2.1.0+incompatible 9 | golang.org/x/tools v0.0.0-20190606050223-4d9ae51c2468 // indirect 10 | google.golang.org/api v0.5.0 11 | ) 12 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= 2 | cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= 3 | cloud.google.com/go v0.39.0 h1:UgQP9na6OTfp4dsAiz/eFpFA1C6tPdH5wiRdi19tuMw= 4 | cloud.google.com/go v0.39.0/go.mod h1:rVLT6fkc8chs9sfPtFc1SBH6em7n+ZoXaG+87tDISts= 5 | firebase.google.com/go v3.7.0+incompatible h1:YcmaqJo0/MoIRjU0hAn5O9RX8xs0HLdAbP7OqGM/JyY= 6 | firebase.google.com/go v3.7.0+incompatible/go.mod h1:xlah6XbEyW6tbfSklcfe5FHJIwjt8toICdV5Wh9ptHs= 7 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 8 | github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= 9 | github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= 10 | github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= 11 | github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= 12 | github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM= 13 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 14 | github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= 15 | github.com/google/go-cmp v0.2.0 h1:+dTQ8DZQJz0Mb/HjFlkptS1FeQ4cWSnN941F8aEG4SQ= 16 | github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= 17 | github.com/google/go-cmp v0.3.1 h1:Xye71clBPdm5HgqGwUkwhbynsUJZhDbS20FvLhQ2izg= 18 | github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 19 | github.com/google/martian v2.1.0+incompatible h1:/CP5g8u/VJHijgedC/Legn3BAbAaWPgecwXBIDzw5no= 20 | github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= 21 | github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= 22 | github.com/googleapis/gax-go/v2 v2.0.4 h1:hU4mGcQI4DaAYW+IbTun+2qEZVFxK0ySjQLTbS0VQKc= 23 | github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= 24 | github.com/hashicorp/golang-lru v0.5.0 h1:CL2msUPvZTLb5O648aiLNJw3hnBxN2+1Jq8rCOH9wdo= 25 | github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= 26 | github.com/jschoedt/go-structmapper v0.0.0-20211203110202-913cb0ae78a5 h1:V/F23HbFY5nGfHEZ8e3hJdW0GHZ0wh08zLLv8uEJFIM= 27 | github.com/jschoedt/go-structmapper v0.0.0-20211203110202-913cb0ae78a5/go.mod h1:x12mRCBeG7r+5pWtMUyfJYX4VXHGoAwMdvkatcx07Oo= 28 | github.com/jschoedt/go-structmapper v0.0.0-20211203135226-adfe56c3580d h1:GMqidqScfSHTzddPE+oNTd7ICYe65RoK6wsPX5Jg0os= 29 | github.com/jschoedt/go-structmapper v0.0.0-20211203135226-adfe56c3580d/go.mod h1:x12mRCBeG7r+5pWtMUyfJYX4VXHGoAwMdvkatcx07Oo= 30 | github.com/jschoedt/go-structmapper v0.0.0-20211208222103-e0384380b85e h1:3PmUNHOZhNq5//udgBc+txp2nhinlsI+EHOA1f2vIqw= 31 | github.com/jschoedt/go-structmapper v0.0.0-20211208222103-e0384380b85e/go.mod h1:x12mRCBeG7r+5pWtMUyfJYX4VXHGoAwMdvkatcx07Oo= 32 | github.com/jschoedt/go-structmapper v0.0.0-20211213121825-6bce6b7494a9 h1:bgaNTvihOr+VmmgiyAcWy1uL37LJEr1+V+XHF+G/4nk= 33 | github.com/jschoedt/go-structmapper v0.0.0-20211213121825-6bce6b7494a9/go.mod h1:x12mRCBeG7r+5pWtMUyfJYX4VXHGoAwMdvkatcx07Oo= 34 | github.com/jschoedt/go-structmapper v0.0.0-20211213224311-9fde56f23449 h1:GJ91hIYvgrrTj0pexy2Dm3g/K/oqKCCNE9o69+aCCZU= 35 | github.com/jschoedt/go-structmapper v0.0.0-20211213224311-9fde56f23449/go.mod h1:x12mRCBeG7r+5pWtMUyfJYX4VXHGoAwMdvkatcx07Oo= 36 | github.com/jschoedt/go-structmapper v0.0.0-20211213232249-19a5c78afaa6 h1:FByTIIEvrBmUR3oaC0w3B4u3ta5GrvvrCyW8ICxsg5A= 37 | github.com/jschoedt/go-structmapper v0.0.0-20211213232249-19a5c78afaa6/go.mod h1:x12mRCBeG7r+5pWtMUyfJYX4VXHGoAwMdvkatcx07Oo= 38 | github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= 39 | github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= 40 | github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= 41 | go.opencensus.io v0.21.0 h1:mU6zScU4U1YAFPHEHYk+3JC4SY7JxgkqS10ZOSyksNg= 42 | go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= 43 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 44 | golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= 45 | golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= 46 | golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= 47 | golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= 48 | golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 49 | golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 50 | golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 51 | golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 52 | golang.org/x/net v0.0.0-20190311183353-d8887717615a h1:oWX7TPOiFAMXLq8o0ikBYfCJVlRHBcsciT5bXOrH628= 53 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 54 | golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= 55 | golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421 h1:Wo7BWFiOk0QRFMLYMqJGFMd9CgUAcGx7V+qEg/h5IBI= 56 | golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= 57 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 58 | golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 59 | golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 60 | golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 61 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 62 | golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 63 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a h1:1BGLXjeY4akVXGgbC9HugT3Jv3hCI0z56oJR5vAMgBU= 64 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 65 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 66 | golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2 h1:z99zHgr7hKfrUcX/KsoJk5FJfjTceCKIp96+biqP4To= 67 | golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 68 | golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 69 | golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 70 | golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= 71 | golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 72 | golang.org/x/tools v0.0.0-20190606050223-4d9ae51c2468/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= 73 | google.golang.org/api v0.5.0 h1:lj9SyhMzyoa38fgFF0oO2T6pjs5IzkLPKfVtxpyCRMM= 74 | google.golang.org/api v0.5.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= 75 | google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= 76 | google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= 77 | google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= 78 | google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= 79 | google.golang.org/genproto v0.0.0-20190508193815-b515fa19cec8 h1:x913Lq/RebkvUmRSdQ8MNb0GZKn+SR1ESfoetcQSeak= 80 | google.golang.org/genproto v0.0.0-20190508193815-b515fa19cec8/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= 81 | google.golang.org/grpc v1.19.0 h1:cfg4PD8YEdSFnm7qLV4++93WcmhH2nIUhMjhdCvl3j8= 82 | google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= 83 | honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 84 | honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 85 | -------------------------------------------------------------------------------- /googlea2cff9e1091c15a0.html: -------------------------------------------------------------------------------- 1 | google-site-verification: googlea2cff9e1091c15a0.html -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package firestorm 2 | 3 | import ( 4 | "cloud.google.com/go/firestore" 5 | "context" 6 | mapper "github.com/jschoedt/go-structmapper" 7 | ) 8 | 9 | // FSClient is the client used to perform the CRUD actions 10 | type FSClient struct { 11 | Client *firestore.Client 12 | MapToDB *mapper.Mapper 13 | MapFromDB *mapper.Mapper 14 | IDKey, ParentKey string 15 | Cache *cacheWrapper 16 | IsEntity func(i interface{}) bool 17 | } 18 | 19 | // NewRequest creates a new CRUD Request to firestore 20 | func (fsc *FSClient) NewRequest() *Request { 21 | r := &Request{} 22 | r.FSC = fsc 23 | r.mapperFunc = func(i map[string]interface{}) { 24 | return 25 | } 26 | return r 27 | } 28 | 29 | // New creates a firestorm client. Supply the names of the id and parent fields of your model structs 30 | // Leave parent blank if sub-collections are not used. 31 | func New(client *firestore.Client, id, parent string) *FSClient { 32 | c := &FSClient{} 33 | c.Client = client 34 | c.MapToDB = mapper.New() 35 | c.MapToDB.MapFunc = c.DefaultToDBMapperFunc 36 | c.MapFromDB = mapper.New() 37 | c.MapFromDB.MapFunc = c.DefaultFromDBMapperFunc 38 | c.MapFromDB.CaseSensitive = false 39 | c.IDKey = id 40 | c.ParentKey = parent 41 | c.Cache = newCacheWrapper(client, newDefaultCache(), nil) 42 | c.IsEntity = isEntity(c.IDKey) 43 | return c 44 | } 45 | 46 | // SetCache sets a second level cache besides the session cache. Use it for eg. memcache or redis 47 | func (fsc *FSClient) SetCache(cache Cache) { 48 | fsc.Cache = newCacheWrapper(fsc.Client, newDefaultCache(), cache) 49 | } 50 | 51 | // getCache gets the transaction cache when inside a transaction - otherwise the global cache 52 | func (fsc *FSClient) getCache(ctx context.Context) *cacheWrapper { 53 | if c, ok := ctx.Value(transCacheKey).(*cacheWrapper); ok { 54 | return c 55 | } 56 | return fsc.Cache 57 | } 58 | 59 | // isEntity tests if the i is a firestore entity 60 | func isEntity(id string) func(i interface{}) bool { 61 | return func(i interface{}) bool { 62 | _, err := getIDValue(id, i) 63 | return err == nil 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /mappers.go: -------------------------------------------------------------------------------- 1 | package firestorm 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | mapper "github.com/jschoedt/go-structmapper" 7 | "reflect" 8 | "strings" 9 | "time" 10 | ) 11 | 12 | // DefaultToDBMapperFunc default mapper that maps entity fields and values to be firestore fields and values 13 | func (fsc *FSClient) DefaultToDBMapperFunc(inKey string, inVal interface{}) (mt mapper.MappingType, outKey string, outVal interface{}) { 14 | inKey = strings.ToLower(inKey) 15 | // do not save as it is the id of the document 16 | switch inKey { 17 | case "id": 18 | return mapper.Ignore, inKey, inVal 19 | } 20 | 21 | v := reflect.ValueOf(inVal) 22 | switch v.Kind() { 23 | case reflect.Struct: 24 | if _, ok := inVal.(time.Time); ok { 25 | return mapper.Custom, inKey, inVal 26 | } 27 | case reflect.Slice: 28 | typ := v.Type().Elem() 29 | if typ.Kind() == reflect.Ptr { 30 | typ = typ.Elem() 31 | } 32 | // check if the type is an entity 33 | if v.Len() > 0 { 34 | first := v.Index(0) 35 | if fsc.IsEntity(first) { 36 | // make it interface type so it matches what firestore returns 37 | elemSlice := reflect.MakeSlice(reflect.TypeOf([]interface{}(nil)), v.Len(), v.Len()) 38 | for i := 0; i < v.Len(); i++ { 39 | fromEmlPtr := v.Index(i) 40 | fromEmlValue := reflect.Indirect(fromEmlPtr) 41 | //log.Printf("val : %v", fromEmlValue) 42 | hid := fromEmlValue.Addr().Interface() 43 | toElmPtr := reflect.ValueOf(fsc.NewRequest().ToRef(hid)) 44 | elemSlice.Index(i).Set(toElmPtr) 45 | } 46 | return mapper.Custom, inKey, elemSlice.Interface() 47 | } 48 | } 49 | return mapper.Default, inKey, inVal 50 | 51 | case reflect.Interface: 52 | fallthrough 53 | case reflect.Ptr: 54 | val := reflect.Indirect(v) 55 | if val.Kind() == reflect.Invalid { 56 | return mapper.Ignore, "", inVal // skip nil pointer 57 | } 58 | if fsc.IsEntity(val) { 59 | return mapper.Custom, inKey, fsc.NewRequest().ToRef(val.Interface()) 60 | } 61 | } 62 | return mapper.Default, inKey, inVal 63 | } 64 | 65 | // DefaultFromDBMapperFunc default mapper that maps firestore fields and values to entity fields and values 66 | func (fsc *FSClient) DefaultFromDBMapperFunc(inKey string, inVal interface{}) (mt mapper.MappingType, outKey string, outVal interface{}) { 67 | return mapper.NilMapFunc(strings.Title(inKey), inVal) 68 | } 69 | 70 | func (fsc *FSClient) toEntities(ctx context.Context, entities []entityMap, toSlicePtr interface{}) error { 71 | var errs []string 72 | valuePtr := reflect.ValueOf(toSlicePtr) 73 | value := reflect.Indirect(valuePtr) 74 | for _, m := range entities { 75 | // log.Printf("type %v", value.Type().Elem()) 76 | if p, err := fsc.toEntity(ctx, m, value.Type().Elem()); err != nil { 77 | errs = append(errs, err.Error()) 78 | continue 79 | } else { 80 | value.Set(reflect.Append(value, p)) 81 | } 82 | } 83 | if len(errs) > 0 { 84 | return errors.New(strings.Join(errs, "\n")) 85 | } 86 | return nil 87 | } 88 | 89 | func (fsc *FSClient) toEntity(ctx context.Context, m map[string]interface{}, typ reflect.Type) (reflect.Value, error) { 90 | isPtr := typ.Kind() == reflect.Ptr 91 | if isPtr { 92 | typ = typ.Elem() 93 | } 94 | 95 | p := reflect.New(typ) 96 | err := fsc.MapFromDB.MapToStruct(m, p.Interface()) 97 | 98 | if isPtr { 99 | return p, err 100 | } 101 | return reflect.Indirect(p), err 102 | } 103 | 104 | func getTypeName(i interface{}) string { 105 | return getStructType(i).Name() 106 | } 107 | 108 | func getStructType(i interface{}) reflect.Type { 109 | t := reflect.TypeOf(i) 110 | if t.Kind() == reflect.Ptr { 111 | return t.Elem() 112 | } 113 | return t 114 | } 115 | -------------------------------------------------------------------------------- /request.go: -------------------------------------------------------------------------------- 1 | package firestorm 2 | 3 | import ( 4 | "cloud.google.com/go/firestore" 5 | "context" 6 | "errors" 7 | "fmt" 8 | "reflect" 9 | ) 10 | 11 | // Request a request builder for querying firestore 12 | type Request struct { 13 | FSC *FSClient 14 | loadPaths []string 15 | mapperFunc mapperFunc 16 | } 17 | 18 | type mapperFunc func(map[string]interface{}) 19 | 20 | // SetMapperFunc is called before the map is saved to firestore. 21 | // This can be used to modify the map before it is saved 22 | func (req *Request) SetMapperFunc(mapperFunc mapperFunc) *Request { 23 | req.mapperFunc = mapperFunc 24 | return req 25 | } 26 | 27 | // SetLoadPaths adds the paths (refs) to load for the entity. 28 | // Eg. to load a users grandmother: 'mother.mother' 29 | // To load all refs on the struct use firestorm.AllEntities 30 | // See examples: https://github.com/jschoedt/go-firestorm/blob/master/tests/integration_test.go 31 | func (req *Request) SetLoadPaths(paths ...string) *Request { 32 | req.loadPaths = paths 33 | return req 34 | } 35 | 36 | // ToCollection creates a firestore CollectionRef to the entity 37 | func (req *Request) ToCollection(entity interface{}) *firestore.CollectionRef { 38 | path := getTypeName(entity) 39 | 40 | // prefix any parents 41 | for p := req.GetParent(entity); p != nil; p = req.GetParent(p) { 42 | n := getTypeName(p) 43 | path = n + "/" + req.GetID(p) + "/" + path 44 | } 45 | 46 | return req.FSC.Client.Collection(path) 47 | } 48 | 49 | // GetParent gets the patent of the entity 50 | func (req *Request) GetParent(entity interface{}) interface{} { 51 | v, err := getIDValue(req.FSC.ParentKey, entity) 52 | if err != nil { 53 | return nil 54 | } 55 | return v.Interface() 56 | } 57 | 58 | // GetID gets the id of the entity. It panics if the entity does not have an ID field. 59 | func (req *Request) GetID(entity interface{}) string { 60 | if v, err := getIDValue(req.FSC.IDKey, entity); err != nil { 61 | panic(err) 62 | } else { 63 | return v.Interface().(string) 64 | } 65 | } 66 | 67 | func getIDValue(id string, entity interface{}) (reflect.Value, error) { 68 | v := reflect.ValueOf(entity) 69 | if cv, ok := entity.(reflect.Value); ok { 70 | v = cv 71 | } 72 | v = reflect.Indirect(v) 73 | if v.Kind() == reflect.Struct { 74 | for i := 0; i < v.NumField(); i++ { 75 | f := v.Field(i) 76 | sf := v.Type().Field(i) 77 | 78 | switch f.Kind() { 79 | case reflect.Struct: 80 | if sf.Anonymous { 81 | if sv, err := getIDValue(id, f); err == nil { 82 | return sv, nil 83 | } 84 | } 85 | } 86 | 87 | // first check if id is statically set 88 | if sf.Name == id { 89 | return f, nil 90 | } 91 | // otherwise use the tag 92 | /* not supported yet 93 | if tag, ok := sf.Tag.Lookup("firestorm"); ok { 94 | if tag == "id" { 95 | return f, nil 96 | } 97 | } 98 | */ 99 | } 100 | } 101 | return v, fmt.Errorf("entity has no id field defined: %v", entity) 102 | } 103 | 104 | // SetID sets the id field to the given id 105 | func (req *Request) SetID(entity interface{}, id string) { 106 | v, err := getIDValue(req.FSC.IDKey, entity) 107 | if err != nil { 108 | panic(err) 109 | } 110 | v.SetString(id) 111 | } 112 | 113 | // ToRef creates a firestore DocumentRef for the entity 114 | func (req *Request) ToRef(entity interface{}) *firestore.DocumentRef { 115 | return req.ToCollection(entity).Doc(req.GetID(entity)) 116 | } 117 | 118 | // GetEntities reads the entities from the database by their id. Supply either a pointer to a struct or pointer to a slice. Returns a 119 | // slice containing the found entities and an error if some entities are not found. 120 | func (req *Request) GetEntities(ctx context.Context, entities interface{}) func() ([]interface{}, error) { 121 | v := reflect.Indirect(reflect.ValueOf(entities)) 122 | switch v.Kind() { 123 | case reflect.Struct: 124 | v = reflect.ValueOf([]interface{}{entities}) 125 | fallthrough 126 | case reflect.Slice: 127 | return req.FSC.getEntities(ctx, req, v) 128 | } 129 | return func() (i []interface{}, e error) { 130 | return nil, fmt.Errorf("kind not supported: %s", v.Kind().String()) 131 | } 132 | } 133 | 134 | // CreateEntities creates the entities and auto creates the id if left empty. Supply either a struct or a slice 135 | // as value or reference. 136 | func (req *Request) CreateEntities(ctx context.Context, entities interface{}) FutureFunc { 137 | v := reflect.Indirect(reflect.ValueOf(entities)) 138 | switch v.Kind() { 139 | case reflect.Struct: 140 | return req.FSC.createEntity(ctx, req, entities) 141 | case reflect.Slice: 142 | return req.FSC.createEntities(ctx, req, v) 143 | } 144 | return createErrorFunc(fmt.Sprintf("kind not supported: %s", v.Kind().String())) 145 | } 146 | 147 | // UpdateEntities updates the entities. Supply either a struct or a slice 148 | // as value or reference. 149 | func (req *Request) UpdateEntities(ctx context.Context, entities interface{}) FutureFunc { 150 | v := reflect.Indirect(reflect.ValueOf(entities)) 151 | switch v.Kind() { 152 | case reflect.Struct: 153 | return req.FSC.updateEntity(ctx, req, entities) 154 | case reflect.Slice: 155 | return req.FSC.updateEntities(ctx, req, v) 156 | } 157 | return createErrorFunc(fmt.Sprintf("Kind not supported: %s", v.Kind().String())) 158 | } 159 | 160 | // DeleteEntities deletes the entities. Supply either a struct or a slice 161 | // as value or reference. 162 | func (req *Request) DeleteEntities(ctx context.Context, entities interface{}) FutureFunc { 163 | v := reflect.Indirect(reflect.ValueOf(entities)) 164 | switch v.Kind() { 165 | case reflect.Struct: 166 | return req.FSC.deleteEntity(ctx, req, entities) 167 | case reflect.Slice: 168 | return req.FSC.deleteEntities(ctx, req, v) 169 | } 170 | return createErrorFunc(fmt.Sprintf("Kind not supported: %s", v.Kind().String())) 171 | } 172 | 173 | // QueryEntities query for entities. Supply a reference to a slice for the result 174 | func (req *Request) QueryEntities(ctx context.Context, query firestore.Query, toSlicePtr interface{}) FutureFunc { 175 | return req.FSC.queryEntities(ctx, req, query, toSlicePtr) 176 | } 177 | 178 | func createErrorFunc(s string) func() error { 179 | return func() error { 180 | return errors.New(s) 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /resolver.go: -------------------------------------------------------------------------------- 1 | package firestorm 2 | 3 | import ( 4 | "cloud.google.com/go/firestore" 5 | "context" 6 | "reflect" 7 | "strings" 8 | ) 9 | 10 | type entityMap = map[string]interface{} 11 | type refSet map[string]*firestore.DocumentRef 12 | type resolveFunc func(m entityMap, ref *firestore.DocumentRef) 13 | 14 | // AllEntities loads all paths on the struct see: SetLoadPaths 15 | const AllEntities = "ALL" 16 | 17 | var refType = reflect.TypeOf((*firestore.DocumentRef)(nil)) 18 | var entityType = reflect.TypeOf((entityMap)(nil)) 19 | 20 | type refCollector struct { 21 | r *resolver 22 | targetsToResolve map[string][]resolveFunc // func that ads the result to the target 23 | refs refSet // refs to resolve 24 | nfRefs map[string]*firestore.DocumentRef // not found refs 25 | } 26 | 27 | func (r *resolver) NewRefCollector() *refCollector { 28 | return &refCollector{r, make(map[string][]resolveFunc), make(refSet), make(map[string]*firestore.DocumentRef)} 29 | } 30 | 31 | func (c *refCollector) Append(m entityMap, key string, ref *firestore.DocumentRef) { 32 | if e, ok := c.r.loaded[ref.Path]; ok { 33 | // it should be safe to modify although I think the spec is ambiguous 34 | // see: https://github.com/golang/go/issues/9926 35 | m[key] = e 36 | } else { 37 | resolveFunc := func(childM entityMap, childRef *firestore.DocumentRef) { 38 | m[key] = childM 39 | } 40 | c.targetsToResolve[ref.Path] = append(c.targetsToResolve[ref.Path], resolveFunc) 41 | c.refs[ref.Path] = ref 42 | } 43 | } 44 | 45 | func (c *refCollector) AppendSlice(m entityMap, key string, refs []*firestore.DocumentRef) { 46 | targetSlice := make([]entityMap, len(refs)) 47 | // it should be safe to modify although I think the spec is ambiguous 48 | // see: https://github.com/golang/go/issues/9926 49 | m[key] = targetSlice 50 | for i, ref := range refs { 51 | if e, ok := c.r.loaded[ref.Path]; ok { 52 | targetSlice[i] = e 53 | } else { 54 | index := i // save index in closure 55 | resolveFunc := func(childM entityMap, childRef *firestore.DocumentRef) { 56 | targetSlice[index] = childM 57 | } 58 | c.targetsToResolve[ref.Path] = append(c.targetsToResolve[ref.Path], resolveFunc) 59 | c.refs[ref.Path] = ref 60 | } 61 | } 62 | } 63 | 64 | // resolves the elements matching the ref and removes them 65 | func (c *refCollector) resolve(m entityMap, ref *firestore.DocumentRef) { 66 | if targets, ok := c.targetsToResolve[ref.Path]; ok { 67 | for _, target := range targets { 68 | target(m, ref) 69 | } 70 | } 71 | } 72 | 73 | func (c *refCollector) getRefs() []*firestore.DocumentRef { 74 | result := make([]*firestore.DocumentRef, 0, len(c.refs)) 75 | for _, v := range c.refs { 76 | result = append(result, v) 77 | } 78 | return result 79 | } 80 | 81 | func (c *refCollector) AppendNotResolved(ref *firestore.DocumentRef) { 82 | c.nfRefs[ref.Path] = ref 83 | } 84 | 85 | func (c *refCollector) getErrors() error { 86 | if len(c.nfRefs) > 0 { 87 | return newNotFoundError(c.nfRefs) 88 | } 89 | return nil 90 | } 91 | 92 | type resolver struct { 93 | fsc *FSClient 94 | resolved map[string]entityMap 95 | loaded map[string]entityMap 96 | paths []string 97 | } 98 | 99 | func newResolver(fsc *FSClient, paths ...string) *resolver { 100 | return &resolver{fsc, make(map[string]entityMap), make(map[string]entityMap), paths} 101 | } 102 | 103 | func (r *resolver) ResolveCacheRef(ctx context.Context, crefs []cacheRef) ([]entityMap, error) { 104 | result := make([]entityMap, len(crefs)) 105 | r.Loaded(crefs) 106 | 107 | col := r.NewRefCollector() 108 | for i, cref := range crefs { 109 | m := cref.GetResult() 110 | if len(m) != 0 { 111 | r.resolveEntity(m, cref.Ref, col, r.paths...) 112 | result[i] = m 113 | } else { 114 | col.AppendNotResolved(cref.Ref) 115 | } 116 | } 117 | 118 | if err := r.resolveChildren(ctx, col, r.paths...); err != nil { 119 | return nil, err 120 | } 121 | 122 | return result, col.getErrors() 123 | } 124 | 125 | func (r *resolver) ResolveDocs(ctx context.Context, docs []*firestore.DocumentSnapshot) ([]entityMap, error) { 126 | result := make([]entityMap, len(docs)) 127 | col := r.NewRefCollector() 128 | for i, doc := range docs { 129 | if doc.Exists() { 130 | m := doc.Data() 131 | r.resolveEntity(m, doc.Ref, col, r.paths...) 132 | result[i] = m 133 | } else { 134 | col.AppendNotResolved(doc.Ref) 135 | } 136 | } 137 | 138 | if err := r.resolveChildren(ctx, col, r.paths...); err != nil { 139 | return nil, err 140 | } 141 | 142 | return result, col.getErrors() 143 | } 144 | 145 | func (r *resolver) resolveChildren(ctx context.Context, col *refCollector, paths ...string) error { 146 | // base case stop recursion when no more children are present 147 | refs := col.getRefs() 148 | if len(refs) == 0 { 149 | return nil 150 | } 151 | 152 | // cut off the first path in the paths list 153 | nextPaths := make([]string, 0, len(paths)) 154 | for _, v := range paths { 155 | split := strings.Split(v, ".") 156 | if len(split) > 1 { 157 | nextPaths = append(nextPaths, split[1]) 158 | } 159 | } 160 | 161 | // now query the DB 162 | crefs, err := r.fsc.getCachedEntities(ctx, refs) 163 | if err != nil { 164 | return err 165 | } 166 | r.Loaded(crefs) 167 | childCol := r.NewRefCollector() 168 | for _, cref := range crefs { 169 | result := cref.GetResult() 170 | if len(result) == 0 { // add not found refs 171 | col.AppendNotResolved(cref.Ref) 172 | continue 173 | } 174 | r.resolveEntity(result, cref.Ref, childCol, nextPaths...) 175 | col.resolve(result, cref.Ref) 176 | } 177 | return r.resolveChildren(ctx, childCol, nextPaths...) 178 | } 179 | 180 | func (r *resolver) resolveEntity(m entityMap, ref *firestore.DocumentRef, col *refCollector, paths ...string) { 181 | // only resolve it once 182 | if ref != nil { 183 | if _, ok := r.resolved[ref.Path]; ok { 184 | return 185 | } 186 | r.resolved[ref.Path] = m 187 | m[r.fsc.IDKey] = ref.ID 188 | //m["createtime"] = doc.CreateTime 189 | //m["updatetime"] = doc.UpdateTime 190 | //m["readtime"] = doc.ReadTime 191 | } 192 | 193 | for k, v := range m { 194 | switch val := v.(type) { 195 | case *firestore.DocumentRef: 196 | if r.contains(k, paths...) { 197 | col.Append(m, k, val) 198 | } else { 199 | delete(m, k) 200 | } 201 | default: 202 | valOf := reflect.ValueOf(v) 203 | switch valOf.Kind() { 204 | case reflect.Map: 205 | if valOf.Len() > 0 && valOf.Type() == entityType { 206 | r.resolveEntity(v.(entityMap), nil, col, paths...) 207 | } 208 | case reflect.Slice: 209 | if valOf.Len() > 0 { 210 | first := valOf.Index(0) 211 | 212 | // from firestore the type of slice is interface 213 | if first.Kind() == reflect.Interface { 214 | first = first.Elem() 215 | } 216 | //fmt.Printf("kind: %v type: %v \n", first.Kind(), first.Type()) 217 | 218 | if first.Kind() == reflect.Map { 219 | for i := 0; i < valOf.Len(); i++ { 220 | r.resolveEntity(valOf.Index(i).Interface().(entityMap), nil, col, paths...) 221 | } 222 | } else if first.Type() == refType { 223 | if !r.contains(k, paths...) { 224 | delete(m, k) 225 | continue 226 | } 227 | refs := make([]*firestore.DocumentRef, valOf.Len()) 228 | for i := 0; i < valOf.Len(); i++ { 229 | fromEmlPtr := valOf.Index(i) 230 | refs[i] = fromEmlPtr.Interface().(*firestore.DocumentRef) 231 | } 232 | col.AppendSlice(m, k, refs) 233 | } 234 | 235 | } 236 | } 237 | } 238 | } 239 | } 240 | 241 | func (r *resolver) contains(find string, paths ...string) bool { 242 | if find == r.fsc.ParentKey { 243 | return true 244 | } 245 | for _, a := range paths { 246 | if a == AllEntities || strings.Index(a, find) == 0 { 247 | return true 248 | } 249 | } 250 | return false 251 | } 252 | 253 | func (r *resolver) Loaded(refs []cacheRef) { 254 | for _, v := range refs { 255 | r.loaded[v.Ref.Path] = v.GetResult() 256 | } 257 | } 258 | -------------------------------------------------------------------------------- /tests/cache_test.go: -------------------------------------------------------------------------------- 1 | package firestormtests 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "github.com/google/go-cmp/cmp" 7 | "github.com/jschoedt/go-firestorm" 8 | "github.com/jschoedt/go-firestorm/cache" 9 | "testing" 10 | "time" 11 | ) 12 | 13 | func TestCacheCRUD(t *testing.T) { 14 | ctx := createSessionCacheContext() 15 | memoryCache := cache.NewMemoryCache(5*time.Minute, 10*time.Minute) 16 | fsc.SetCache(memoryCache) 17 | 18 | car := &Car{} 19 | car.ID = "MyCar" 20 | car.Make = "Toyota" 21 | 22 | // Create the entity 23 | fsc.NewRequest().CreateEntities(ctx, car)() 24 | assertInCache(ctx, memoryCache, car, t) 25 | 26 | // Update the entity 27 | car.Make = "Jeep" 28 | fsc.NewRequest().UpdateEntities(ctx, car)() 29 | m := assertInCache(ctx, memoryCache, car, t) 30 | if m["make"] != car.Make { 31 | t.Errorf("Value should be: %v - but was: %v", car.Make, m["Make"]) 32 | } 33 | 34 | // Delete the entity 35 | fsc.NewRequest().DeleteEntities(ctx, car)() 36 | if m := assertInCache(ctx, memoryCache, car, t); len(m) > 0 { 37 | t.Errorf("Value should be: %v - but was: %v", "nil", m) 38 | } 39 | } 40 | 41 | func TestCacheTransaction(t *testing.T) { 42 | ctx := createSessionCacheContext() 43 | memoryCache := cache.NewMemoryCache(5*time.Minute, 10*time.Minute) 44 | fsc.SetCache(memoryCache) 45 | 46 | car := &Car{} 47 | car.ID = "MyCar" 48 | car.Make = "Toyota" 49 | 50 | fsc.DoInTransaction(ctx, func(tctx context.Context) error { 51 | // Create the entity 52 | fsc.NewRequest().CreateEntities(tctx, car)() 53 | assertInSessionCache(tctx, car, t) 54 | assertNotInCache(ctx, memoryCache, car, t) 55 | return errors.New("rollback") 56 | }) 57 | 58 | assertNotInCache(ctx, memoryCache, car, t) 59 | 60 | fsc.NewRequest().GetEntities(ctx, car)() 61 | if m := assertInCache(ctx, memoryCache, car, t); m != nil { 62 | t.Errorf("entity should be nil : %v", m) 63 | } 64 | 65 | fsc.DoInTransaction(ctx, func(tctx context.Context) error { 66 | // Create the entity 67 | return fsc.NewRequest().CreateEntities(tctx, car)() 68 | }) 69 | 70 | if m := assertInCache(ctx, memoryCache, car, t); m == nil { 71 | t.Errorf("entity should be not be nill : %v", m) 72 | } 73 | 74 | car.Make = "Jeep" 75 | 76 | fsc.DoInTransaction(ctx, func(tctx context.Context) error { 77 | // Create the entity 78 | fsc.NewRequest().UpdateEntities(tctx, car)() 79 | assertInSessionCache(tctx, car, t) 80 | assertInCache(ctx, memoryCache, car, t) 81 | return nil 82 | }) 83 | 84 | assertInCache(ctx, memoryCache, car, t) 85 | 86 | // Delete the entity 87 | fsc.NewRequest().DeleteEntities(ctx, car)() 88 | if m := assertInCache(ctx, memoryCache, car, t); len(m) > 0 { 89 | t.Errorf("Value should be: %v - but was: %v", "nil", m) 90 | } 91 | } 92 | 93 | func assertInSessionCache(ctx context.Context, car *Car, t *testing.T) { 94 | cacheKey := fsc.NewRequest().ToRef(car).Path 95 | sessionCache := getSessionCache(ctx) 96 | 97 | if val, ok := sessionCache[cacheKey]; !ok { 98 | t.Errorf("entity not found in session cache : %v", cacheKey) 99 | if !cmp.Equal(val, car) { 100 | t.Errorf("The elements were not the same %v", cmp.Diff(sessionCache[cacheKey], car)) 101 | } 102 | } 103 | } 104 | 105 | func assertInCache(ctx context.Context, memoryCache *cache.InMemoryCache, car *Car, t *testing.T) map[string]interface{} { 106 | cacheKey := fsc.NewRequest().ToRef(car).Path 107 | sessionCache := getSessionCache(ctx) 108 | sesVal, ok := sessionCache[cacheKey] 109 | 110 | assertInSessionCache(ctx, car, t) 111 | m, err := memoryCache.Get(ctx, cacheKey) 112 | if err != nil { 113 | // a nil value was set for a key 114 | if len(m) == 0 && ok && sesVal == nil { 115 | return m 116 | } 117 | t.Errorf("entity not found in cache : %v", cacheKey) 118 | } 119 | 120 | if !cmp.Equal(sesVal, m) { 121 | t.Errorf("The elements were not the same %v", cmp.Diff(sesVal, m)) 122 | } 123 | return m 124 | } 125 | 126 | func assertNotInCache(ctx context.Context, memoryCache *cache.InMemoryCache, car *Car, t *testing.T) { 127 | cacheKey := fsc.NewRequest().ToRef(car).Path 128 | sessionCache := getSessionCache(ctx) 129 | 130 | if _, ok := sessionCache[cacheKey]; ok { 131 | t.Errorf("entity should not be in session cache : %v", cacheKey) 132 | } 133 | 134 | if _, err := memoryCache.Get(ctx, cacheKey); err != firestorm.ErrCacheMiss { 135 | t.Errorf("entity should not be in cache : %v", cacheKey) 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /tests/integration_test.go: -------------------------------------------------------------------------------- 1 | package firestormtests 2 | 3 | import ( 4 | "cloud.google.com/go/firestore" 5 | "context" 6 | "github.com/google/go-cmp/cmp" 7 | "github.com/jschoedt/go-firestorm" 8 | "testing" 9 | "time" 10 | ) 11 | 12 | type Person struct { 13 | ID string 14 | Name string 15 | Spouse *Person 16 | Relations []*Relation // becomes nested as Relation is missing ID 17 | } 18 | 19 | type Car struct { 20 | ID string 21 | Make string 22 | Owner *Person // becomes firestore ref 23 | Driver Person // becomes a nested entity since he is not a reference 24 | Passengers []Person // becomes a firestore array of refs 25 | Tags []string 26 | Numbers []int 27 | Year time.Time 28 | } 29 | 30 | type Relation struct { 31 | Name string 32 | Friends []*Person // becomes a firestore array of refs 33 | } 34 | 35 | func TestCRUD(t *testing.T) { 36 | testRunner(t, testCRUD_) 37 | } 38 | 39 | func testCRUD_(ctx context.Context, t *testing.T) { 40 | car := &Car{} 41 | car.Make = "Toyota" 42 | car.Year, _ = time.Parse(time.RFC3339, "2001-01-01T00:00:00.000Z") 43 | 44 | // Create the entity 45 | fsc.NewRequest().CreateEntities(ctx, car)() 46 | 47 | if car.ID == "" { 48 | t.Errorf("car should have an auto generated ID") 49 | } 50 | 51 | // Read the entity by ID 52 | otherCar := &Car{ID: car.ID} 53 | fsc.NewRequest().GetEntities(ctx, otherCar)() 54 | if otherCar.Make != "Toyota" { 55 | t.Errorf("car should have name: Toyota but was: %s", otherCar.Make) 56 | } 57 | if otherCar.Year != car.Year { 58 | t.Errorf("car should have same year: %s", otherCar.Year) 59 | } 60 | 61 | // Update the entity 62 | car.Make = "Jeep" 63 | fsc.NewRequest().UpdateEntities(ctx, car)() 64 | 65 | otherCar = &Car{ID: car.ID} 66 | fsc.NewRequest().GetEntities(ctx, otherCar)() 67 | if otherCar.Make != "Jeep" { 68 | t.Errorf("car should have name: Jeep but was: %s", otherCar.Make) 69 | } 70 | 71 | // Delete the entity 72 | fsc.NewRequest().DeleteEntities(ctx, car)() 73 | 74 | otherCar = &Car{ID: car.ID} 75 | if _, err := fsc.NewRequest().GetEntities(ctx, otherCar)(); err == nil { 76 | t.Errorf("We expect a notFoundError") 77 | } 78 | } 79 | func TestSearch(t *testing.T) { 80 | testRunner(t, testSearch_) 81 | } 82 | func testSearch_(ctx context.Context, t *testing.T) { 83 | car := &Car{} 84 | car.ID = "testID" 85 | car.Make = "Toyota" 86 | 87 | fsc.NewRequest().CreateEntities(ctx, car)() 88 | defer cleanup(car) 89 | 90 | query := fsc.Client.Collection("Car").Where("make", "==", "Toyota") 91 | 92 | result := make([]Car, 0) 93 | if err := fsc.NewRequest().QueryEntities(ctx, query, &result)(); err != nil { 94 | t.Errorf("car was not found by search: %v", err) 95 | } 96 | 97 | if result[0].ID != car.ID || result[0].Make != car.Make { 98 | t.Errorf("entity did not match original entity : %v", result) 99 | } 100 | 101 | ptrResult := make([]*Car, 0) 102 | if err := fsc.NewRequest().QueryEntities(ctx, query, &ptrResult)(); err != nil { 103 | t.Errorf("car was not found by search: %v", car) 104 | } 105 | 106 | if ptrResult[0].ID != car.ID || ptrResult[0].Make != car.Make { 107 | t.Errorf("entity did not match original entity : %v", ptrResult) 108 | } 109 | } 110 | 111 | func TestConcurrency(t *testing.T) { 112 | testRunner(t, testConcurrency_) 113 | } 114 | func testConcurrency_(ctx context.Context, t *testing.T) { 115 | if testing.Short() { 116 | return 117 | } 118 | car := &Car{Make: "Toyota"} 119 | 120 | // Create the entity 121 | future := fsc.NewRequest().CreateEntities(ctx, car) 122 | defer cleanup(car) 123 | if car.ID != "" { 124 | t.Errorf("car ID should not have been set yet") 125 | } 126 | 127 | // so some more work 128 | 129 | // blocks and waits for the database to finish 130 | future() 131 | 132 | // now the car has been saved and the ID has been set 133 | if car.ID == "" { 134 | t.Errorf("car should have an auto generated ID now") 135 | } 136 | } 137 | 138 | func TestRelations(t *testing.T) { 139 | testRunner(t, testRelations_) 140 | } 141 | func testRelations_(ctx context.Context, t *testing.T) { 142 | john := &Person{ID: "JohnsID", Name: "John"} // predefined ID 143 | mary := &Person{ID: "MarysID", Name: "Mary"} 144 | john.Spouse = mary 145 | mary.Spouse = john 146 | 147 | // Creates both values and references 148 | fsc.NewRequest().CreateEntities(ctx, []interface{}{john, mary})() 149 | defer cleanup(john, mary) 150 | 151 | // Reverting to the Firestore API we can test that the ref has been created 152 | snapshot, _ := fsc.Client.Collection("Person").Doc(john.ID).Get(ctx) 153 | if spouseRef, ok := snapshot.Data()["spouse"].(*firestore.DocumentRef); !ok { 154 | t.Errorf("spouse ref should have been a firestore.DocumentRef: %v", spouseRef) 155 | } else { 156 | if spouseRef.ID != mary.ID { 157 | t.Errorf("the id of the spouse ref should have been MarysID: %s", spouseRef.ID) 158 | } 159 | } 160 | } 161 | 162 | func TestAutoLoad(t *testing.T) { 163 | testRunner(t, testAutoLoad_) 164 | } 165 | func testAutoLoad_(ctx context.Context, t *testing.T) { 166 | john := Person{ID: "JohnsID", Name: "John"} // predefined ID 167 | mary := Person{ID: "MarysID", Name: "Mary"} 168 | john.Spouse = &mary 169 | mary.Spouse = &john 170 | car := &Car{ 171 | Make: "Toyota", 172 | Owner: &john, 173 | Driver: Person{Name: "Mark"}, // embedded entity 174 | Passengers: []Person{john, mary}, 175 | Tags: []string{"tag1", "tag2"}, 176 | Numbers: []int{1, 2, 3}, 177 | } 178 | car.Year, _ = time.Parse(time.RFC3339, "2001-01-01T00:00:00.000Z") 179 | 180 | // Creates both values and references 181 | fsc.NewRequest().CreateEntities(ctx, []interface{}{john, mary, car})() 182 | defer cleanup(john, mary, car) 183 | 184 | // Read the entity by ID 185 | otherCar := &Car{ID: car.ID} 186 | fsc.NewRequest().GetEntities(ctx, otherCar)() 187 | if otherCar.Make != "Toyota" && otherCar.Driver.Name == "Mark" && 188 | len(otherCar.Tags) == 2 && len(otherCar.Numbers) == 3 { 189 | t.Errorf("saved element did not match original: %s", otherCar.Make) 190 | } 191 | 192 | // Read the car and its owner in one go. Note passengers are not loaded 193 | otherCar = &Car{ID: car.ID} 194 | fsc.NewRequest().SetLoadPaths("owner").GetEntities(ctx, otherCar)() 195 | if otherCar.Owner.ID != john.ID && len(otherCar.Passengers) == 0 { 196 | t.Errorf("The owners are the same so the IDs should be equal: %s", otherCar.Owner.ID) 197 | } 198 | 199 | // Read all references on the car 200 | otherCar = &Car{ID: car.ID} 201 | fsc.NewRequest().SetLoadPaths(firestorm.AllEntities).GetEntities(ctx, otherCar)() 202 | if otherCar.Owner.ID != john.ID || len(otherCar.Passengers) != 2 || otherCar.Passengers[0].ID != john.ID { 203 | t.Errorf("The owner and passengers should have been loaded: %v", otherCar) 204 | } 205 | 206 | // Also read the Spouses 207 | otherCar = &Car{ID: car.ID} 208 | fsc.NewRequest().SetLoadPaths(firestorm.AllEntities, "passengers.spouse").GetEntities(ctx, otherCar)() 209 | if otherCar.Passengers[0].Spouse == nil || otherCar.Passengers[0].Spouse.ID != mary.ID || 210 | otherCar.Passengers[1].Spouse == nil || otherCar.Passengers[1].Spouse.ID != john.ID { 211 | t.Errorf("The owner and passengers should have been loaded: %v", otherCar) 212 | } 213 | 214 | // Since John's spouse was resolved as being a passenger it is also resolved as the owner 215 | if otherCar.Owner.Spouse.ID != mary.ID { 216 | t.Errorf("The owner and passengers should have been loaded: %v", otherCar) 217 | } 218 | } 219 | 220 | func TestCycles(t *testing.T) { 221 | testRunner(t, testCycles_) 222 | } 223 | func testCycles_(ctx context.Context, t *testing.T) { 224 | john := &Person{ID: "JohnsID", Name: "John"} // predefined ID 225 | mary := &Person{ID: "MarysID", Name: "Mary"} 226 | john.Spouse = mary 227 | mary.Spouse = john 228 | 229 | // Creates both values and references 230 | fsc.NewRequest().CreateEntities(ctx, []interface{}{john, mary})() 231 | defer cleanup(john, mary) 232 | 233 | // Using auto load that is much simpler. Load John and spouse in one go 234 | john = &Person{ID: john.ID} 235 | fsc.NewRequest().SetLoadPaths("spouse").GetEntities(ctx, john)() 236 | if john.Spouse == nil || john.Spouse.ID != mary.ID { 237 | t.Errorf("Johns spouse should have been loaded: %v", john.Spouse) 238 | } 239 | 240 | // Also the back reference has been resolved to john 241 | john = &Person{ID: john.ID} 242 | fsc.NewRequest().SetLoadPaths("spouse", "spouse.spouse").GetEntities(ctx, john)() 243 | if john.Spouse.Spouse.ID != john.ID { 244 | t.Errorf("Johns spouse's spouse should be John: %v", john.Spouse.Spouse) 245 | } 246 | 247 | // Same result but only one round-trip to the database 248 | john = &Person{ID: john.ID} 249 | mary = &Person{ID: mary.ID} 250 | fsc.NewRequest().SetLoadPaths("spouse").GetEntities(ctx, []interface{}{john, mary})() 251 | if john.Spouse.Spouse.ID != john.ID { 252 | t.Errorf("Johns spouse's spouse should be John: %v", john.Spouse.Spouse) 253 | } 254 | } 255 | 256 | func TestTransactions(t *testing.T) { 257 | testRunner(t, testTransactions_) 258 | } 259 | func testTransactions_(ctx context.Context, t *testing.T) { 260 | car := &Car{Make: "Toyota"} 261 | 262 | fsc.DoInTransaction(ctx, func(transCtx context.Context) error { 263 | 264 | // Create the entity in the transaction using the transCtx 265 | fsc.NewRequest().CreateEntities(transCtx, car)() 266 | 267 | // Using the transCtx we can load the entity as it is saved in the session context 268 | otherCar := &Car{ID: car.ID} 269 | fsc.NewRequest().GetEntities(transCtx, otherCar)() 270 | if otherCar.Make != car.Make { 271 | t.Errorf("The car should have been saved in the transaction context") 272 | } 273 | 274 | // Loading using an other context (request) will fail as the car is not created until the func returns successfully 275 | if _, err := fsc.NewRequest().GetEntities(ctx, &Car{ID: car.ID})(); err == nil { 276 | t.Errorf("We expect a notFoundError") 277 | } 278 | 279 | return nil 280 | }) 281 | 282 | defer cleanup(car) 283 | 284 | // Now we can load the car as the transaction has been committed 285 | otherCar := &Car{ID: car.ID} 286 | fsc.NewRequest().GetEntities(ctx, otherCar)() 287 | if otherCar.Make != "Toyota" { 288 | t.Errorf("car should have name: Toyota but was: %s", otherCar.Make) 289 | } 290 | } 291 | 292 | func TestNestedRefs(t *testing.T) { 293 | testRunner(t, testNestedRefs_) 294 | } 295 | func testNestedRefs_(ctx context.Context, t *testing.T) { 296 | john := &Person{Name: "John"} 297 | friend1 := &Person{Name: "Friend1"} 298 | friend2 := &Person{Name: "Friend2"} 299 | 300 | // Creates both values and references 301 | fsc.NewRequest().CreateEntities(ctx, []interface{}{john, friend1, friend2})() 302 | defer cleanup(john, friend1, friend2) 303 | 304 | // Add the nested relation 305 | john.Relations = []*Relation{{Friends: []*Person{friend1, friend2}}} 306 | fsc.NewRequest().UpdateEntities(ctx, john)() 307 | 308 | // Reverting to the Firestore API we can test that the ref has been created 309 | snapshot, _ := fsc.Client.Collection("Person").Doc(john.ID).Get(ctx) 310 | 311 | if relations, ok := snapshot.Data()["relations"].([]interface{}); !ok { 312 | t.Errorf("relations should have been slice of map: %v", relations) 313 | } else { 314 | if rel, ok := relations[0].(map[string]interface{}); !ok { 315 | t.Errorf("rel should have been a map: %v", rel) 316 | } else { 317 | if refs, ok := rel["friends"].([]interface{}); !ok { 318 | t.Errorf("friends ref should have been array of interface: %v", refs) 319 | } else { 320 | if ref, ok := refs[0].(*firestore.DocumentRef); !ok { 321 | t.Errorf("first relation not a *Relation: %v", ref) 322 | } 323 | } 324 | } 325 | } 326 | 327 | p := &Person{ID: john.ID} 328 | fsc.NewRequest().SetLoadPaths("friends").GetEntities(ctx, p)() 329 | if !cmp.Equal(john, p) { 330 | t.Errorf("The structs were not the same %v - %v DIFF: %v", john, p, cmp.Diff(john, p)) 331 | } 332 | } 333 | 334 | type Moao struct { 335 | ID string 336 | unexportedName string 337 | } 338 | type SubMoao struct { 339 | Moao 340 | LocalName string 341 | } 342 | 343 | func TestAnonymousStructs(t *testing.T) { 344 | testRunner(t, testAnonymousStructs_) 345 | } 346 | func testAnonymousStructs_(ctx context.Context, t *testing.T) { 347 | sub := &SubMoao{} 348 | sub.unexportedName = "moao" 349 | sub.LocalName = "sub" 350 | 351 | // Create the entity 352 | fsc.NewRequest().CreateEntities(ctx, sub)() 353 | defer cleanup(sub) 354 | 355 | if sub.ID == "" { 356 | t.Errorf("element should have an auto generated ID") 357 | } 358 | 359 | // Read the entity by ID 360 | otherSub := &SubMoao{} 361 | otherSub.ID = sub.ID 362 | fsc.NewRequest().GetEntities(ctx, otherSub)() 363 | if otherSub.unexportedName != sub.unexportedName { 364 | t.Errorf("name should match: %s", otherSub.unexportedName) 365 | } 366 | if otherSub.LocalName != sub.LocalName { 367 | t.Errorf("name should match: %s", otherSub.LocalName) 368 | } 369 | } 370 | -------------------------------------------------------------------------------- /tests/setup.go: -------------------------------------------------------------------------------- 1 | package firestormtests 2 | 3 | import ( 4 | "context" 5 | "firebase.google.com/go" 6 | "github.com/jschoedt/go-firestorm" 7 | "google.golang.org/api/option" 8 | "io/ioutil" 9 | "testing" 10 | ) 11 | 12 | var fsc *firestorm.FSClient 13 | 14 | func init() { 15 | ctx := context.Background() 16 | 17 | b, _ := ioutil.ReadFile("../auth/sn-dev.json") 18 | 19 | app, _ := firebase.NewApp(ctx, nil, option.WithCredentialsJSON(b)) 20 | 21 | dbClient, _ := app.Firestore(ctx) 22 | 23 | fsc = firestorm.New(dbClient, "ID", "") 24 | } 25 | 26 | func testRunner(t *testing.T, f func(ctx context.Context, t *testing.T)) { 27 | f(context.Background(), t) 28 | f(createSessionCacheContext(), t) 29 | } 30 | 31 | func cleanup(entities ...interface{}) { 32 | fsc.NewRequest().DeleteEntities(context.Background(), entities)() 33 | } 34 | 35 | func createSessionCacheContext() context.Context { 36 | ctx := context.Background() 37 | return context.WithValue(ctx, firestorm.SessionCacheKey, make(map[string]firestorm.EntityMap)) 38 | } 39 | 40 | func getSessionCache(ctx context.Context) map[string]firestorm.EntityMap { 41 | if c, ok := ctx.Value(firestorm.SessionCacheKey).(map[string]firestorm.EntityMap); ok { 42 | return c 43 | } 44 | return nil 45 | } 46 | -------------------------------------------------------------------------------- /utils.go: -------------------------------------------------------------------------------- 1 | package firestorm 2 | 3 | import ( 4 | "cloud.google.com/go/firestore" 5 | ) 6 | 7 | type EntityMap map[string]interface{} 8 | 9 | // Copy returns a 'shallow' copy of the map. 10 | func (entity EntityMap) Copy() EntityMap { 11 | if entity == nil { 12 | return entity 13 | } 14 | m := make(EntityMap, len(entity)) 15 | for k, v := range entity { 16 | m[k] = v 17 | } 18 | return m 19 | } 20 | 21 | type cacheRef struct { 22 | result map[string]interface{} 23 | Ref *firestore.DocumentRef 24 | } 25 | 26 | func (ref cacheRef) GetResult() map[string]interface{} { 27 | return ref.result 28 | } 29 | 30 | func newCacheRef(result map[string]interface{}, ref *firestore.DocumentRef) cacheRef { 31 | return cacheRef{result: result, Ref: ref} 32 | } 33 | --------------------------------------------------------------------------------