Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Ordering Filter #2

Open
wants to merge 14 commits into
base: feat-visibility-filter
Choose a base branch
from
Open
78 changes: 45 additions & 33 deletions api/proto/racing/racing.pb.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions api/proto/racing/racing.proto
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ service Racing {
// Request for ListRaces call.
message ListRacesRequest {
ListRacesRequestFilter filter = 1;
// Orders the response by the given order string. See https://cloud.google.com/apis/design/design_patterns#sorting_order
optional string order_by = 2;
}

// Response to ListRaces call.
Expand Down
79 changes: 75 additions & 4 deletions racing/db/races.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@ package db

import (
"database/sql"
"errors"
"strings"
"sync"
"time"
"unicode"

"github.com/golang/protobuf/ptypes"
_ "github.com/mattn/go-sqlite3"
Expand All @@ -19,7 +21,7 @@ type RacesRepo interface {
Clear() error
InsertRace(*racing.Race) error
// List will return a list of races.
List(filter *racing.ListRacesRequestFilter) ([]*racing.Race, error)
List(request *racing.ListRacesRequest) ([]*racing.Race, error)
ListAll() ([]*racing.Race, error)
}

Expand Down Expand Up @@ -60,7 +62,7 @@ func (r *racesRepo) InsertRace(race *racing.Race) error {
return r.insert(race)
}

func (r *racesRepo) List(filter *racing.ListRacesRequestFilter) ([]*racing.Race, error) {
func (r *racesRepo) List(request *racing.ListRacesRequest) ([]*racing.Race, error) {
var (
err error
query string
Expand All @@ -69,8 +71,8 @@ func (r *racesRepo) List(filter *racing.ListRacesRequestFilter) ([]*racing.Race,

query = getRaceQueries()[racesList]

query, args = r.applyFilter(query, filter)

query, args = r.applyFilter(query, request.Filter)
query = r.applyOrdering(query, request.OrderBy)
rows, err := r.db.Query(query, args...)
if err != nil {
return nil, err
Expand Down Expand Up @@ -115,6 +117,75 @@ func (r *racesRepo) applyFilter(query string, filter *racing.ListRacesRequestFil
return query, args
}

func (r *racesRepo) applyOrdering(query string, orderBy *string) string {
const defaultOrder = " ORDER BY advertised_start_time"
if orderBy == nil {
query += defaultOrder
} else {
// DB implementation doesn't allow prepared statements for ORDER BY
// input variables.
// To allow for dynamic and safe ordering, we must sanitize and rebuild
// in SQL friendly format.
orderStr, err := toOrderBySql(*orderBy)
if err != nil {
query += defaultOrder
} else if orderStr != nil {
query += " ORDER BY " + *orderStr
} else {
query += defaultOrder
}
}
return query
}

// Accepts `order_by` definition from Google API standards [1] and returns an
// output in SQL friendly format.
// [1] - https://cloud.google.com/apis/design/design_patterns#sorting_order
func toOrderBySql(input string) (*string, error) {
var (
terms []string
)
// 1. Splits the input string by comma.
// 2. Then for each element, determine the words (max of 2, min of 1).
// 3. Ensure that the first (column name) only contains valid chars.
// 4. Ensure that if sort parameter is provided, it is "asc" or "desc" only.
// 5. Rebuilds the string in CSV (SQL ORDER BY) format.
for _, str := range strings.Split(input, ",") {
words := strings.Fields(str)
wordCount := len(words)
if wordCount > 2 || wordCount < 1 {
return nil, errors.New("Invalid order by term count.")
}
sortField := words[0]
if strings.IndexFunc(sortField, isUnsafeColumnChar) != -1 {
return nil, errors.New("Invalid column name.")
}
if wordCount == 2 {
sort := words[1]
if !(strings.EqualFold(sort, "asc") || strings.EqualFold(sort, "desc")) {
return nil, errors.New("Invalid order by dir parameter.")
}
sortField += " " + sort
}
terms = append(terms, sortField)
}
output := strings.Join(terms, ",")
return &output, nil
}

// Determines if the supplied rune is safe to be used in the column name.
// This err's on the side of caution and makes no attempt for numerics or
// escaped special chars.
func isUnsafeColumnChar(c rune) bool {
switch c {
case '_':
// Edge case for column names with underscores.
return false
default:
return !unicode.IsLetter(c)
}
}

func (m *racesRepo) scanRaces(
rows *sql.Rows,
) ([]*racing.Race, error) {
Expand Down
92 changes: 89 additions & 3 deletions racing/db/races_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,8 @@ func TestPopulateAndFetchRepo(t *testing.T) {
var a []int64
filter := racing.ListRacesRequestFilter{MeetingIds: a}
a = append(a, races[0].MeetingId)
rsp, err := racesRepo.List(&filter)
rq := racing.ListRacesRequest{Filter: &filter}
rsp, err := racesRepo.List(&rq)
if err != nil {
t.Fatalf("Unable to retrieve races list.")
}
Expand Down Expand Up @@ -116,7 +117,8 @@ func TestPopulateAndFilterVisible(t *testing.T) {
}
visible := true
filter := racing.ListRacesRequestFilter{Visible: &visible}
rsp, err := racesRepo.List(&filter)
rq := racing.ListRacesRequest{Filter: &filter}
rsp, err := racesRepo.List(&rq)
if err != nil {
t.Fatalf("Unable to retrieve races list.")
}
Expand All @@ -143,7 +145,7 @@ func TestFetchAllEmpty(t *testing.T) {
}

func TestFetchAll(t *testing.T) {
racingDB, err := GetTestDB("races", "TestPopulateAndFetchRepo")
racingDB, err := GetTestDB("races", "TestFetchAll")
if err != nil {
t.Fatalf("Failed to open testdb %v", err)
}
Expand Down Expand Up @@ -173,6 +175,90 @@ func TestFetchAll(t *testing.T) {
}
}

func TestOrderBy(t *testing.T) {
racingDB, err := GetTestDB("races", "TestOrderBy")
if err != nil {
t.Fatalf("Failed to open testdb %v", err)
}
racesRepo := db.NewRacesRepo(racingDB)
_ = racesRepo.Init(false)

tm1, _ := ptypes.TimestampProto(time.Now().AddDate(0, 0, 2))
race1 :=
racing.Race{Id: int64(1), MeetingId: int64(5),
Name: "Test1", Number: int64(5),
Visible: true, AdvertisedStartTime: tm1}
err = racesRepo.InsertRace(&race1)
if err != nil {
t.Fatalf("Failed to insert first race %v.", err)
}
tm2, _ := ptypes.TimestampProto(time.Now().AddDate(0, 0, 2))
race2 :=
racing.Race{Id: int64(5), MeetingId: int64(3),
Name: "Test2", Number: int64(9),
Visible: false, AdvertisedStartTime: tm2}
err = racesRepo.InsertRace(&race2)
if err != nil {
t.Fatalf("Failed to insert second race %v.", err)
}

// Names descending
s := "name desc"
rq := racing.ListRacesRequest{OrderBy: &s}
rsp, err := racesRepo.List(&rq)
if err != nil {
t.Fatalf("Unable to retrieve races list.")
}
if len(rsp) != 2 {
t.Fatalf("Returned incorrect amount of races.")
}
if rsp[0].Id != race2.Id || rsp[1].Id != race1.Id {
t.Fatalf("Failed to sort by name descending: N1: %v, N2: %v",
rsp[0].Name, rsp[1].Name)
}

// Names ascending
s = "name asc"
rq = racing.ListRacesRequest{OrderBy: &s}
rsp, err = racesRepo.List(&rq)
if err != nil {
t.Fatalf("Unable to retrieve races list.")
}
if len(rsp) != 2 {
t.Fatalf("Returned incorrect amount of races.")
}
if rsp[0].Id != race1.Id || rsp[1].Id != race2.Id {
t.Fatalf("Failed to sort by name ascending: N1: %v, N2: %v",
rsp[0].Name, rsp[1].Name)
}

// Multispace, multi param
s = "number asc,name desc"
//Race three has the same `number` value as race two, but name is different
race3 :=
racing.Race{Id: int64(8), MeetingId: int64(3),
Name: "Test3", Number: int64(9),
Visible: false, AdvertisedStartTime: tm2}
err = racesRepo.InsertRace(&race3)
if err != nil {
t.Fatalf("Failed to insert third race %v.", err)
}

rq = racing.ListRacesRequest{OrderBy: &s}
rsp, err = racesRepo.List(&rq)
if err != nil {
t.Fatalf("Unable to retrieve races list.")
}
if len(rsp) != 3 {
t.Fatalf("Returned incorrect amount of races.")
}
// Expect Race1, Race3 then Race2
if rsp[0].Id != race1.Id || rsp[1].Id != race3.Id || rsp[2].Id != race2.Id {
t.Fatalf("Failed to sort by name ascending: N1: %v, N2: %v, N3: %v",
rsp[0].Name, rsp[1].Name, rsp[2].Name)
}
}

// Helpers //

func GetRaces() []*racing.Race {
Expand Down
1 change: 1 addition & 0 deletions racing/proto/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ This defines the protobuf message types for the racing API.

### ListRacesRequest
- Supports a filter parameter of type `ListRacesRequestFilter`.
- Supports a order_by parameter of the form defined by [Google API Design](https://cloud.google.com/apis/design/design_patterns#sorting_order).

### ListRacesRequestFilter
- A list of integer IDs can be supplied to perform a bulk lookup request. (optional)
Expand Down
Loading