From d0368c77837a2222c80afbf187afc1145b8673f1 Mon Sep 17 00:00:00 2001 From: Ariel Mashraki Date: Tue, 21 May 2019 00:16:09 +0300 Subject: [PATCH] rql: add a time layout option Closed #11 --- README.md | 10 +++- integration/rql_test.go | 12 ++++- rql.go | 116 +++++++++++++++++++++++++++------------- rql_test.go | 90 ++++++++++++++++++++++--------- 4 files changed, 163 insertions(+), 65 deletions(-) diff --git a/README.md b/README.md index 4f6e58c..6e852f7 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/integration/rql_test.go b/integration/rql_test.go index afb689d..2a77a22 100644 --- a/integration/rql_test.go +++ b/integration/rql_test.go @@ -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) { @@ -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"] }`) @@ -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) } } @@ -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) diff --git a/rql.go b/rql.go index 50dc1ab..7d3bc45 100644 --- a/rql.go +++ b/rql.go @@ -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 } @@ -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++ { @@ -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) @@ -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: @@ -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 { @@ -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++ @@ -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. @@ -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, +} diff --git a/rql_test.go b/rql_test.go index c188735..240cf7c 100644 --- a/rql_test.go +++ b/rql_test.go @@ -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", @@ -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) { @@ -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{ @@ -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"), }, }, }, @@ -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}, }, }, { @@ -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{ @@ -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 }