Skip to content

Commit

Permalink
Merge pull request #14 from a8m/feat/timelayout
Browse files Browse the repository at this point in the history
rql: add a time layout option
  • Loading branch information
a8m authored May 23, 2019
2 parents 0e78add + d0368c7 commit cc9f6f2
Show file tree
Hide file tree
Showing 4 changed files with 163 additions and 65 deletions.
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,15 @@ Let's go over the validation rules:
3. `float` (32,64), sql.NullFloat64: - Number
4. `bool`, `sql.NullBool` - Boolean
5. `string`, `sql.NullString` - String
6. `time.Time`, and other types that convertible to `time.Time` - time.RFC3339 format (JS format), and parsable to `time.Time`.
6. `time.Time`, and other types that convertible to `time.Time` - The default layout is time.RFC3339 format (JS format), and parsable to `time.Time`.
It's possible to override the `time.Time` layout format with custom one. You can either use one of the standard layouts in the `time` package, or use a custom one. For example:
```go
type User struct {
T1 time.Time `rql:"filter"` // time.RFC3339
T2 time.Time `rql:"filter,layout=UnixDate"` // time.UnixDate
T3 time.Time `rql:"filter,layout=2006-01-02 15:04"` // 2006-01-02 15:04 (custom)
}
```

Note that all rules are applied to pointers as well. It means, if you have a field `Name *string` in your struct, we still use the string validation rule for it.

Expand Down
12 changes: 10 additions & 2 deletions integration/rql_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ type User struct {
Name string `rql:"filter"`
AddressName string `rql:"filter"`
CreatedAt time.Time `rql:"filter"`
UnixTime time.Time `rql:"filter,layout=UnixDate"`
CustomTime time.Time `rql:"filter,layout=2006-01-02 15:04"`
}

func TestMySQL(t *testing.T) {
Expand All @@ -46,6 +48,10 @@ func TestMySQL(t *testing.T) {
AssertCount(t, db, 1, `{ "filter": {"address_name": "address_1" } }`) // 1st user
AssertCount(t, db, 100, fmt.Sprintf(`{"filter": {"created_at": { "$gt": %q } } }`, CreateTime.Add(-time.Hour).Format(time.RFC3339)))
AssertCount(t, db, 100, fmt.Sprintf(`{"filter": {"created_at": { "$lte": %q } } }`, CreateTime.Add(time.Hour).Format(time.RFC3339)))
AssertCount(t, db, 100, fmt.Sprintf(`{"filter": {"unix_time": { "$gt": %q } } }`, CreateTime.Add(-time.Hour).Format(time.UnixDate)))
AssertCount(t, db, 100, fmt.Sprintf(`{"filter": {"unix_time": { "$lte": %q } } }`, CreateTime.Add(time.Hour).Format(time.UnixDate)))
AssertCount(t, db, 100, fmt.Sprintf(`{"filter": {"custom_time": { "$gt": %q } } }`, CreateTime.Add(-time.Hour).Format("2006-01-02 15:04")))
AssertCount(t, db, 100, fmt.Sprintf(`{"filter": {"custom_time": { "$lte": %q } } }`, CreateTime.Add(time.Hour).Format("2006-01-02 15:04")))
AssertMatchIDs(t, db, []int{1}, `{ "filter": { "id": 1 } }`)
AssertMatchIDs(t, db, []int{2, 3}, `{ "filter": { "$or": [ { "id": 2 }, { "id": 3 } ] } }`)
AssertMatchIDs(t, db, []int{3, 2}, `{ "filter": { "$or": [ { "id": 2 }, { "id": 3 } ] }, "sort": ["-id"] }`)
Expand All @@ -59,7 +65,7 @@ func AssertCount(t *testing.T, db *gorm.DB, expected int, query string) {
err = db.Model(User{}).Where(params.FilterExp, params.FilterArgs...).Count(&count).Error
must(t, err, "count users")
if count != expected {
t.Errorf("AssertCount:\n\twant: %d\n\tgot: %d", expected, count)
t.Errorf("AssertCount: %s\n\twant: %d\n\tgot: %d", query, expected, count)
}
}

Expand Down Expand Up @@ -108,7 +114,9 @@ func SetUp(t *testing.T, db *gorm.DB) {
Admin: i%2 == 0,
Name: fmt.Sprintf("user_%d", i),
AddressName: fmt.Sprintf("address_%d", i),
CreatedAt: CreateTime.Add(time.Minute * 1),
CreatedAt: CreateTime.Add(time.Minute),
UnixTime: CreateTime.Add(time.Minute),
CustomTime: CreateTime.Add(time.Minute),
}).Error
must(t, err, "create user")
}(i)
Expand Down
116 changes: 78 additions & 38 deletions rql.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,9 @@ func NewParser(c Config) (*Parser, error) {
Config: c,
fields: make(map[string]*field),
}
p.init()
if err := p.init(); err != nil {
return nil, err
}
return p, nil
}

