cache: combine sorting and filtering into a query with its micro-DSL

Michael Muré created

Change summary

cache/bug_excerpt.go      |  22 +++++-
cache/query.go            | 128 +++++++++++++++++++++++++++++++++++++++++
cache/query_test.go       |  30 +++++++++
cache/repo_cache.go       |  75 +++++++++++++++++++----
cache/sorting.go          |  55 -----------------
graphql/resolvers/repo.go |   5 +
termui/bug_table.go       |  11 ++
7 files changed, 249 insertions(+), 77 deletions(-)

Detailed changes

cache/bug_excerpt.go 🔗

@@ -22,8 +22,8 @@ type BugExcerpt struct {
 	Labels []bug.Label
 }
 
-func NewBugExcerpt(b bug.Interface, snap *bug.Snapshot) BugExcerpt {
-	return BugExcerpt{
+func NewBugExcerpt(b bug.Interface, snap *bug.Snapshot) *BugExcerpt {
+	return &BugExcerpt{
 		Id:                b.Id(),
 		CreateLamportTime: b.CreateLamportTime(),
 		EditLamportTime:   b.EditLamportTime(),
@@ -44,7 +44,21 @@ func init() {
  * Sorting
  */
 
-type BugsByCreationTime []BugExcerpt
+type BugsById []*BugExcerpt
+
+func (b BugsById) Len() int {
+	return len(b)
+}
+
+func (b BugsById) Less(i, j int) bool {
+	return b[i].Id < b[j].Id
+}
+
+func (b BugsById) Swap(i, j int) {
+	b[i], b[j] = b[j], b[i]
+}
+
+type BugsByCreationTime []*BugExcerpt
 
 func (b BugsByCreationTime) Len() int {
 	return len(b)
@@ -72,7 +86,7 @@ func (b BugsByCreationTime) Swap(i, j int) {
 	b[i], b[j] = b[j], b[i]
 }
 
-type BugsByEditTime []BugExcerpt
+type BugsByEditTime []*BugExcerpt
 
 func (b BugsByEditTime) Len() int {
 	return len(b)

cache/query.go 🔗

@@ -0,0 +1,128 @@
+package cache
+
+import (
+	"fmt"
+	"strings"
+)
+
+type Query struct {
+	Filters
+	OrderBy
+	OrderDirection
+}
+
+// ParseQuery parse a query DSL
+//
+// Ex: "status:open author:descartes sort:edit-asc"
+//
+// Supported filter fields are:
+// - status:
+// - author:
+// - label:
+// - no:
+//
+// Sorting is done with:
+// - sort:
+//
+// Todo: write a complete doc
+func ParseQuery(query string) (*Query, error) {
+	fields := strings.Fields(query)
+
+	result := &Query{
+		OrderBy:        OrderByCreation,
+		OrderDirection: OrderDescending,
+	}
+
+	sortingDone := false
+
+	for _, field := range fields {
+		split := strings.Split(field, ":")
+		if len(split) != 2 {
+			return nil, fmt.Errorf("can't parse \"%s\"", field)
+		}
+
+		switch split[0] {
+		case "status":
+			f, err := StatusFilter(split[1])
+			if err != nil {
+				return nil, err
+			}
+			result.Status = append(result.Status, f)
+
+		case "author":
+			f := AuthorFilter(split[1])
+			result.Author = append(result.Author, f)
+
+		case "label":
+			f := LabelFilter(split[1])
+			result.Label = append(result.Label, f)
+
+		case "no":
+			err := result.parseNoFilter(split[1])
+			if err != nil {
+				return nil, err
+			}
+
+		case "sort":
+			if sortingDone {
+				return nil, fmt.Errorf("multiple sorting")
+			}
+
+			err := result.parseSorting(split[1])
+			if err != nil {
+				return nil, err
+			}
+
+			sortingDone = true
+
+		default:
+			return nil, fmt.Errorf("unknow query field %s", split[0])
+		}
+	}
+
+	return result, nil
+}
+
+func (q *Query) parseNoFilter(query string) error {
+	switch query {
+	case "label":
+		q.NoFilters = append(q.NoFilters, NoLabelFilter())
+	default:
+		return fmt.Errorf("unknown \"no\" filter")
+	}
+
+	return nil
+}
+
+func (q *Query) parseSorting(query string) error {
+	switch query {
+	// default ASC
+	case "id-desc":
+		q.OrderBy = OrderById
+		q.OrderDirection = OrderDescending
+	case "id", "id-asc":
+		q.OrderBy = OrderById
+		q.OrderDirection = OrderAscending
+
+	// default DESC
+	case "creation", "creation-desc":
+		q.OrderBy = OrderByCreation
+		q.OrderDirection = OrderDescending
+	case "creation-asc":
+		q.OrderBy = OrderByCreation
+		q.OrderDirection = OrderAscending
+
+	// default DESC
+	case "edit", "edit-desc":
+		q.OrderBy = OrderByEdit
+		q.OrderDirection = OrderDescending
+	case "edit-asc":
+		q.OrderBy = OrderByEdit
+		q.OrderDirection = OrderAscending
+
+	default:
+		return fmt.Errorf("unknow sorting %s", query)
+	}
+
+	return nil
+}

cache/query_test.go 🔗

@@ -0,0 +1,30 @@
+package cache
+
+import "testing"
+
+func TestQueryParse(t *testing.T) {
+	var tests = []struct {
+		input string
+		ok    bool
+	}{
+		{"gibberish", false},
+
+		{"status:", false},
+
+		{"status:open", true},
+		{"status:closed", true},
+		{"status:unknown", false},
+
+		{"author:rene", true},
+		// Todo: fix parsing
+		// {"author:\"Rene Descartes\"", true},
+
+	}
+
+	for _, test := range tests {
+		_, err := ParseQuery(test.input)
+		if (err == nil) != test.ok {
+			t.Fatalf("Unexpected parse result, expected: %v, err: %v", test.ok, err)
+		}
+	}
+}

cache/repo_cache.go 🔗

@@ -8,6 +8,7 @@ import (
 	"io/ioutil"
 	"os"
 	"path"
+	"sort"
 	"strconv"
 	"strings"
 
@@ -19,7 +20,7 @@ import (
 
 type RepoCache struct {
 	repo     repository.Repo
-	excerpts map[string]BugExcerpt
+	excerpts map[string]*BugExcerpt
 	bugs     map[string]*BugCache
 }
 
@@ -101,7 +102,7 @@ func (c *RepoCache) loadExcerpts() error {
 
 	decoder := gob.NewDecoder(f)
 
-	var excerpts map[string]BugExcerpt
+	var excerpts map[string]*BugExcerpt
 
 	err = decoder.Decode(&excerpts)
 	if err != nil {
@@ -143,7 +144,9 @@ func repoExcerptsFilePath(repo repository.Repo) string {
 }
 
 func (c *RepoCache) buildAllExcerpt() {
-	c.excerpts = make(map[string]BugExcerpt)
+	fmt.Printf("Building bug cache... ")
+
+	c.excerpts = make(map[string]*BugExcerpt)
 
 	allBugs := bug.ReadAllLocalBugs(c.repo)
 
@@ -151,18 +154,8 @@ func (c *RepoCache) buildAllExcerpt() {
 		snap := b.Bug.Compile()
 		c.excerpts[b.Bug.Id()] = NewBugExcerpt(b.Bug, &snap)
 	}
-}
-
-func (c *RepoCache) allExcerpt() []BugExcerpt {
-	result := make([]BugExcerpt, len(c.excerpts))
 
-	i := 0
-	for _, val := range c.excerpts {
-		result[i] = val
-		i++
-	}
-
-	return result
+	fmt.Println("Done.")
 }
 
 func (c *RepoCache) ResolveBug(id string) (*BugCache, error) {
@@ -215,6 +208,60 @@ func (c *RepoCache) ResolveBugPrefix(prefix string) (*BugCache, error) {
 	return cached, nil
 }
 
+func (c *RepoCache) QueryBugs(query *Query) []string {
+	if query == nil {
+		return c.AllBugsIds()
+	}
+
+	var filtered []*BugExcerpt
+
+	for _, excerpt := range c.excerpts {
+		if query.Match(excerpt) {
+			filtered = append(filtered, excerpt)
+		}
+	}
+
+	var sorter sort.Interface
+
+	switch query.OrderBy {
+	case OrderById:
+		sorter = BugsById(filtered)
+	case OrderByCreation:
+		sorter = BugsByCreationTime(filtered)
+	case OrderByEdit:
+		sorter = BugsByEditTime(filtered)
+	default:
+		panic("missing sort type")
+	}
+
+	if query.OrderDirection == OrderDescending {
+		sorter = sort.Reverse(sorter)
+	}
+
+	sort.Sort(sorter)
+
+	result := make([]string, len(filtered))
+
+	for i, val := range filtered {
+		result[i] = val.Id
+	}
+
+	return result
+}
+
+// AllBugsIds return all known bug ids
+func (c *RepoCache) AllBugsIds() []string {
+	result := make([]string, len(c.excerpts))
+
+	i := 0
+	for _, excerpt := range c.excerpts {
+		result[i] = excerpt.Id
+		i++
+	}
+
+	return result
+}
+
 // ClearAllBugs clear all bugs kept in memory
 func (c *RepoCache) ClearAllBugs() {
 	c.bugs = make(map[string]*BugCache)

cache/sorting.go 🔗

@@ -1,7 +1,5 @@
 package cache
 
-import "sort"
-
 type OrderBy int
 
 const (
@@ -18,56 +16,3 @@ const (
 	OrderAscending
 	OrderDescending
 )
-
-func (c *RepoCache) AllBugsId(order OrderBy, direction OrderDirection) []string {
-	if order == OrderById {
-		return c.orderIds(direction)
-	}
-
-	excerpts := c.allExcerpt()
-
-	var sorter sort.Interface
-
-	switch order {
-	case OrderByCreation:
-		sorter = BugsByCreationTime(excerpts)
-	case OrderByEdit:
-		sorter = BugsByEditTime(excerpts)
-	default:
-		panic("missing sort type")
-	}
-
-	if direction == OrderDescending {
-		sorter = sort.Reverse(sorter)
-	}
-
-	sort.Sort(sorter)
-
-	result := make([]string, len(excerpts))
-
-	for i, val := range excerpts {
-		result[i] = val.Id
-	}
-
-	return result
-}
-
-func (c *RepoCache) orderIds(direction OrderDirection) []string {
-	result := make([]string, len(c.excerpts))
-
-	i := 0
-	for key := range c.excerpts {
-		result[i] = key
-		i++
-	}
-
-	var sorter sort.Interface = sort.StringSlice(result)
-
-	if direction == OrderDescending {
-		sorter = sort.Reverse(sorter)
-	}
-
-	sort.Sort(sorter)
-
-	return result
-}

graphql/resolvers/repo.go 🔗

@@ -20,7 +20,10 @@ func (repoResolver) AllBugs(ctx context.Context, obj *models.Repository, after *
 	}
 
 	// Simply pass a []string with the ids to the pagination algorithm
-	source := obj.Repo.AllBugsId(cache.OrderByCreation, cache.OrderAscending)
+	source := obj.Repo.QueryBugs(&cache.Query{
+		OrderBy:        cache.OrderByCreation,
+		OrderDirection: cache.OrderAscending,
+	})
 
 	// The edger create a custom edge holding just the id
 	edger := func(id string, offset int) connections.Edge {

termui/bug_table.go 🔗

@@ -20,15 +20,20 @@ const remote = "origin"
 
 type bugTable struct {
 	repo         *cache.RepoCache
+	query        *cache.Query
 	allIds       []string
 	bugs         []*cache.BugCache
 	pageCursor   int
 	selectCursor int
 }
 
-func newBugTable(cache *cache.RepoCache) *bugTable {
+func newBugTable(c *cache.RepoCache) *bugTable {
 	return &bugTable{
-		repo:         cache,
+		repo: c,
+		query: &cache.Query{
+			OrderBy:        cache.OrderByCreation,
+			OrderDirection: cache.OrderAscending,
+		},
 		pageCursor:   0,
 		selectCursor: 0,
 	}
@@ -212,7 +217,7 @@ func (bt *bugTable) disable(g *gocui.Gui) error {
 }
 
 func (bt *bugTable) paginate(max int) error {
-	bt.allIds = bt.repo.AllBugsId(cache.OrderByCreation, cache.OrderAscending)
+	bt.allIds = bt.repo.QueryBugs(bt.query)
 
 	return bt.doPaginate(max)
 }