From fba718b890d16799ba04bb1516c81739ce9216ff Mon Sep 17 00:00:00 2001 From: thanhhaudev Date: Thu, 31 Oct 2024 16:10:45 +0700 Subject: [PATCH] init data loaders --- go.mod | 3 + go.sum | 6 ++ src/datastore/postgres/author.go | 2 +- src/datastore/postgres/book.go | 9 ++ src/datastore/postgres/borrower.go | 9 ++ src/graph/model/author.go | 6 +- src/graph/model/book.go | 4 + src/graph/model/borrower.go | 4 + src/graph/resolver/author.resolvers.go | 2 + src/graph/resolver/book.resolvers.go | 3 +- src/loader/author.go | 27 ++++++ src/loader/book.go | 28 ++++++ src/loader/borrower.go | 28 ++++++ src/loader/loader.go | 113 +++++++++++++++++++++++++ src/repository/repository.go | 2 + src/server.go | 9 +- 16 files changed, 251 insertions(+), 4 deletions(-) create mode 100644 src/loader/author.go create mode 100644 src/loader/book.go create mode 100644 src/loader/borrower.go create mode 100644 src/loader/loader.go diff --git a/go.mod b/go.mod index d204893..d0c3e0d 100644 --- a/go.mod +++ b/go.mod @@ -36,7 +36,10 @@ require ( github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/sosodev/duration v1.3.1 // indirect github.com/urfave/cli/v2 v2.27.4 // indirect + github.com/vikstrous/dataloadgen v0.0.6 // indirect github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect + go.opentelemetry.io/otel v1.11.1 // indirect + go.opentelemetry.io/otel/trace v1.11.1 // indirect golang.org/x/crypto v0.22.0 // indirect golang.org/x/mod v0.20.0 // indirect golang.org/x/sync v0.8.0 // indirect diff --git a/go.sum b/go.sum index fa5a7ad..98cf6cf 100644 --- a/go.sum +++ b/go.sum @@ -81,8 +81,14 @@ github.com/urfave/cli/v2 v2.27.4 h1:o1owoI+02Eb+K107p27wEX9Bb8eqIoZCfLXloLUSWJ8= github.com/urfave/cli/v2 v2.27.4/go.mod h1:m4QzxcD2qpra4z7WhzEGn74WZLViBnMpb1ToCAKdGRQ= github.com/vektah/gqlparser/v2 v2.5.17 h1:9At7WblLV7/36nulgekUgIaqHZWn5hxqluxrxGUhOmI= github.com/vektah/gqlparser/v2 v2.5.17/go.mod h1:1lz1OeCqgQbQepsGxPVywrjdBHW2T08PUS3pJqepRww= +github.com/vikstrous/dataloadgen v0.0.6 h1:A7s/fI3QNnH80CA9vdNbWK7AsbLjIxNHpZnV+VnOT1s= +github.com/vikstrous/dataloadgen v0.0.6/go.mod h1:8vuQVpBH0ODbMKAPUdCAPcOGezoTIhgAjgex51t4vbg= github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= +go.opentelemetry.io/otel v1.11.1 h1:4WLLAmcfkmDk2ukNXJyq3/kiz/3UzCaYq6PskJsaou4= +go.opentelemetry.io/otel v1.11.1/go.mod h1:1nNhXBbWSD0nsL38H6btgnFN2k4i0sNLHNNMZMSbUGE= +go.opentelemetry.io/otel/trace v1.11.1 h1:ofxdnzsNrGBYXbP7t7zpUK281+go5rF7dvdIZXF8gdQ= +go.opentelemetry.io/otel/trace v1.11.1/go.mod h1:f/Q9G7vzk5u91PhbmKbg1Qn0rzH1LJ4vbPHFGkTPtOk= go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30= diff --git a/src/datastore/postgres/author.go b/src/datastore/postgres/author.go index bff6b1e..1ae7e76 100644 --- a/src/datastore/postgres/author.go +++ b/src/datastore/postgres/author.go @@ -32,7 +32,7 @@ func (a *authorRepository) FindByID(ctx context.Context, id int) (*model.Author, func (a *authorRepository) FindByIDs(ctx context.Context, ids []int) ([]*model.Author, error) { var authors []*model.Author - if err := a.db.WithContext(ctx).Where("id IN (?)", ids).Find(&authors).Error; err != nil { + if err := a.db.WithContext(ctx).Preload("Books").Where("id IN (?)", ids).Find(&authors).Error; err != nil { return nil, err } diff --git a/src/datastore/postgres/book.go b/src/datastore/postgres/book.go index 89b395b..43fe3cc 100644 --- a/src/datastore/postgres/book.go +++ b/src/datastore/postgres/book.go @@ -11,6 +11,15 @@ type bookRepository struct { db *gorm.DB } +func (b *bookRepository) FindByIDs(ctx context.Context, ids []int) ([]*model.Book, error) { + var books []*model.Book + if err := b.db.WithContext(ctx).Preload("Authors").Find(&books, ids).Error; err != nil { + return nil, err + } + + return books, nil +} + func (b *bookRepository) FindBooksByAuthorID(ctx context.Context, authorID int) ([]*model.Book, error) { var author model.Author if err := b.db.WithContext(ctx).Preload("Books").First(&author, authorID).Error; err != nil { diff --git a/src/datastore/postgres/borrower.go b/src/datastore/postgres/borrower.go index 1fd2215..120692a 100644 --- a/src/datastore/postgres/borrower.go +++ b/src/datastore/postgres/borrower.go @@ -31,6 +31,15 @@ func (b *borrowerRepository) FindByID(ctx context.Context, id int) (*model.Borro return &borrower, nil } +func (b *borrowerRepository) FindByIDs(ctx context.Context, ids []int) ([]*model.Borrower, error) { + var borrowers []*model.Borrower + if err := b.db.WithContext(ctx).Find(&borrowers, ids).Error; err != nil { + return nil, err + } + + return borrowers, nil +} + func (b *borrowerRepository) FindBorrowerBooksByID(ctx context.Context, borrowerID int) ([]*model.BorrowerBook, error) { var borrowerBooks []*model.BorrowerBook if err := b.db.WithContext(ctx). diff --git a/src/graph/model/author.go b/src/graph/model/author.go index d51a6c2..8753888 100644 --- a/src/graph/model/author.go +++ b/src/graph/model/author.go @@ -10,7 +10,7 @@ import ( type Author struct { ID int `gorm:"primaryKey"` Name string `json:"name"` - Books []*Book `gorm:"many2many:authors_books" json:"books"` + Books []*Book `gorm:"many2many:authors_books;foreignKey:ID;joinForeignKey:AuthorID;References:ID;JoinReferences:BookID"` CreatedAt time.Time UpdatedAt time.Time DeletedAt gorm.DeletedAt `gorm:"index"` @@ -20,6 +20,10 @@ func (a Author) TableName() string { return "authors" } +func (a *Author) GetID() int { + return a.ID +} + type CreateAuthorInput struct { Name string `json:"name"` Books []int `json:"books"` diff --git a/src/graph/model/book.go b/src/graph/model/book.go index a338639..143528e 100644 --- a/src/graph/model/book.go +++ b/src/graph/model/book.go @@ -111,6 +111,10 @@ func (b Book) TableName() string { return "books" } +func (b *Book) GetID() int { + return b.ID +} + func (b *Book) SetAuthors(authors []*Author) { b.Authors = authors } diff --git a/src/graph/model/borrower.go b/src/graph/model/borrower.go index d4256df..d3257a7 100644 --- a/src/graph/model/borrower.go +++ b/src/graph/model/borrower.go @@ -104,3 +104,7 @@ type Borrower struct { func (b Borrower) TableName() string { return "borrowers" } + +func (b *Borrower) GetID() int { + return b.ID +} diff --git a/src/graph/resolver/author.resolvers.go b/src/graph/resolver/author.resolvers.go index a0f7007..b7365d8 100644 --- a/src/graph/resolver/author.resolvers.go +++ b/src/graph/resolver/author.resolvers.go @@ -12,6 +12,8 @@ import ( ) // Books is the resolver for the books field. +// this is the sub-resolver for the authors field in the Author type. So, it will be called for each author in the list of authors. +// Use the data loader to load the books for the author instead of querying the database directly to optimize the performance. func (r *authorResolver) Books(ctx context.Context, obj *model.Author) ([]*model.Book, error) { books, err := r.bookService.FindBooksByAuthorID(ctx, obj.ID) if err != nil { diff --git a/src/graph/resolver/book.resolvers.go b/src/graph/resolver/book.resolvers.go index dcd4aa5..cb8761c 100644 --- a/src/graph/resolver/book.resolvers.go +++ b/src/graph/resolver/book.resolvers.go @@ -6,12 +6,13 @@ package resolver import ( "context" - "github.com/thanhhaudev/go-graphql/src/graph/generated" "github.com/thanhhaudev/go-graphql/src/graph/model" ) // Authors is the resolver for the authors field. +// this is the sub-resolver for the authors field in the Book type. So, it will be called for each book in the list of books. +// Use the data loader to load the authors for the book instead of querying the database directly to optimize the performance. func (r *bookResolver) Authors(ctx context.Context, obj *model.Book) ([]*model.Author, error) { authors, err := r.authorService.FindAuthorsByBookID(ctx, obj.ID) if err != nil { diff --git a/src/loader/author.go b/src/loader/author.go new file mode 100644 index 0000000..e3c6056 --- /dev/null +++ b/src/loader/author.go @@ -0,0 +1,27 @@ +package loader + +import ( + "context" + "github.com/thanhhaudev/go-graphql/src/graph/model" + "github.com/thanhhaudev/go-graphql/src/repository" +) + +type authorLoader struct { + authorRepository repository.AuthorRepository +} + +// getAuthors implements a batch function that loads authors by IDs +func (a *authorLoader) getAuthors(ctx context.Context, ids []int) ([]*model.Author, []error) { + authors, err := a.authorRepository.FindByIDs(ctx, ids) + if err != nil { + return nil, []error{err} + } + + return mapping(authors, ids), nil +} + +func newAuthorLoader(authorRepository repository.AuthorRepository) *authorLoader { + return &authorLoader{ + authorRepository: authorRepository, + } +} diff --git a/src/loader/book.go b/src/loader/book.go new file mode 100644 index 0000000..08f83e0 --- /dev/null +++ b/src/loader/book.go @@ -0,0 +1,28 @@ +package loader + +import ( + "context" + + "github.com/thanhhaudev/go-graphql/src/graph/model" + "github.com/thanhhaudev/go-graphql/src/repository" +) + +type bookLoader struct { + bookRepository repository.BookRepository +} + +// getBooks implements a batch function that loads books by IDs +func (b *bookLoader) getBooks(ctx context.Context, ids []int) ([]*model.Book, []error) { + books, err := b.bookRepository.GetByIDs(ctx, ids) + if err != nil { + return nil, []error{err} + } + + return mapping(books, ids), nil +} + +func newBookLoader(bookRepository repository.BookRepository) *bookLoader { + return &bookLoader{ + bookRepository: bookRepository, + } +} diff --git a/src/loader/borrower.go b/src/loader/borrower.go new file mode 100644 index 0000000..888fd1c --- /dev/null +++ b/src/loader/borrower.go @@ -0,0 +1,28 @@ +package loader + +import ( + "context" + + "github.com/thanhhaudev/go-graphql/src/graph/model" + "github.com/thanhhaudev/go-graphql/src/repository" +) + +type borrowerLoader struct { + borrowerRepository repository.BorrowerRepository +} + +// getBorrowers implements a batch function that loads borrowers by IDs +func (b *borrowerLoader) getBorrowers(ctx context.Context, ids []int) ([]*model.Borrower, []error) { + borrowers, err := b.borrowerRepository.FindByIDs(ctx, ids) + if err != nil { + return nil, []error{err} + } + + return mapping(borrowers, ids), nil +} + +func newBorrowerLoader(borrowerRepository repository.BorrowerRepository) *borrowerLoader { + return &borrowerLoader{ + borrowerRepository: borrowerRepository, + } +} diff --git a/src/loader/loader.go b/src/loader/loader.go new file mode 100644 index 0000000..4d06526 --- /dev/null +++ b/src/loader/loader.go @@ -0,0 +1,113 @@ +package loader + +import ( + "context" + "net/http" + "time" + + "github.com/thanhhaudev/go-graphql/src/graph/model" + "github.com/thanhhaudev/go-graphql/src/repository" + "github.com/vikstrous/dataloadgen" +) + +type ctxKey string + +type Model interface { + GetID() int +} + +const ( + loadersKey = ctxKey("dataLoaders") +) + +// Loaders wrap your data loaders to inject via middleware +type Loaders struct { + AuthorLoader *dataloadgen.Loader[int, *model.Author] + BookLoader *dataloadgen.Loader[int, *model.Book] + BorrowerLoader *dataloadgen.Loader[int, *model.Borrower] +} + +// NewLoaders instantiates data loaders for the middleware +func NewLoaders( + authorRepository repository.AuthorRepository, + bookRepository repository.BookRepository, + borrowerRepository repository.BorrowerRepository, +) *Loaders { + al := newAuthorLoader(authorRepository) + bl := newBookLoader(bookRepository) + brl := newBorrowerLoader(borrowerRepository) + w := dataloadgen.WithWait(10 * time.Millisecond) + + return &Loaders{ + AuthorLoader: dataloadgen.NewLoader(al.getAuthors, w), + BookLoader: dataloadgen.NewLoader(bl.getBooks, w), + BorrowerLoader: dataloadgen.NewLoader(brl.getBorrowers, w), + } +} + +// Middleware injects data loaders into the context +// This middleware likely injects data loaders into the request context, optimizing data fetching. +// This accessibly allows the resolvers to load data in a batch, reducing the number of queries to the database. +func Middleware( + authorRepository repository.AuthorRepository, + bookRepository repository.BookRepository, + borrowerRepository repository.BorrowerRepository, + next http.Handler, +) http.Handler { + // return a middleware that injects the loader to the request context + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + loader := NewLoaders(authorRepository, bookRepository, borrowerRepository) + r = r.WithContext(context.WithValue(r.Context(), loadersKey, loader)) + + next.ServeHTTP(w, r) + }) +} + +// mapping maps the models to the same order as the input IDs +func mapping[M Model](models []M, ids []int) []M { + // Create a map to easily find authors by ID + idMap := make(map[int]M) + for _, m := range models { + idMap[m.GetID()] = m + } + + // Prepare the result slice in the same order as the input IDs + // This is important because the result will be used in the same order as the input IDs + result := make([]M, len(ids)) + for i, id := range ids { + result[i] = idMap[id] + } + + return result +} + +// For returns the data loader for a given context +func For(ctx context.Context) *Loaders { + return ctx.Value(loadersKey).(*Loaders) +} + +// FindAuthor loads an author by ID +func FindAuthor(ctx context.Context, id int) (*model.Author, error) { + return For(ctx).AuthorLoader.Load(ctx, id) +} + +// GetAuthors loads authors by IDs +func GetAuthors(ctx context.Context, ids []int) ([]*model.Author, error) { + return For(ctx).AuthorLoader.LoadAll(ctx, ids) +} + +func FindBook(ctx context.Context, id int) (*model.Book, error) { + return For(ctx).BookLoader.Load(ctx, id) +} + +func GetBooks(ctx context.Context, ids []int) ([]*model.Book, error) { + return For(ctx).BookLoader.LoadAll(ctx, ids) +} + +func FindBorrower(ctx context.Context, id int) (*model.Borrower, error) { + return For(ctx).BorrowerLoader.Load(ctx, id) +} + +func GetBorrowers(ctx context.Context, ids []int) ([]*model.Borrower, error) { + return For(ctx).BorrowerLoader.LoadAll(ctx, ids) +} diff --git a/src/repository/repository.go b/src/repository/repository.go index e76e5b1..d58c871 100644 --- a/src/repository/repository.go +++ b/src/repository/repository.go @@ -8,6 +8,7 @@ import ( type BookRepository interface { GetAll(ctx context.Context) ([]*model.Book, error) FindByID(ctx context.Context, id int) (*model.Book, error) + FindByIDs(ctx context.Context, ids []int) ([]*model.Book, error) FindBooksByAuthorID(ctx context.Context, authorID int) ([]*model.Book, error) Create(ctx context.Context, m *model.Book) error Update(ctx context.Context, m *model.Book) error @@ -27,6 +28,7 @@ type AuthorRepository interface { type BorrowerRepository interface { GetAll(ctx context.Context) ([]*model.Borrower, error) FindByID(ctx context.Context, id int) (*model.Borrower, error) + FindByIDs(ctx context.Context, ids []int) ([]*model.Borrower, error) FindByTelNumber(ctx context.Context, telNumber string) (*model.Borrower, error) FindBorrowerBooksByID(ctx context.Context, borrowerID int) ([]*model.BorrowerBook, error) Create(ctx context.Context, m *model.Borrower) (*model.Borrower, error) diff --git a/src/server.go b/src/server.go index c0760a1..faa96a0 100644 --- a/src/server.go +++ b/src/server.go @@ -12,6 +12,7 @@ import ( "github.com/thanhhaudev/go-graphql/src/datastore/postgres" "github.com/thanhhaudev/go-graphql/src/graph/generated" "github.com/thanhhaudev/go-graphql/src/graph/resolver" + "github.com/thanhhaudev/go-graphql/src/loader" "github.com/thanhhaudev/go-graphql/src/service" ) @@ -46,10 +47,16 @@ func main() { defer db.Close() // Close database connection when main function exits - srv := handler.NewDefaultServer(generated.NewExecutableSchema(generated.Config{ + // create the query handler + var srv http.Handler = handler.NewDefaultServer(generated.NewExecutableSchema(generated.Config{ Resolvers: resolver.NewResolver(authorService, bookService, borrowerService), })) + // inject data loaders into the context + // wrap the srv handler with the middleware + srv = loader.Middleware(authorRepo, bookRepo, borrowerRepo, srv) + + // route handlers http.Handle("/", playground.Handler("GraphQL playground", "/query")) http.Handle("/query", srv)