Expand Down Expand Up @@ -196,7 +198,7 @@ func Column(s string) string {

// init initializes the parser parsing state. it scans the fields
// in a breath-first-search order and for each one of the field calls parseField.
func (p *Parser) init() {
func (p *Parser) init() error {
t := indirect(reflect.TypeOf(p.Model))
l := list.New()
for i := 0; i < t.NumField(); i++ {
Expand All @@ -209,7 +211,9 @@ func (p *Parser) init() {
// no matter what the type of this field. if it has a tag,
// it is probably a filterable or sortable.
case ok:
p.parseField(f)
if err := p.parseField(f); err != nil {
return err
}
case t.Kind() == reflect.Struct:
for i := 0; i < t.NumField(); i++ {
f1 := t.Field(i)
Expand All @@ -222,16 +226,44 @@ func (p *Parser) init() {
p.Log("ignore embedded field %q that is not struct type", f.Name)
}
}
return nil
}

// parseField parses the given struct field tag, and add a rule
// in the parser according to its type and the options that were set on the tag.
func (p *Parser) parseField(sf reflect.StructField) {
func (p *Parser) parseField(sf reflect.StructField) error {
f := &field{
Name: p.ColumnFn(sf.Name),
CovertFn: valueFn,
FilterOps: make(map[string]bool),
}
layout := time.RFC3339
opts := strings.Split(sf.Tag.Get(p.TagName), ",")
for _, opt := range opts {
switch s := strings.TrimSpace(opt); {
case s == "sort":
f.Sortable = true
case s == "filter":
f.Filterable = true
case strings.HasPrefix(opt, "column"):
f.Name = strings.TrimPrefix(opt, "column=")
case strings.HasPrefix(opt, "layout"):
layout = strings.TrimPrefix(opt, "layout=")
// if it's one of the standard layouts, like: RFC822 or Kitchen.
if ly, ok := layouts[layout]; ok {
layout = ly
}
// test the layout on a value (on itself). however, some layouts are invalid
// time values for time.Parse, due to formats such as _ for space padding and
// Z for zone information.
v := strings.NewReplacer("_", " ", "Z", "+").Replace(layout)
if _, err := time.Parse(layout, v); err != nil {
return fmt.Errorf("rql: layout %q is not parsable: %v", layout, err)
}
default:
p.Log("Ignoring unknown option %q in struct tag", opt)
}
}
filterOps := make([]Op, 0)
switch typ := indirect(sf.Type); typ.Kind() {
case reflect.Bool:
Expand Down Expand Up @@ -267,40 +299,25 @@ func (p *Parser) parseField(sf reflect.StructField) {
f.ValidateFn = validateFloat
filterOps = append(filterOps, EQ, NEQ, LT, LTE, GT, GTE)
case time.Time:
f.ValidateFn = validateTime
f.CovertFn = convertTime
f.ValidateFn = validateTime(layout)
f.CovertFn = convertTime(layout)
filterOps = append(filterOps, EQ, NEQ, LT, LTE, GT, GTE)
default:
if v.Type().ConvertibleTo(reflect.TypeOf(time.Time{})) {
f.ValidateFn = validateTime
f.CovertFn = convertTime
filterOps = append(filterOps, EQ, NEQ, LT, LTE, GT, GTE)
} else {
p.Log("the type for field %q is not supported", sf.Name)
return
if !v.Type().ConvertibleTo(reflect.TypeOf(time.Time{})) {
return fmt.Errorf("rql: field type for %q is not supported", sf.Name)
}
f.ValidateFn = validateTime(layout)
f.CovertFn = convertTime(layout)
filterOps = append(filterOps, EQ, NEQ, LT, LTE, GT, GTE)
}
default:
p.Log("the type for field %q is not supported", sf.Name)
return
return fmt.Errorf("rql: field type for %q is not supported", sf.Name)
}
for _, op := range filterOps {
f.FilterOps[p.op(op)] = true
}
opts := strings.Split(sf.Tag.Get(p.TagName), ",")
for _, opt := range opts {
switch s := strings.TrimSpace(opt); {
case s == "sort":
f.Sortable = true
case s == "filter":
f.Filterable = true
case strings.HasPrefix(opt, "column"):
f.Name = strings.TrimPrefix(opt, "column=")
default:
p.Log("Ingnoring unknown option %q in struct tag", opt)
}
}
p.fields[f.Name] = f
return nil
}

type parseState struct {
Expand Down Expand Up @@ -407,7 +424,7 @@ func (p *parseState) field(f *field, v interface{}) {
p.WriteString(" AND ")
}
expect(f.FilterOps[opName], "can not apply op %q on field %q", opName, f.Name)
must(f.ValidateFn(opVal), "invalid datatype for field %q", f.Name)
must(f.ValidateFn(opVal), "invalid datatype or format for field %q", f.Name)
p.WriteString(p.fmtOp(f.Name, Op(opName[1:])))
p.values = append(p.values, f.CovertFn(opVal))
i++
Expand Down Expand Up @@ -519,13 +536,15 @@ func validateUInt(v interface{}) error {
}

// validate that the underlined element of this interface is a "datetime" string.
func validateTime(v interface{}) error {
s, ok := v.(string)
if !ok {
return errorType(v, "string")
func validateTime(layout string) func(interface{}) error {
return func(v interface{}) error {
s, ok := v.(string)
if !ok {
return errorType(v, "string")
}
_, err := time.Parse(layout, s)
return err
}
_, err := time.Parse(time.RFC3339, s)
return err
}

// convert float to int.
Expand All @@ -534,12 +553,33 @@ func convertInt(v interface{}) interface{} {
}

// convert string to time object.
func convertTime(v interface{}) interface{} {
t, _ := time.Parse(time.RFC3339, v.(string))
return t
func convertTime(layout string) func(interface{}) interface{} {
return func(v interface{}) interface{} {
t, _ := time.Parse(layout, v.(string))
return t
}
}

// nop converter.
func valueFn(v interface{}) interface{} {
return v
}

// layouts holds all standard time.Time layouts.
var layouts = map[string]string{
"ANSIC": time.ANSIC,
"UnixDate": time.UnixDate,
"RubyDate": time.RubyDate,
"RFC822": time.RFC822,
"RFC822Z": time.RFC822Z,
"RFC850": time.RFC850,
"RFC1123": time.RFC1123,
"RFC1123Z": time.RFC1123Z,
"RFC3339": time.RFC3339,
"RFC3339Nano": time.RFC3339Nano,
"Kitchen": time.Kitchen,
"Stamp": time.Stamp,
"StampMilli": time.StampMilli,
"StampMicro": time.StampMicro,
"StampNano": time.StampNano,
}
90 changes: 66 additions & 24 deletions rql_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,11 @@ func TestInit(t *testing.T) {
}),
},
{
name: "ignore unsupported types",
name: "return an error for unsupported types",
model: new(struct {
Age interface{} `rql:"filter"`
}),
wantErr: true,
},
{
name: "model is mandatory",
Expand Down Expand Up @@ -95,6 +96,13 @@ func TestInit(t *testing.T) {
}{}
})(),
},
{
name: "time format",
model: new(struct {
CreatedAt time.Time `rql:"filter,layout=2006-01-02 15:04"`
UpdatedAt time.Time `rql:"filter,layout=Kitchen"`
}),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
Expand Down Expand Up @@ -247,22 +255,6 @@ func TestParse(t *testing.T) {
FilterArgs: []interface{}{},
},
},
{
name: "ignore unsupported struct",
conf: Config{
Model: struct {
strings.Reader `rql:"filter"`
*strings.Builder `rql:"filter"`
}{},
DefaultLimit: 25,
},
input: []byte(`{}`),
wantOut: &Params{
Limit: 25,
FilterExp: "",
FilterArgs: []interface{}{},
},
},
{
name: "type alias",
conf: Config{
Expand Down Expand Up @@ -369,10 +361,10 @@ func TestParse(t *testing.T) {
Limit: 25,
FilterExp: "created_at = ? AND updated_at = ? AND swagger_date = ? AND ptr_swagger_date = ?",
FilterArgs: []interface{}{
mustParseTime("2018-01-14T06:05:48.839Z"),
mustParseTime("2018-01-14T06:05:48.839Z"),
mustParseTime("2018-01-14T06:05:48.839Z"),
mustParseTime("2018-01-14T06:05:48.839Z"),
mustParseTime(time.RFC3339, "2018-01-14T06:05:48.839Z"),
mustParseTime(time.RFC3339, "2018-01-14T06:05:48.839Z"),
mustParseTime(time.RFC3339, "2018-01-14T06:05:48.839Z"),
mustParseTime(time.RFC3339, "2018-01-14T06:05:48.839Z"),
},
},
},
Expand Down Expand Up @@ -433,7 +425,7 @@ func TestParse(t *testing.T) {
wantOut: &Params{
Limit: 25,
FilterExp: "created_at > ? AND work_address LIKE ? AND (work_salary = ? OR (work_salary >= ? AND work_salary <= ?))",
FilterArgs: []interface{}{mustParseTime("2018-01-14T06:05:48.839Z"), "%DC%", 100, 200, 300},
FilterArgs: []interface{}{mustParseTime(time.RFC3339, "2018-01-14T06:05:48.839Z"), "%DC%", 100, 200, 300},
},
},
{
Expand Down Expand Up @@ -541,6 +533,56 @@ func TestParse(t *testing.T) {
FilterArgs: []interface{}{"id", "full_name", "http_url", "uuid"},
},
},
{
name: "time unix layout",
conf: Config{
Model: new(struct {
CreatedAt time.Time `rql:"filter,layout=UnixDate"`
}),
},
input: []byte(`{
"filter": {
"created_at": { "$gt": "Thu May 23 09:30:06 IDT 2000" }
}
}`),
wantOut: &Params{
Limit: 25,
FilterExp: "created_at > ?",
FilterArgs: []interface{}{mustParseTime(time.UnixDate, "Thu May 23 09:30:06 IDT 2000")},
},
},
{
name: "time custom layout",
conf: Config{
Model: new(struct {
CreatedAt time.Time `rql:"filter,layout=2006-01-02 15:04"`
}),
},
input: []byte(`{
"filter": {
"created_at": { "$gt": "2006-01-02 15:04" }
}
}`),
wantOut: &Params{
Limit: 25,
FilterExp: "created_at > ?",
FilterArgs: []interface{}{mustParseTime("2006-01-02 15:04", "2006-01-02 15:04")},
},
},
{
name: "mismatch time unix layout",
conf: Config{
Model: new(struct {
CreatedAt time.Time `rql:"filter,layout=UnixDate"`
}),
},
input: []byte(`{
"filter": {
"created_at": { "$gt": "2006-01-02 15:04" }
}
}`),
wantErr: true,
},
{
name: "mismatch int type 1",
conf: Config{
Expand Down Expand Up @@ -872,7 +914,7 @@ func split(e string) []string {
return s
}

func mustParseTime(s string) time.Time {
t, _ := time.Parse(time.RFC3339, s)
func mustParseTime(layout, s string) time.Time {
t, _ := time.Parse(layout, s)
return t
}

0 comments on commit cc9f6f2

Please sign in to comment.