From 5277353e3b101bcc51f7cfc9a6d612f6150cfaaa Mon Sep 17 00:00:00 2001 From: Drew DeVault Date: Sun, 12 Apr 2020 16:17:07 -0400 Subject: [PATCH] Implement repositories by ID --- graphql/graph/generated/generated.go | 179 +++++++++++++- graphql/graph/model/repository.go | 24 ++ graphql/graph/schema.graphqls | 7 + graphql/graph/schema.resolvers.go | 10 +- graphql/loaders/middleware.go | 59 ++++- graphql/loaders/repositoriesbyidloader_gen.go | 224 ++++++++++++++++++ graphql/server.go | 3 + 7 files changed, 496 insertions(+), 10 deletions(-) create mode 100644 graphql/loaders/repositoriesbyidloader_gen.go diff --git a/graphql/graph/generated/generated.go b/graphql/graph/generated/generated.go index 170cb26..59be72e 100644 --- a/graphql/graph/generated/generated.go +++ b/graphql/graph/generated/generated.go @@ -97,11 +97,13 @@ type ComplexityRoot struct { } Query struct { - Me func(childComplexity int) int - Repositories func(childComplexity int, next *int, filter *model.FilterBy) int - Repository func(childComplexity int, id int) int - User func(childComplexity int, username string) int - Version func(childComplexity int) int + Me func(childComplexity int) int + Repositories func(childComplexity int, next *int, filter *model.FilterBy) int + Repository func(childComplexity int, id int) int + RepositoryByName func(childComplexity int, name string) int + RepositoryByOwner func(childComplexity int, owner string, repo string) int + User func(childComplexity int, username string) int + Version func(childComplexity int) int } Reference struct { @@ -196,6 +198,8 @@ type QueryResolver interface { User(ctx context.Context, username string) (*model.User, error) Repositories(ctx context.Context, next *int, filter *model.FilterBy) ([]*model.Repository, error) Repository(ctx context.Context, id int) (*model.Repository, error) + RepositoryByName(ctx context.Context, name string) (*model.Repository, error) + RepositoryByOwner(ctx context.Context, owner string, repo string) (*model.Repository, error) } type RepositoryResolver interface { Owner(ctx context.Context, obj *model.Repository) (model.Entity, error) @@ -525,6 +529,30 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Query.Repository(childComplexity, args["id"].(int)), true + case "Query.repositoryByName": + if e.complexity.Query.RepositoryByName == nil { + break + } + + args, err := ec.field_Query_repositoryByName_args(context.TODO(), rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Query.RepositoryByName(childComplexity, args["name"].(string)), true + + case "Query.repositoryByOwner": + if e.complexity.Query.RepositoryByOwner == nil { + break + } + + args, err := ec.field_Query_repositoryByOwner_args(context.TODO(), rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Query.RepositoryByOwner(childComplexity, args["owner"].(string), args["repo"].(string)), true + case "Query.user": if e.complexity.Query.User == nil { break @@ -1254,6 +1282,13 @@ type Query { # Returns a specific repository repository(id: Int!): Repository + + # Returns a specific repository, owned by the authenticated user. + repositoryByName(name: String!): Repository + + # Returns a specific repository, owned by the given canonical name (e.g. + # "~sircmpwn"). + repositoryByOwner(owner: String!, repo: String!): Repository } # Details for repository creation or updates @@ -1475,6 +1510,42 @@ func (ec *executionContext) field_Query_repositories_args(ctx context.Context, r return args, nil } +func (ec *executionContext) field_Query_repositoryByName_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { + var err error + args := map[string]interface{}{} + var arg0 string + if tmp, ok := rawArgs["name"]; ok { + arg0, err = ec.unmarshalNString2string(ctx, tmp) + if err != nil { + return nil, err + } + } + args["name"] = arg0 + return args, nil +} + +func (ec *executionContext) field_Query_repositoryByOwner_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { + var err error + args := map[string]interface{}{} + var arg0 string + if tmp, ok := rawArgs["owner"]; ok { + arg0, err = ec.unmarshalNString2string(ctx, tmp) + if err != nil { + return nil, err + } + } + args["owner"] = arg0 + var arg1 string + if tmp, ok := rawArgs["repo"]; ok { + arg1, err = ec.unmarshalNString2string(ctx, tmp) + if err != nil { + return nil, err + } + } + args["repo"] = arg1 + return args, nil +} + func (ec *executionContext) field_Query_repository_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { var err error args := map[string]interface{}{} @@ -3099,6 +3170,82 @@ func (ec *executionContext) _Query_repository(ctx context.Context, field graphql return ec.marshalORepository2ᚖgitᚗsrᚗhtᚋאsircmpwnᚋgitᚗsrᚗhtᚋgraphqlᚋgraphᚋmodelᚐRepository(ctx, field.Selections, res) } +func (ec *executionContext) _Query_repositoryByName(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "Query", + Field: field, + Args: nil, + IsMethod: true, + } + + ctx = graphql.WithFieldContext(ctx, fc) + rawArgs := field.ArgumentMap(ec.Variables) + args, err := ec.field_Query_repositoryByName_args(ctx, rawArgs) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + fc.Args = args + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Query().RepositoryByName(rctx, args["name"].(string)) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*model.Repository) + fc.Result = res + return ec.marshalORepository2ᚖgitᚗsrᚗhtᚋאsircmpwnᚋgitᚗsrᚗhtᚋgraphqlᚋgraphᚋmodelᚐRepository(ctx, field.Selections, res) +} + +func (ec *executionContext) _Query_repositoryByOwner(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + fc := &graphql.FieldContext{ + Object: "Query", + Field: field, + Args: nil, + IsMethod: true, + } + + ctx = graphql.WithFieldContext(ctx, fc) + rawArgs := field.ArgumentMap(ec.Variables) + args, err := ec.field_Query_repositoryByOwner_args(ctx, rawArgs) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + fc.Args = args + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Query().RepositoryByOwner(rctx, args["owner"].(string), args["repo"].(string)) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*model.Repository) + fc.Result = res + return ec.marshalORepository2ᚖgitᚗsrᚗhtᚋאsircmpwnᚋgitᚗsrᚗhtᚋgraphqlᚋgraphᚋmodelᚐRepository(ctx, field.Selections, res) +} + func (ec *executionContext) _Query___type(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { defer func() { if r := recover(); r != nil { @@ -6477,6 +6624,28 @@ func (ec *executionContext) _Query(ctx context.Context, sel ast.SelectionSet) gr res = ec._Query_repository(ctx, field) return res }) + case "repositoryByName": + field := field + out.Concurrently(i, func() (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Query_repositoryByName(ctx, field) + return res + }) + case "repositoryByOwner": + field := field + out.Concurrently(i, func() (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Query_repositoryByOwner(ctx, field) + return res + }) case "__type": out.Values[i] = ec._Query___type(ctx, field) case "__schema": diff --git a/graphql/graph/model/repository.go b/graphql/graph/model/repository.go index f0ce498..feb6cae 100644 --- a/graphql/graph/model/repository.go +++ b/graphql/graph/model/repository.go @@ -27,6 +27,30 @@ type Repository struct { repo *git.Repository } +func (r *Repository) Rows() string { + return ` + repo.id, + repo.created, repo.updated, + repo.name, repo.description, + repo.visibility, + repo.upstream_uri, + repo.path, + repo.owner_id + ` +} + +func (r *Repository) Fields() []interface{} { + return []interface{}{ + &r.ID, + &r.Created, &r.Updated, + &r.Name, &r.Description, + &r.Visibility, + &r.UpstreamURL, + &r.Path, + &r.OwnerID, + } +} + func (r *Repository) Repo() *git.Repository { if r.repo != nil { return r.repo diff --git a/graphql/graph/schema.graphqls b/graphql/graph/schema.graphqls index c9443b9..0cb12e2 100644 --- a/graphql/graph/schema.graphqls +++ b/graphql/graph/schema.graphqls @@ -240,6 +240,13 @@ type Query { # Returns a specific repository repository(id: Int!): Repository + + # Returns a specific repository, owned by the authenticated user. + repositoryByName(name: String!): Repository + + # Returns a specific repository, owned by the given canonical name (e.g. + # "~sircmpwn"). + repositoryByOwner(owner: String!, repo: String!): Repository } # Details for repository creation or updates diff --git a/graphql/graph/schema.resolvers.go b/graphql/graph/schema.resolvers.go index dcf7b0a..d146590 100644 --- a/graphql/graph/schema.resolvers.go +++ b/graphql/graph/schema.resolvers.go @@ -77,11 +77,19 @@ func (r *queryResolver) Repositories(ctx context.Context, next *int, filter *mod } func (r *queryResolver) Repository(ctx context.Context, id int) (*model.Repository, error) { + return loaders.ForContext(ctx).RepositoriesByID.Load(id) +} + +func (r *queryResolver) RepositoryByName(ctx context.Context, name string) (*model.Repository, error) { + panic(fmt.Errorf("not implemented")) +} + +func (r *queryResolver) RepositoryByOwner(ctx context.Context, owner string, repo string) (*model.Repository, error) { panic(fmt.Errorf("not implemented")) } func (r *repositoryResolver) Owner(ctx context.Context, obj *model.Repository) (model.Entity, error) { - return loaders.ForContext(ctx).UsersById.Load(obj.OwnerID) + return loaders.ForContext(ctx).UsersByID.Load(obj.OwnerID) } func (r *repositoryResolver) References(ctx context.Context, obj *model.Repository, count *int, next *string, glob *string) ([]*model.Reference, error) { diff --git a/graphql/loaders/middleware.go b/graphql/loaders/middleware.go index ce55187..d4dc503 100644 --- a/graphql/loaders/middleware.go +++ b/graphql/loaders/middleware.go @@ -18,10 +18,11 @@ type contextKey struct { } type Loaders struct { - UsersById UserLoader + UsersByID UserLoader + RepositoriesByID RepositoriesByIDLoader } -func fetchUsersById(ctx context.Context, +func fetchUsersByID(ctx context.Context, db *sql.DB) func (ids []int) ([]*model.User, []error) { return func (ids []int) ([]*model.User, []error) { @@ -59,14 +60,64 @@ func fetchUsersById(ctx context.Context, } } +func fetchRepositoriesByID(ctx context.Context, + db *sql.DB) func (ids []int) ([]*model.Repository, []error) { + + return func (ids []int) ([]*model.Repository, []error) { + var ( + err error + rows *sql.Rows + repo model.Repository + ) + if rows, err = db.QueryContext(ctx, ` + SELECT DISTINCT `+repo.Rows()+` + FROM repository repo + FULL OUTER JOIN + access ON repo.id = access.repo_id + WHERE + repo.id = ANY($1) + AND (access.user_id = 1 + OR repo.owner_id = 1 + OR repo.visibility != 'private') + `, pq.Array(ids)); err != nil { + panic(err) + } + defer rows.Close() + + reposById := map[int]*model.Repository{} + for rows.Next() { + repo := model.Repository{} + if err := rows.Scan(repo.Fields()...); err != nil { + panic(err) + } + reposById[repo.ID] = &repo + } + if err = rows.Err(); err != nil { + panic(err) + } + + repos := make([]*model.Repository, len(ids)) + for i, id := range ids { + repos[i] = reposById[id] + } + + return repos, nil + } +} + func Middleware(db *sql.DB) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ctx := context.WithValue(r.Context(), loadersCtxKey, &Loaders{ - UsersById: UserLoader{ + UsersByID: UserLoader{ + maxBatch: 100, + wait: 1 * time.Millisecond, + fetch: fetchUsersByID(r.Context(), db), + }, + RepositoriesByID: RepositoriesByIDLoader{ maxBatch: 100, wait: 1 * time.Millisecond, - fetch: fetchUsersById(r.Context(), db), + fetch: fetchRepositoriesByID(r.Context(), db), }, }) r = r.WithContext(ctx) diff --git a/graphql/loaders/repositoriesbyidloader_gen.go b/graphql/loaders/repositoriesbyidloader_gen.go new file mode 100644 index 0000000..10d77ae --- /dev/null +++ b/graphql/loaders/repositoriesbyidloader_gen.go @@ -0,0 +1,224 @@ +// Code generated by github.com/vektah/dataloaden, DO NOT EDIT. + +package loaders + +import ( + "sync" + "time" + + "git.sr.ht/~sircmpwn/git.sr.ht/graphql/graph/model" +) + +// RepositoriesByIDLoaderConfig captures the config to create a new RepositoriesByIDLoader +type RepositoriesByIDLoaderConfig struct { + // Fetch is a method that provides the data for the loader + Fetch func(keys []int) ([]*model.Repository, []error) + + // Wait is how long wait before sending a batch + Wait time.Duration + + // MaxBatch will limit the maximum number of keys to send in one batch, 0 = not limit + MaxBatch int +} + +// NewRepositoriesByIDLoader creates a new RepositoriesByIDLoader given a fetch, wait, and maxBatch +func NewRepositoriesByIDLoader(config RepositoriesByIDLoaderConfig) *RepositoriesByIDLoader { + return &RepositoriesByIDLoader{ + fetch: config.Fetch, + wait: config.Wait, + maxBatch: config.MaxBatch, + } +} + +// RepositoriesByIDLoader batches and caches requests +type RepositoriesByIDLoader struct { + // this method provides the data for the loader + fetch func(keys []int) ([]*model.Repository, []error) + + // how long to done before sending a batch + wait time.Duration + + // this will limit the maximum number of keys to send in one batch, 0 = no limit + maxBatch int + + // INTERNAL + + // lazily created cache + cache map[int]*model.Repository + + // the current batch. keys will continue to be collected until timeout is hit, + // then everything will be sent to the fetch method and out to the listeners + batch *repositoriesByIDLoaderBatch + + // mutex to prevent races + mu sync.Mutex +} + +type repositoriesByIDLoaderBatch struct { + keys []int + data []*model.Repository + error []error + closing bool + done chan struct{} +} + +// Load a Repository by key, batching and caching will be applied automatically +func (l *RepositoriesByIDLoader) Load(key int) (*model.Repository, error) { + return l.LoadThunk(key)() +} + +// LoadThunk returns a function that when called will block waiting for a Repository. +// This method should be used if you want one goroutine to make requests to many +// different data loaders without blocking until the thunk is called. +func (l *RepositoriesByIDLoader) LoadThunk(key int) func() (*model.Repository, error) { + l.mu.Lock() + if it, ok := l.cache[key]; ok { + l.mu.Unlock() + return func() (*model.Repository, error) { + return it, nil + } + } + if l.batch == nil { + l.batch = &repositoriesByIDLoaderBatch{done: make(chan struct{})} + } + batch := l.batch + pos := batch.keyIndex(l, key) + l.mu.Unlock() + + return func() (*model.Repository, error) { + <-batch.done + + var data *model.Repository + if pos < len(batch.data) { + data = batch.data[pos] + } + + var err error + // its convenient to be able to return a single error for everything + if len(batch.error) == 1 { + err = batch.error[0] + } else if batch.error != nil { + err = batch.error[pos] + } + + if err == nil { + l.mu.Lock() + l.unsafeSet(key, data) + l.mu.Unlock() + } + + return data, err + } +} + +// LoadAll fetches many keys at once. It will be broken into appropriate sized +// sub batches depending on how the loader is configured +func (l *RepositoriesByIDLoader) LoadAll(keys []int) ([]*model.Repository, []error) { + results := make([]func() (*model.Repository, error), len(keys)) + + for i, key := range keys { + results[i] = l.LoadThunk(key) + } + + repositorys := make([]*model.Repository, len(keys)) + errors := make([]error, len(keys)) + for i, thunk := range results { + repositorys[i], errors[i] = thunk() + } + return repositorys, errors +} + +// LoadAllThunk returns a function that when called will block waiting for a Repositorys. +// This method should be used if you want one goroutine to make requests to many +// different data loaders without blocking until the thunk is called. +func (l *RepositoriesByIDLoader) LoadAllThunk(keys []int) func() ([]*model.Repository, []error) { + results := make([]func() (*model.Repository, error), len(keys)) + for i, key := range keys { + results[i] = l.LoadThunk(key) + } + return func() ([]*model.Repository, []error) { + repositorys := make([]*model.Repository, len(keys)) + errors := make([]error, len(keys)) + for i, thunk := range results { + repositorys[i], errors[i] = thunk() + } + return repositorys, errors + } +} + +// Prime the cache with the provided key and value. If the key already exists, no change is made +// and false is returned. +// (To forcefully prime the cache, clear the key first with loader.clear(key).prime(key, value).) +func (l *RepositoriesByIDLoader) Prime(key int, value *model.Repository) bool { + l.mu.Lock() + var found bool + if _, found = l.cache[key]; !found { + // make a copy when writing to the cache, its easy to pass a pointer in from a loop var + // and end up with the whole cache pointing to the same value. + cpy := *value + l.unsafeSet(key, &cpy) + } + l.mu.Unlock() + return !found +} + +// Clear the value at key from the cache, if it exists +func (l *RepositoriesByIDLoader) Clear(key int) { + l.mu.Lock() + delete(l.cache, key) + l.mu.Unlock() +} + +func (l *RepositoriesByIDLoader) unsafeSet(key int, value *model.Repository) { + if l.cache == nil { + l.cache = map[int]*model.Repository{} + } + l.cache[key] = value +} + +// keyIndex will return the location of the key in the batch, if its not found +// it will add the key to the batch +func (b *repositoriesByIDLoaderBatch) keyIndex(l *RepositoriesByIDLoader, key int) int { + for i, existingKey := range b.keys { + if key == existingKey { + return i + } + } + + pos := len(b.keys) + b.keys = append(b.keys, key) + if pos == 0 { + go b.startTimer(l) + } + + if l.maxBatch != 0 && pos >= l.maxBatch-1 { + if !b.closing { + b.closing = true + l.batch = nil + go b.end(l) + } + } + + return pos +} + +func (b *repositoriesByIDLoaderBatch) startTimer(l *RepositoriesByIDLoader) { + time.Sleep(l.wait) + l.mu.Lock() + + // we must have hit a batch limit and are already finalizing this batch + if b.closing { + l.mu.Unlock() + return + } + + l.batch = nil + l.mu.Unlock() + + b.end(l) +} + +func (b *repositoriesByIDLoaderBatch) end(l *RepositoriesByIDLoader) { + b.data, b.error = l.fetch(b.keys) + close(b.done) +} diff --git a/graphql/server.go b/graphql/server.go index 2f2c896..d98af2f 100644 --- a/graphql/server.go +++ b/graphql/server.go @@ -51,6 +51,9 @@ func main() { } router := chi.NewRouter() + // TODO: Add middleware to: + // - Gracefully handle panics + // - Log queries in debug mode router.Use(auth.Middleware(db)) router.Use(loaders.Middleware(db)) -- 2.38.4