├── LICENSE ├── README.md └── main.go /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 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 | # graphql-dataloader-example 2 | 3 | An example project on how to use [github.com/graphql-go/graphql](https://github.com/graphql-go/graphql) with [github.com/graph-gophers/dataloader](https://github.com/graph-gophers/dataloader) to support batching to avoid n+1 queries. 4 | 5 | #### Getting Started 6 | 7 | To run the example: 8 | ```bash 9 | go run main.go 10 | ``` 11 | 12 | Output: 13 | ``` 14 | 2018/09/22 16:03:55 [GetCategoryBatchFn] batch size: 3 15 | 2018/09/22 16:03:55 [GetUserOrdersBatchFn] batch size: 1 16 | 2018/09/22 16:03:55 [GetProductsBatchFn] batch size: 1 17 | 2018/09/22 16:03:55 [GraphQL] result: {"data":{"categories":[{"id":1,"name":"name#1"},{"id":2,"name":"name#2"},{"id":3,"name":"name#3"}],"currentUser":{"firstName":"user#1 first name","id":1,"lastName":"user#1 last name","orders":[{"id":200,"products":[{"categories":[{"id":3,"name":"name#3"},{"id":1,"name":"name#1"},{"id":2,"name":"name#2"}],"id":100,"title":"product#100"}]}]}}} 18 | ``` 19 | 20 | GraphQL result pretty print using [jq](https://github.com/stedolan/jq): 21 | ```bash 22 | 23 | (graphql-dataloader-example)-> echo '{"data":{"categories":[{"id":1,"name":"name#1"},{"id":2,"name":"name#2"},{"id":3,"name":"name#3"}],"currentUser":{"firstName":"user#1 first name","id":1,"lastName":"user#1 last name","orders":[{"id":200,"products":[{"categories":[{"id":3,"name":"name#3"},{"id":1,"name":"name#1"},{"id":2,"name":"name#2"}],"id":100,"title":"product#100"}]}]}}}' | jq 24 | ``` 25 | 26 | ```json 27 | { 28 | "data": { 29 | "categories": [ 30 | { 31 | "id": 1, 32 | "name": "name#1" 33 | }, 34 | { 35 | "id": 2, 36 | "name": "name#2" 37 | }, 38 | { 39 | "id": 3, 40 | "name": "name#3" 41 | } 42 | ], 43 | "currentUser": { 44 | "firstName": "user#1 first name", 45 | "id": 1, 46 | "lastName": "user#1 last name", 47 | "orders": [ 48 | { 49 | "id": 200, 50 | "products": [ 51 | { 52 | "categories": [ 53 | { 54 | "id": 3, 55 | "name": "name#3" 56 | }, 57 | { 58 | "id": 1, 59 | "name": "name#1" 60 | }, 61 | { 62 | "id": 2, 63 | "name": "name#2" 64 | } 65 | ], 66 | "id": 100, 67 | "title": "product#100" 68 | } 69 | ] 70 | } 71 | ] 72 | } 73 | } 74 | } 75 | ``` 76 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "log" 9 | "strconv" 10 | "strings" 11 | "sync" 12 | 13 | "github.com/graph-gophers/dataloader" 14 | "github.com/graphql-go/graphql" 15 | ) 16 | 17 | type Category struct { 18 | ID uint 19 | Name string 20 | } 21 | 22 | type Product struct { 23 | ID uint 24 | Title string 25 | ProductCategories []ProductCategory 26 | } 27 | 28 | type ProductCategory struct { 29 | CategoryID uint 30 | ProductID uint 31 | } 32 | 33 | type Order struct { 34 | ID uint 35 | UserID uint 36 | OrderItems []OrderItem 37 | } 38 | 39 | type OrderItem struct { 40 | OrderID uint 41 | ProductID uint 42 | } 43 | 44 | type User struct { 45 | ID uint 46 | FirstName string 47 | LastName string 48 | Orders []Order 49 | } 50 | 51 | type Client struct { 52 | } 53 | 54 | func (c *Client) ListUserOrders(userIDs []uint) ([]Order, error) { 55 | return []Order{ 56 | Order{ 57 | ID: 200, 58 | UserID: 1, 59 | OrderItems: []OrderItem{ 60 | OrderItem{OrderID: 200, ProductID: 100}, 61 | }, 62 | }, 63 | }, nil 64 | } 65 | 66 | func (c *Client) ListCategories(categoryIDs []uint) ([]Category, error) { 67 | var categories []Category 68 | for _, categoryID := range categoryIDs { 69 | c := Category{ID: categoryID, Name: fmt.Sprintf("name#%v", categoryID)} 70 | categories = append(categories, c) 71 | } 72 | return categories, nil 73 | } 74 | 75 | func (c *Client) ListProducts(productIDs []uint) ([]Product, error) { 76 | var products []Product 77 | for _, productID := range productIDs { 78 | product := Product{ 79 | ID: productID, 80 | Title: fmt.Sprintf("product#%v", productID), 81 | ProductCategories: []ProductCategory{ 82 | ProductCategory{ProductID: productID, CategoryID: 1}, 83 | ProductCategory{ProductID: productID, CategoryID: 2}, 84 | ProductCategory{ProductID: productID, CategoryID: 3}, 85 | }, 86 | } 87 | products = append(products, product) 88 | } 89 | return products, nil 90 | } 91 | 92 | type ResolverKey struct { 93 | Key string 94 | Client *Client 95 | } 96 | 97 | func (rk *ResolverKey) client() *Client { 98 | return rk.Client 99 | } 100 | 101 | func NewResolverKey(key string, client *Client) *ResolverKey { 102 | return &ResolverKey{ 103 | Key: key, 104 | Client: client, 105 | } 106 | } 107 | 108 | func (rk *ResolverKey) String() string { 109 | return rk.Key 110 | } 111 | 112 | func (rk *ResolverKey) Raw() interface{} { 113 | return rk.Key 114 | } 115 | 116 | var CategoryType = graphql.NewObject(graphql.ObjectConfig{ 117 | Name: "Category", 118 | Fields: graphql.Fields{ 119 | "id": &graphql.Field{Type: graphql.Int}, 120 | "name": &graphql.Field{Type: graphql.String}, 121 | }, 122 | }) 123 | 124 | var ProductType = graphql.NewObject(graphql.ObjectConfig{ 125 | Name: "Product", 126 | Fields: graphql.Fields{ 127 | "id": &graphql.Field{Type: graphql.Int}, 128 | "title": &graphql.Field{Type: graphql.String}, 129 | "categories": &graphql.Field{ 130 | Type: graphql.NewList(CategoryType), 131 | Resolve: func(p graphql.ResolveParams) (interface{}, error) { 132 | var ( 133 | product = p.Source.(Product) 134 | v = p.Context.Value 135 | loaders = v("loaders").(map[string]*dataloader.Loader) 136 | c = v("client").(*Client) 137 | thunks []dataloader.Thunk 138 | wg sync.WaitGroup 139 | handleErrors = func(errors []error) error { 140 | var errs []string 141 | for _, e := range errors { 142 | errs = append(errs, e.Error()) 143 | } 144 | return fmt.Errorf(strings.Join(errs, "\n")) 145 | } 146 | ) 147 | 148 | for _, productCategory := range product.ProductCategories { 149 | id := productCategory.CategoryID 150 | key := NewResolverKey(fmt.Sprintf("%d", id), c) 151 | // Here we could use `.LoadMany` 152 | // like: `categories` Resolve function. 153 | // But using `.Load` instead to demostrate that 154 | // queries are batched. 155 | thunk := loaders["GetCategory"].Load(p.Context, key) 156 | thunks = append(thunks, thunk) 157 | } 158 | 159 | type result struct { 160 | categories []Category 161 | errs []error 162 | } 163 | ch := make(chan *result, 1) 164 | 165 | go func() { 166 | var categories []Category 167 | var errs []error 168 | for _, thunk := range thunks { 169 | wg.Add(1) 170 | go func(t dataloader.Thunk) { 171 | defer wg.Done() 172 | r, err := t() 173 | if err != nil { 174 | errs = append(errs, err) 175 | return 176 | } 177 | c := r.(Category) 178 | categories = append(categories, c) 179 | }(thunk) 180 | 181 | } 182 | wg.Wait() 183 | ch <- &result{categories: categories, errs: errs} 184 | }() 185 | 186 | return func() (interface{}, error) { 187 | r := <-ch 188 | if len(r.errs) > 0 { 189 | return nil, handleErrors(r.errs) 190 | } 191 | return r.categories, nil 192 | }, nil 193 | 194 | }, 195 | }, 196 | }, 197 | }) 198 | 199 | var OrderType = graphql.NewObject(graphql.ObjectConfig{ 200 | Name: "Order", 201 | Fields: graphql.Fields{ 202 | "id": &graphql.Field{Type: graphql.Int}, 203 | "products": &graphql.Field{ 204 | Type: graphql.NewList(ProductType), 205 | Resolve: func(p graphql.ResolveParams) (interface{}, error) { 206 | var ( 207 | productIDs []uint 208 | order = p.Source.(Order) 209 | keys dataloader.Keys 210 | v = p.Context.Value 211 | loaders = v("loaders").(map[string]*dataloader.Loader) 212 | c = v("client").(*Client) 213 | 214 | handleErrors = func(errors []error) error { 215 | var errs []string 216 | for _, e := range errors { 217 | errs = append(errs, e.Error()) 218 | } 219 | return fmt.Errorf(strings.Join(errs, "\n")) 220 | } 221 | ) 222 | 223 | for _, orderItem := range order.OrderItems { 224 | productIDs = append(productIDs, orderItem.ProductID) 225 | } 226 | 227 | for _, productID := range productIDs { 228 | key := NewResolverKey(fmt.Sprintf("%d", productID), c) 229 | keys = append(keys, key) 230 | } 231 | 232 | thunk := loaders["GetProducts"].LoadMany(p.Context, keys) 233 | return func() (interface{}, error) { 234 | products, errs := thunk() 235 | if len(errs) > 0 { 236 | return nil, handleErrors(errs) 237 | } 238 | return products, nil 239 | }, nil 240 | }, 241 | }, 242 | }, 243 | }) 244 | 245 | var UserType = graphql.NewObject(graphql.ObjectConfig{ 246 | Name: "User", 247 | Fields: graphql.Fields{ 248 | "id": &graphql.Field{Type: graphql.Int}, 249 | "firstName": &graphql.Field{Type: graphql.String}, 250 | "lastName": &graphql.Field{Type: graphql.String}, 251 | "orders": &graphql.Field{ 252 | Type: graphql.NewList(OrderType), 253 | Resolve: func(p graphql.ResolveParams) (interface{}, error) { 254 | var ( 255 | v = p.Context.Value 256 | c = v("client").(*Client) 257 | loaders = v("loaders").(map[string]*dataloader.Loader) 258 | user = p.Source.(*User) 259 | key = NewResolverKey(fmt.Sprintf("%d", user.ID), c) 260 | ) 261 | thunk := loaders["GetUserOrders"].Load(p.Context, key) 262 | return func() (interface{}, error) { 263 | return thunk() 264 | }, nil 265 | }, 266 | }, 267 | }, 268 | }) 269 | 270 | var QueryType = graphql.NewObject(graphql.ObjectConfig{ 271 | Name: "Query", 272 | Fields: graphql.Fields{ 273 | "currentUser": &graphql.Field{ 274 | Type: UserType, 275 | Resolve: func(p graphql.ResolveParams) (interface{}, error) { 276 | var user = p.Context.Value("currentUser").(*User) 277 | return user, nil 278 | }, 279 | }, 280 | "categories": &graphql.Field{ 281 | Type: graphql.NewList(CategoryType), 282 | Resolve: func(p graphql.ResolveParams) (interface{}, error) { 283 | var ( 284 | // Same categories from `Client.ListCategories` 285 | // with ID (1, 2, 3). 286 | // `GetCategoryBatchFn` batch them. 287 | categoryIDs []uint = []uint{1, 2, 3} 288 | keys dataloader.Keys 289 | v = p.Context.Value 290 | loaders = v("loaders").(map[string]*dataloader.Loader) 291 | c = v("client").(*Client) 292 | 293 | handleErrors = func(errors []error) error { 294 | var errs []string 295 | for _, e := range errors { 296 | errs = append(errs, e.Error()) 297 | } 298 | return fmt.Errorf(strings.Join(errs, "\n")) 299 | } 300 | ) 301 | 302 | for _, categoryID := range categoryIDs { 303 | key := NewResolverKey(fmt.Sprintf("%d", 304 | categoryID), c) 305 | keys = append(keys, key) 306 | } 307 | 308 | thunk := loaders["GetCategory"].LoadMany(p.Context, keys) 309 | return func() (interface{}, error) { 310 | categories, errs := thunk() 311 | if len(errs) > 0 { 312 | return nil, handleErrors(errs) 313 | } 314 | return categories, nil 315 | }, nil 316 | }, 317 | }, 318 | }, 319 | }) 320 | 321 | func main() { 322 | schema, err := graphql.NewSchema(graphql.SchemaConfig{ 323 | Query: QueryType, 324 | }) 325 | if err != nil { 326 | log.Fatal(err) 327 | } 328 | var loaders = make(map[string]*dataloader.Loader, 1) 329 | var client = Client{} 330 | loaders["GetUserOrders"] = dataloader.NewBatchedLoader(GetUserOrdersBatchFn) 331 | loaders["GetCategory"] = dataloader.NewBatchedLoader(GetCategoryBatchFn) 332 | loaders["GetProducts"] = dataloader.NewBatchedLoader(GetProductsBatchFn) 333 | query := ` 334 | query { 335 | currentUser { 336 | id 337 | firstName 338 | lastName 339 | orders { 340 | id 341 | products { 342 | id 343 | title 344 | categories { 345 | id 346 | name 347 | } 348 | } 349 | } 350 | } 351 | categories { 352 | id 353 | name 354 | } 355 | } 356 | ` 357 | currentUser := User{ 358 | ID: 1, 359 | FirstName: "user#1 first name", 360 | LastName: "user#1 last name", 361 | } 362 | ctx := context.WithValue(context.Background(), "currentUser", ¤tUser) 363 | ctx = context.WithValue(ctx, "loaders", loaders) 364 | ctx = context.WithValue(ctx, "client", &client) 365 | result := graphql.Do(graphql.Params{ 366 | Context: ctx, 367 | RequestString: query, 368 | Schema: schema, 369 | }) 370 | b, err := json.Marshal(result) 371 | if err != nil { 372 | log.Fatal(err) 373 | } 374 | log.Printf("[GraphQL] result: %s", b) 375 | } 376 | 377 | func GetProductsBatchFn(ctx context.Context, keys dataloader.Keys) []*dataloader.Result { 378 | handleError := func(err error) []*dataloader.Result { 379 | var results []*dataloader.Result 380 | var result dataloader.Result 381 | result.Error = err 382 | results = append(results, &result) 383 | return results 384 | } 385 | var productIDs []uint 386 | for _, key := range keys { 387 | id, err := strconv.ParseUint(key.String(), 10, 32) 388 | if err != nil { 389 | return handleError(err) 390 | } 391 | productIDs = append(productIDs, uint(id)) 392 | } 393 | products, err := keys[0].(*ResolverKey).client().ListProducts(productIDs) 394 | if err != nil { 395 | return handleError(err) 396 | } 397 | 398 | var productsMap = make(map[uint]Product, len(productIDs)) 399 | for _, product := range products { 400 | productsMap[product.ID] = product 401 | } 402 | 403 | var results []*dataloader.Result 404 | for _, productID := range productIDs { 405 | product, ok := productsMap[productID] 406 | if !ok { 407 | err := errors.New(fmt.Sprintf("product not found, "+ 408 | "product_id: %d", productID)) 409 | return handleError(err) 410 | } 411 | result := dataloader.Result{ 412 | Data: product, 413 | Error: nil, 414 | } 415 | results = append(results, &result) 416 | } 417 | log.Printf("[GetProductsBatchFn] batch size: %d", len(results)) 418 | return results 419 | } 420 | 421 | func GetUserOrdersBatchFn(ctx context.Context, keys dataloader.Keys) []*dataloader.Result { 422 | handleError := func(err error) []*dataloader.Result { 423 | var results []*dataloader.Result 424 | var result dataloader.Result 425 | result.Error = err 426 | results = append(results, &result) 427 | return results 428 | } 429 | var userIDs []uint 430 | for _, key := range keys { 431 | id, err := strconv.ParseUint(key.String(), 10, 32) 432 | if err != nil { 433 | return handleError(err) 434 | } 435 | userIDs = append(userIDs, uint(id)) 436 | } 437 | orders, err := keys[0].(*ResolverKey).client().ListUserOrders(userIDs) 438 | if err != nil { 439 | return handleError(err) 440 | } 441 | 442 | var usersMap = make(map[uint][]Order, len(userIDs)) 443 | for _, order := range orders { 444 | if _, found := usersMap[order.UserID]; found { 445 | usersMap[order.UserID] = append( 446 | usersMap[order.UserID], order) 447 | } else { 448 | usersMap[order.UserID] = []Order{order} 449 | } 450 | } 451 | 452 | var results []*dataloader.Result 453 | for _, userID := range userIDs { 454 | orders, ok := usersMap[userID] 455 | if !ok { 456 | err := errors.New(fmt.Sprintf("orders not found, "+ 457 | "user id: %d", userID)) 458 | return handleError(err) 459 | } 460 | result := dataloader.Result{ 461 | Data: orders, 462 | Error: nil, 463 | } 464 | results = append(results, &result) 465 | } 466 | log.Printf("[GetUserOrdersBatchFn] batch size: %d", len(results)) 467 | return results 468 | } 469 | 470 | func GetCategoryBatchFn(ctx context.Context, keys dataloader.Keys) []*dataloader.Result { 471 | var results []*dataloader.Result 472 | handleError := func(err error) []*dataloader.Result { 473 | var results []*dataloader.Result 474 | var result dataloader.Result 475 | result.Error = err 476 | results = append(results, &result) 477 | return results 478 | } 479 | var categoryIDs []uint 480 | for _, key := range keys { 481 | id, err := strconv.ParseUint(key.String(), 10, 32) 482 | if err != nil { 483 | return handleError(err) 484 | } 485 | categoryIDs = append(categoryIDs, uint(id)) 486 | } 487 | categories, err := keys[0].(*ResolverKey).client().ListCategories(categoryIDs) 488 | if err != nil { 489 | return handleError(err) 490 | } 491 | var categoryMap = make(map[uint]Category, len(categoryIDs)) 492 | for _, category := range categories { 493 | categoryMap[category.ID] = category 494 | } 495 | for _, category := range categories { 496 | category = categoryMap[category.ID] 497 | result := dataloader.Result{ 498 | Data: category, 499 | Error: nil, 500 | } 501 | results = append(results, &result) 502 | } 503 | log.Printf("[GetCategoryBatchFn] batch size: %d", len(results)) 504 | return results 505 | } 506 | --------------------------------------------------------------------------------