From 4352e9438c431e16ad883b134dc09dbfa0fdaa3e Mon Sep 17 00:00:00 2001 From: Drew DeVault Date: Tue, 14 Apr 2020 16:17:15 -0400 Subject: [PATCH] api: add initial search support I still want to reduce some of this boilerplate if possible --- api/database/filter.go | 51 +++++++++++++++++++++++ api/database/ql.go | 6 ++- api/graph/generated/generated.go | 34 ++++++++++------ api/graph/model/filter.go | 70 -------------------------------- api/graph/model/models_gen.go | 2 +- api/graph/model/repository.go | 26 ++++++------ api/graph/schema.graphqls | 4 +- api/graph/schema.resolvers.go | 58 ++++++++++++++++++++++---- 8 files changed, 141 insertions(+), 110 deletions(-) create mode 100644 api/database/filter.go delete mode 100644 api/graph/model/filter.go diff --git a/api/database/filter.go b/api/database/filter.go new file mode 100644 index 0000000..a935ba1 --- /dev/null +++ b/api/database/filter.go @@ -0,0 +1,51 @@ +package database + +import ( + "strings" + + "github.com/google/shlex" + sq "github.com/Masterminds/squirrel" +) + +type KeyFunc func(sq.SelectBuilder, string) (string, error) + +type SearchTerm struct { + Key string + Value string + Inverse bool +} + +type Searchable interface { + Selectable + + // Update the select builder for bare search terms + DefaultSearch(sq.SelectBuilder, string) (sq.SelectBuilder, error) + + // Return a map of KeyFuncs for each search key, whose values update the + // select builder for the given search term + //KeySearch() map[string]KeyFunc + + // Update the select builder for a key/value pair which is unknown + //FallbackSearch(sq.SelectBuilder, + // key, value string) (sq.SelectBuilder, error) +} + +func ApplyFilter(query sq.SelectBuilder, resource Searchable, + search string) (sq.SelectBuilder, error) { + terms, err := shlex.Split(search) + if err != nil { + return query, err + } + + for _, term := range terms { + parts := strings.SplitN(term, ":", 2) + if len(parts) == 1 { + query, err = resource.DefaultSearch(query, term) + if err != nil { + return query, err + } + } + } + + return query, nil +} diff --git a/api/database/ql.go b/api/database/ql.go index 459ddfa..6498652 100644 --- a/api/database/ql.go +++ b/api/database/ql.go @@ -4,6 +4,7 @@ import ( "context" "sort" + "github.com/lib/pq" "github.com/vektah/gqlparser/v2/ast" "git.sr.ht/~sircmpwn/gqlgen/graphql" @@ -32,9 +33,10 @@ func ColumnsFor(ctx context.Context, alias string, for _, qlCol := range fields { if sqlCol, ok := colMap[qlCol.Name]; ok { if alias != "" { - columns = append(columns, alias+"."+sqlCol) + columns = append(columns, pq.QuoteIdentifier(alias)+ + "."+pq.QuoteIdentifier(sqlCol)) } else { - columns = append(columns, sqlCol) + columns = append(columns, pq.QuoteIdentifier(sqlCol)) } } } diff --git a/api/graph/generated/generated.go b/api/graph/generated/generated.go index 129a7a2..2da84ab 100644 --- a/api/graph/generated/generated.go +++ b/api/graph/generated/generated.go @@ -102,7 +102,7 @@ type ComplexityRoot struct { Query struct { Me func(childComplexity int) int - Repositories func(childComplexity int, next *int, filter *model.FilterBy) int + Repositories func(childComplexity int, count *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 @@ -206,7 +206,7 @@ type QueryResolver interface { Version(ctx context.Context) (*model.Version, error) Me(ctx context.Context) (*model.User, error) User(ctx context.Context, username string) (*model.User, error) - Repositories(ctx context.Context, next *int, filter *model.FilterBy) ([]*model.Repository, error) + Repositories(ctx context.Context, count *int, 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) @@ -533,7 +533,7 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return 0, false } - return e.complexity.Query.Repositories(childComplexity, args["next"].(*int), args["filter"].(*model.FilterBy)), true + return e.complexity.Query.Repositories(childComplexity, args["count"].(*int), args["next"].(*int), args["filter"].(*model.FilterBy)), true case "Query.repository": if e.complexity.Query.Repository == nil { @@ -1322,7 +1322,7 @@ type Tag implements Object { # Specifies filtering criteria for a listing query input FilterBy { # Same search syntax as searching on the web UI - terms: String! + search: String! } type Query { @@ -1341,7 +1341,7 @@ type Query { # will be to return all repositories that the user either (1) has been given # explicit access to via ACLs or (2) has implicit access to either by # ownership or group membership. - repositories(next: Int, filter: FilterBy): [Repository]! + repositories(count: Int = 10, next: Int, filter: FilterBy): [Repository]! # Returns a specific repository repository(id: Int!): Repository @@ -1555,21 +1555,29 @@ func (ec *executionContext) field_Query_repositories_args(ctx context.Context, r var err error args := map[string]interface{}{} var arg0 *int - if tmp, ok := rawArgs["next"]; ok { + if tmp, ok := rawArgs["count"]; ok { arg0, err = ec.unmarshalOInt2ᚖint(ctx, tmp) if err != nil { return nil, err } } - args["next"] = arg0 - var arg1 *model.FilterBy + args["count"] = arg0 + var arg1 *int + if tmp, ok := rawArgs["next"]; ok { + arg1, err = ec.unmarshalOInt2ᚖint(ctx, tmp) + if err != nil { + return nil, err + } + } + args["next"] = arg1 + var arg2 *model.FilterBy if tmp, ok := rawArgs["filter"]; ok { - arg1, err = ec.unmarshalOFilterBy2ᚖgitᚗsrᚗhtᚋאsircmpwnᚋgitᚗsrᚗhtᚋapiᚋgraphᚋmodelᚐFilterBy(ctx, tmp) + arg2, err = ec.unmarshalOFilterBy2ᚖgitᚗsrᚗhtᚋאsircmpwnᚋgitᚗsrᚗhtᚋapiᚋgraphᚋmodelᚐFilterBy(ctx, tmp) if err != nil { return nil, err } } - args["filter"] = arg1 + args["filter"] = arg2 return args, nil } @@ -3248,7 +3256,7 @@ func (ec *executionContext) _Query_repositories(ctx context.Context, field graph 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().Repositories(rctx, args["next"].(*int), args["filter"].(*model.FilterBy)) + return ec.resolvers.Query().Repositories(rctx, args["count"].(*int), args["next"].(*int), args["filter"].(*model.FilterBy)) }) if err != nil { ec.Error(ctx, err) @@ -6410,9 +6418,9 @@ func (ec *executionContext) unmarshalInputFilterBy(ctx context.Context, obj inte for k, v := range asMap { switch k { - case "terms": + case "search": var err error - it.Terms, err = ec.unmarshalNString2string(ctx, v) + it.Search, err = ec.unmarshalNString2string(ctx, v) if err != nil { return it, err } diff --git a/api/graph/model/filter.go b/api/graph/model/filter.go deleted file mode 100644 index 92da5c1..0000000 --- a/api/graph/model/filter.go +++ /dev/null @@ -1,70 +0,0 @@ -package model - -import ( - "fmt" - "strings" - - "github.com/google/shlex" - "github.com/lib/pq" -) - -type KeyFunc func(tbl, value string) (string, error) - -type SearchTerm struct { - Key string - Value string - Inverse bool -} - -type Searchable interface { - // Returns the default WHERE clause for a given search term with no key. - Default(tbl, term string) (string, error) - - // Returns a map of search functions for a given key, where each function - // returns the appropriate WHERE clause for searching with the given - // string. - Keys() map[string]KeyFunc - - // Returns a WHERE clause for a given search key and value, where the key - // was not found in the Keys() map. - Fallback(tbl, key, value string) (string, error) -} - -type WhereClause struct { - Clause string - Parameters []interface{} -} - -// Returns a WHERE clause for the given fitler -func Where(query *string, tbl string, param int, - resource Searchable) (*WhereClause, error) { - if query == nil { - return &WhereClause{"true /* No search terms */", nil}, nil - } - - tbl = pq.QuoteIdentifier(tbl) - terms, err := shlex.Split(*query) - if err != nil { - return nil, err - } - - var ( - clauses []string - params []interface{} - ) - for _, term := range terms { - parts := strings.SplitN(term, ":", 2) - variable := fmt.Sprintf("$%d", param) - param += 1 - if len(parts) == 1 { - clause, err := resource.Default(tbl, variable) - if err != nil { - return nil, err - } - clauses = append(clauses, fmt.Sprintf("(%s)", clause)) - params = append(params, term) - } - } - - return &WhereClause{strings.Join(clauses, " AND "), params}, nil -} diff --git a/api/graph/model/models_gen.go b/api/graph/model/models_gen.go index cc9fcb8..baf58b3 100644 --- a/api/graph/model/models_gen.go +++ b/api/graph/model/models_gen.go @@ -32,7 +32,7 @@ type Artifact struct { } type FilterBy struct { - Terms string `json:"terms"` + Search string `json:"search"` } type RepoInput struct { diff --git a/api/graph/model/repository.go b/api/graph/model/repository.go index 801a3ce..86b9056 100644 --- a/api/graph/model/repository.go +++ b/api/graph/model/repository.go @@ -2,10 +2,10 @@ package model import ( "context" - "strings" "time" "github.com/go-git/go-git/v5" + sq "github.com/Masterminds/squirrel" "git.sr.ht/~sircmpwn/git.sr.ht/api/database" ) @@ -51,19 +51,6 @@ func (r *Repository) Head() *Reference { return &Reference{Ref: ref, Repo: r.repo} } -func (r *Repository) Columns(ctx context.Context, tbl string) string { - columns := ColumnsFor(ctx, map[string]string{ - "id": "id", - "created": "created", - "updated": "updated", - "name": "name", - "description": "description", - "visibility": "visibility", - "upstreamUrl": "upstream_uri", - }, tbl) - return strings.Join(append(columns, tbl+".path", tbl+".owner_id"), ", ") -} - func (r *Repository) Select(ctx context.Context) []string { return append(database.ColumnsFor(ctx, r.alias, map[string]string{ "id": "id", @@ -95,3 +82,14 @@ func (r *Repository) Fields(ctx context.Context) []interface{} { }) return append(fields, &r.Path, &r.OwnerID) } + +func (r *Repository) DefaultSearch(query sq.SelectBuilder, + term string) (sq.SelectBuilder, error) { + name := database.WithAlias(r.alias, "name") + desc := database.WithAlias(r.alias, "description") + return query. + Where(sq.Or{ + sq.Expr(name + ` ILIKE '%' || ? || '%'`, term), + sq.Expr(desc + ` ILIKE '%' || ? || '%'`, term), + }), nil +} diff --git a/api/graph/schema.graphqls b/api/graph/schema.graphqls index f8f0f61..538f91b 100644 --- a/api/graph/schema.graphqls +++ b/api/graph/schema.graphqls @@ -231,7 +231,7 @@ type Tag implements Object { # Specifies filtering criteria for a listing query input FilterBy { # Same search syntax as searching on the web UI - terms: String! + search: String! } type Query { @@ -250,7 +250,7 @@ type Query { # will be to return all repositories that the user either (1) has been given # explicit access to via ACLs or (2) has implicit access to either by # ownership or group membership. - repositories(next: Int, filter: FilterBy): [Repository]! + repositories(count: Int = 10, next: Int, filter: FilterBy): [Repository]! # Returns a specific repository repository(id: Int!): Repository diff --git a/api/graph/schema.resolvers.go b/api/graph/schema.resolvers.go index ca87673..5e8130c 100644 --- a/api/graph/schema.resolvers.go +++ b/api/graph/schema.resolvers.go @@ -16,7 +16,6 @@ import ( "git.sr.ht/~sircmpwn/git.sr.ht/api/graph/model" "git.sr.ht/~sircmpwn/git.sr.ht/api/loaders" "git.sr.ht/~sircmpwn/gqlgen/graphql" - sq "github.com/Masterminds/squirrel" "github.com/go-git/go-git/v5/plumbing" ) @@ -75,8 +74,43 @@ func (r *queryResolver) User(ctx context.Context, username string) (*model.User, return loaders.ForContext(ctx).UsersByName.Load(username) } -func (r *queryResolver) Repositories(ctx context.Context, next *int, filter *model.FilterBy) ([]*model.Repository, error) { - panic(fmt.Errorf("not implemented")) +func (r *queryResolver) Repositories(ctx context.Context, count *int, next *int, filter *model.FilterBy) ([]*model.Repository, error) { + var ( + err error + rows *sql.Rows + ) + repo := (&model.Repository{}).As(`repo`) + query := database. + Select(ctx, repo). + From(`repository repo`). + Where(`repo.owner_id = ?`, auth.ForContext(ctx).ID). + OrderBy(`repo.id DESC`). + Limit(uint64(*count)) + if next != nil { + query = query.Where(`repo.id < ?`, *next) + } + if filter != nil { + searchable, _ := repo.(database.Searchable) + query, err = database.ApplyFilter(query, searchable, filter.Search) + if err != nil { + return nil, err + } + } + + if rows, err = query.RunWith(r.DB).QueryContext(ctx); err != nil { + panic(err) + } + defer rows.Close() + + var repos []*model.Repository + for rows.Next() { + var repo model.Repository + if err := rows.Scan(repo.Fields(ctx)...); err != nil { + panic(err) + } + repos = append(repos, &repo) + } + return repos, nil } func (r *queryResolver) Repository(ctx context.Context, id int) (*model.Repository, error) { @@ -142,15 +176,23 @@ func (r *userResolver) Repositories(ctx context.Context, obj *model.User, count err error rows *sql.Rows ) + repo := (&model.Repository{}).As(`repo`) query := database. - Select(ctx, (&model.Repository{}).As(`repo`)). + Select(ctx, repo). From(`repository repo`). - Where(sq.And{ - sq.Expr(`repo.owner_id = ?`, obj.ID), - sq.Expr(`CASE WHEN ? != 0 THEN repo.id < ? ELSE true END`, next, next), - }). + Where(`repo.owner_id = ?`, obj.ID). OrderBy(`id DESC`). Limit(uint64(*count)) + if next != nil { + query = query.Where(`repo.id < ?`, *next) + } + if filter != nil { + searchable, _ := repo.(database.Searchable) + query, err = database.ApplyFilter(query, searchable, filter.Search) + if err != nil { + return nil, err + } + } if rows, err = query.RunWith(r.DB).QueryContext(ctx); err != nil { panic(err) } -- 2.38.4