~edwargix/git.sr.ht

4352e9438c431e16ad883b134dc09dbfa0fdaa3e — Drew DeVault 5 years ago 7748741
api: add initial search support

I still want to reduce some of this boilerplate if possible
A api/database/filter.go => api/database/filter.go +51 -0
@@ 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
}

M api/database/ql.go => api/database/ql.go +4 -2
@@ 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))
			}
		}
	}

M api/graph/generated/generated.go => api/graph/generated/generated.go +21 -13
@@ 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
			}

D api/graph/model/filter.go => api/graph/model/filter.go +0 -70
@@ 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
}

M api/graph/model/models_gen.go => api/graph/model/models_gen.go +1 -1
@@ 32,7 32,7 @@ type Artifact struct {
}

type FilterBy struct {
	Terms string `json:"terms"`
	Search string `json:"search"`
}

type RepoInput struct {

M api/graph/model/repository.go => api/graph/model/repository.go +12 -14
@@ 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
}

M api/graph/schema.graphqls => api/graph/schema.graphqls +2 -2
@@ 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

M api/graph/schema.resolvers.go => api/graph/schema.resolvers.go +50 -8
@@ 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)
	}