diff --git a/db.go b/db.go index ec5659ac66cb77b628b1244c2d599d8bfc01c3eb..d28d94e2028e24099b40b275c18f23a618bc7815 100644 --- a/db.go +++ b/db.go @@ -100,6 +100,10 @@ type Func struct { // } type And []interface{} +func (a And) And(exp ...interface{}) And { + return append(a, exp...) +} + // Or is an array of interfaced that is used to join two or more expressions // under logical disjunction, it accepts `db.Cond{}`, `db.And{}`, `db.Raw{}` // and other `db.Or{}` values. @@ -113,6 +117,10 @@ type And []interface{} // } type Or []interface{} +func (o Or) Or(exp ...interface{}) Or { + return append(o, exp...) +} + // Raw holds chunks of data to be passed to the database without any filtering. // Use with care. // diff --git a/postgresql/_dumps/structs.sql b/postgresql/_dumps/structs.sql index 0b4069022cefa2a685b64745a97d2bdb97cdab06..25a210f573e6ba623a789e25db6cda8da9b7fd50 100644 --- a/postgresql/_dumps/structs.sql +++ b/postgresql/_dumps/structs.sql @@ -64,3 +64,12 @@ CREATE TABLE composite_keys ( some_val varchar(255) default '', primary key (code, user_id) ); + +DROP TABLE IF EXISTS option_types; + +CREATE TABLE option_types ( + id serial primary key, + name varchar(255) default '', + tags varchar(64)[], + settings jsonb +); diff --git a/postgresql/database_test.go b/postgresql/database_test.go index c474f78c491e1859db7cb2ecf93a70b50767eb19..c0d522a9c57d21d8b2eee4ddd9c7d99422351346 100644 --- a/postgresql/database_test.go +++ b/postgresql/database_test.go @@ -1651,6 +1651,281 @@ func TestDataTypes(t *testing.T) { } } +func TestOptionTypes(t *testing.T) { + var err error + var sess db.Database + var optionTypes db.Collection + + if sess, err = db.Open(Adapter, settings); err != nil { + t.Fatal(err) + } + + defer sess.Close() + + if optionTypes, err = sess.Collection("option_types"); err != nil { + t.Fatal(err) + } + + if err = optionTypes.Truncate(); err != nil { + t.Fatal(err) + } + + // TODO: lets do some benchmarking on these auto-wrapped option types.. + + // TODO: add nullable jsonb field mapped to a []string + + // A struct with wrapped option types defined in the struct tags + // for postgres string array and jsonb types + type optionType struct { + ID int64 `db:"id,omitempty"` + Name string `db:"name"` + Tags []string `db:"tags,stringarray"` + Settings map[string]interface{} `db:"settings,jsonb"` + } + + // Item 1 + item1 := optionType{ + Name: "Food", + Tags: []string{"toronto", "pizza"}, + Settings: map[string]interface{}{"a": 1, "b": 2}, + } + + id, err := optionTypes.Append(item1) + if err != nil { + t.Fatal(err) + } + + if pk, ok := id.(int64); !ok || pk == 0 { + t.Fatalf("Expecting an ID.") + } + + var item1Chk optionType + if err := optionTypes.Find(db.Cond{"id": id}).One(&item1Chk); err != nil { + t.Fatal(err) + } + + if item1Chk.Settings["a"].(float64) != 1 { // float64 because of json.. + t.Fatalf("Expecting Settings['a'] of jsonb value to be 1") + } + + if item1Chk.Tags[0] != "toronto" { + t.Fatalf("Expecting first element of Tags stringarray to be 'toronto'") + } + + // Item 1 B + item1b := &optionType{ + Name: "Golang", + Tags: []string{"love", "it"}, + Settings: map[string]interface{}{"go": 1, "lang": 2}, + } + + id, err = optionTypes.Append(item1b) + if err != nil { + t.Fatal(err) + } + + if pk, ok := id.(int64); !ok || pk == 0 { + t.Fatalf("Expecting an ID.") + } + + var item1bChk optionType + if err := optionTypes.Find(db.Cond{"id": id}).One(&item1bChk); err != nil { + t.Fatal(err) + } + + if item1bChk.Settings["go"].(float64) != 1 { // float64 because of json.. + t.Fatalf("Expecting Settings['go'] of jsonb value to be 1") + } + + if item1bChk.Tags[0] != "love" { + t.Fatalf("Expecting first element of Tags stringarray to be 'love'") + } + + // Item 1 C + item1c := &optionType{ + Name: "Sup", Tags: []string{}, Settings: map[string]interface{}{}, + } + + id, err = optionTypes.Append(item1c) + if err != nil { + t.Fatal(err) + } + + if pk, ok := id.(int64); !ok || pk == 0 { + t.Fatalf("Expecting an ID.") + } + + var item1cChk optionType + if err := optionTypes.Find(db.Cond{"id": id}).One(&item1cChk); err != nil { + t.Fatal(err) + } + + if len(item1cChk.Tags) != 0 { + t.Fatalf("Expecting tags array to be empty but is %v", item1cChk.Tags) + } + + if len(item1cChk.Settings) != 0 { + t.Fatalf("Expecting Settings map to be empty") + } + + // An option type to pointer jsonb field + type optionType2 struct { + ID int64 `db:"id,omitempty"` + Name string `db:"name"` + Tags []string `db:"tags,stringarray"` + Settings *map[string]interface{} `db:"settings,jsonb"` + } + + item2 := optionType2{ + Name: "JS", Tags: []string{"hi", "bye"}, Settings: nil, + } + + id, err = optionTypes.Append(item2) + if err != nil { + t.Fatal(err) + } + + if pk, ok := id.(int64); !ok || pk == 0 { + t.Fatalf("Expecting an ID.") + } + + var item2Chk optionType2 + res := optionTypes.Find(db.Cond{"id": id}) + if err := res.One(&item2Chk); err != nil { + t.Fatal(err) + } + + if item2Chk.ID != id.(int64) { + t.Fatalf("Expecting id to match") + } + + if item2Chk.Name != item2.Name { + t.Fatalf("Expecting Name to match") + } + + if item2Chk.Tags[0] != item2.Tags[0] || len(item2Chk.Tags) != len(item2.Tags) { + t.Fatalf("Expecting tags to match") + } + + // Update the value + m := map[string]interface{}{} + m["lang"] = "javascript" + m["num"] = 31337 + item2.Settings = &m + err = res.Update(item2) + if err != nil { + t.Fatal(err) + } + + if err := res.One(&item2Chk); err != nil { + t.Fatal(err) + } + + if (*item2Chk.Settings)["num"].(float64) != 31337 { // float64 because of json.. + t.Fatalf("Expecting Settings['num'] of jsonb value to be 31337") + } + + if (*item2Chk.Settings)["lang"] != "javascript" { + t.Fatalf("Expecting Settings['lang'] of jsonb value to be 'javascript'") + } + + // An option type to pointer string array field + type optionType3 struct { + ID int64 `db:"id,omitempty"` + Name string `db:"name"` + Tags *[]string `db:"tags,stringarray"` + Settings map[string]interface{} `db:"settings,jsonb"` + } + + item3 := optionType3{ + Name: "Julia", Tags: nil, Settings: map[string]interface{}{"girl": true, "lang": true}, + } + + id, err = optionTypes.Append(item3) + if err != nil { + t.Fatal(err) + } + + if pk, ok := id.(int64); !ok || pk == 0 { + t.Fatalf("Expecting an ID.") + } + + var item3Chk optionType2 + if err := optionTypes.Find(db.Cond{"id": id}).One(&item3Chk); err != nil { + t.Fatal(err) + } +} + +func TestOptionTypeJsonbStruct(t *testing.T) { + var err error + var sess db.Database + var optionTypes db.Collection + + if sess, err = db.Open(Adapter, settings); err != nil { + t.Fatal(err) + } + + defer sess.Close() + + if optionTypes, err = sess.Collection("option_types"); err != nil { + t.Fatal(err) + } + + if err = optionTypes.Truncate(); err != nil { + t.Fatal(err) + } + + // A struct with wrapped option types defined in the struct tags + // for postgres string array and jsonb types + type Settings struct { + Name string `json:"name"` + Num int64 `json:"num"` + } + + type OptionType struct { + ID int64 `db:"id,omitempty"` + Name string `db:"name"` + Tags []string `db:"tags,stringarray"` + Settings Settings `db:"settings,jsonb"` + } + + item1 := &OptionType{ + Name: "Hi", + Tags: []string{"aah", "ok"}, + Settings: Settings{Name: "a", Num: 123}, + } + + id, err := optionTypes.Append(item1) + if err != nil { + t.Fatal(err) + } + + if pk, ok := id.(int64); !ok || pk == 0 { + t.Fatalf("Expecting an ID.") + } + + var item1Chk OptionType + if err := optionTypes.Find(db.Cond{"id": id}).One(&item1Chk); err != nil { + t.Fatal(err) + } + + if len(item1Chk.Tags) != 2 { + t.Fatalf("Expecting 2 tags") + } + + if item1Chk.Tags[0] != "aah" { + t.Fatalf("Expecting first tag to be 0") + } + + if item1Chk.Settings.Name != "a" { + t.Fatalf("Expecting Name to be 'a'") + } + + if item1Chk.Settings.Num != 123 { + t.Fatalf("Expecting Num to be 123") + } +} + // We are going to benchmark the engine, so this is no longed needed. func TestDisableDebug(t *testing.T) { os.Setenv(db.EnvEnableDebug, "") diff --git a/util/sqlutil/fetch.go b/util/sqlutil/fetch.go index cd75d147eed1066e2d4e86690bddb048b8b30e76..73dd137678940bdbc893a93a68d66f041bb77527 100644 --- a/util/sqlutil/fetch.go +++ b/util/sqlutil/fetch.go @@ -22,7 +22,7 @@ package sqlutil import ( - "errors" + "encoding/json" "reflect" "github.com/jmoiron/sqlx" @@ -150,16 +150,101 @@ func fetchResult(itemT reflect.Type, rows *sqlx.Rows, columns []string) (reflect case reflect.Struct: values := make([]interface{}, len(columns)) - fields := rows.Mapper.TraversalsByName(itemT, columns) + typeMap := rows.Mapper.TypeMap(itemT) + fieldMap := typeMap.Names + wrappedValues := map[reflect.Value][]interface{}{} - if err = fieldsByTraversal(item, fields, values, true); err != nil { - return item, err + for i, k := range columns { + fi, ok := fieldMap[k] + if !ok { + values[i] = new(interface{}) + continue + } + + f := reflectx.FieldByIndexesReadOnly(item, fi.Index) + + // TODO: refactor into a nice pattern + if _, ok := fi.Options["stringarray"]; ok { + values[i] = &[]byte{} + wrappedValues[f] = []interface{}{"stringarray", values[i]} + } else if _, ok := fi.Options["int64array"]; ok { + values[i] = &[]byte{} + wrappedValues[f] = []interface{}{"int64array", values[i]} + } else if _, ok := fi.Options["jsonb"]; ok { + values[i] = &[]byte{} + wrappedValues[f] = []interface{}{"jsonb", values[i]} + } else { + values[i] = f.Addr().Interface() + } + + if u, ok := values[i].(db.Unmarshaler); ok { + values[i] = scanner{u} + } } + // Scanner - for reads + // Valuer - for writes + + // OptionTypes + // - before/after scan + // - before/after valuer.. + if err = rows.Scan(values...); err != nil { return item, err } + // TODO: move this stuff out of here.. find a nice pattern + for f, v := range wrappedValues { + opt := v[0].(string) + b := v[1].(*[]byte) + + switch opt { + case "stringarray": + v := StringArray{} + err := v.Scan(*b) + if err != nil { + return item, err + } + f.Set(reflect.ValueOf(v)) + case "int64array": + v := Int64Array{} + err := v.Scan(*b) + if err != nil { + return item, err + } + f.Set(reflect.ValueOf(v)) + case "jsonb": + if len(*b) == 0 { + continue + } + + var vv reflect.Value + t := reflect.PtrTo(f.Type()) + + switch t.Kind() { + case reflect.Map: + vv = reflect.MakeMap(t) + case reflect.Slice: + vv = reflect.MakeSlice(t, 0, 0) + default: + vv = reflect.New(t) + } + + err := json.Unmarshal(*b, vv.Interface()) + if err != nil { + return item, err + } + + vv = vv.Elem().Elem() + + if !vv.IsValid() || (vv.Kind() == reflect.Ptr && vv.IsNil()) { + continue + } + + f.Set(vv) + } + } + case reflect.Map: columns, err := rows.Columns() @@ -188,34 +273,3 @@ func fetchResult(itemT reflect.Type, rows *sqlx.Rows, columns []string) (reflect return item, nil } - -func fieldsByTraversal(v reflect.Value, traversals [][]int, values []interface{}, ptrs bool) error { - v = reflect.Indirect(v) - - if v.Kind() != reflect.Struct { - return errors.New("argument not a struct") - } - - for i, traversal := range traversals { - - if len(traversal) == 0 { - values[i] = new(interface{}) - continue - } - - f := reflectx.FieldByIndexes(v, traversal) - - if ptrs { - values[i] = f.Addr().Interface() - } else { - values[i] = f.Interface() - } - - // Provides compatibility with db.Unmarshaler - if u, ok := values[i].(db.Unmarshaler); ok { - values[i] = scanner{u} - } - - } - return nil -} diff --git a/util/sqlutil/scanner.go b/util/sqlutil/scanner.go index 5baa5ca8d3b695171fd9ee10160b0c6e16f0c4ff..34bde7a0fe4fc2b2b8db6d9c6f32c9bda48cd132 100644 --- a/util/sqlutil/scanner.go +++ b/util/sqlutil/scanner.go @@ -23,6 +23,12 @@ package sqlutil import ( "database/sql" + "database/sql/driver" + "encoding/json" + "errors" + "strconv" + "strings" + "upper.io/db" ) @@ -35,3 +41,148 @@ func (u scanner) Scan(v interface{}) error { } var _ sql.Scanner = scanner{} + +//------ + +type JsonbType struct { + V interface{} +} + +func (j *JsonbType) Scan(src interface{}) error { + b, ok := src.([]byte) + if !ok { + return errors.New("Scan source was not []bytes") + } + + v := JsonbType{} + if err := json.Unmarshal(b, &v.V); err != nil { + return err + } + *j = v + return nil +} + +func (j JsonbType) Value() (driver.Value, error) { + b, err := json.Marshal(j.V) + if err != nil { + return nil, err + } + return b, nil +} + +//------ + +type StringArray []string + +func (a *StringArray) Scan(src interface{}) error { + if src == nil { + *a = StringArray{} + return nil + } + b, ok := src.([]byte) + if !ok { + return errors.New("Scan source was not []bytes") + } + if len(b) == 0 { + return nil + } + s := string(b)[1 : len(b)-1] + if s == "" { + return nil + } + results := strings.Split(s, ",") + *a = StringArray(results) + return nil +} + +// Value implements the driver.Valuer interface. +func (a StringArray) Value() (driver.Value, error) { + if a == nil { + return nil, nil + } + + if n := len(a); n > 0 { + // There will be at least two curly brackets, 2*N bytes of quotes, + // and N-1 bytes of delimiters. + b := make([]byte, 1, 1+3*n) + b[0] = '{' + + b = appendArrayQuotedString(b, a[0]) + for i := 1; i < n; i++ { + b = append(b, ',') + b = appendArrayQuotedString(b, a[i]) + } + + return append(b, '}'), nil + } + + return []byte{'{', '}'}, nil +} + +func appendArrayQuotedString(b []byte, v string) []byte { + b = append(b, '"') + for { + i := strings.IndexAny(v, `"\`) + if i < 0 { + b = append(b, v...) + break + } + if i > 0 { + b = append(b, v[:i]...) + } + b = append(b, '\\', v[i]) + v = v[i+1:] + } + return append(b, '"') +} + +//------ + +type Int64Array []int64 + +func (a *Int64Array) Scan(src interface{}) error { + if src == nil { + return nil + } + b, ok := src.([]byte) + if !ok { + return errors.New("Scan source was not []bytes") + } + + s := string(b)[1 : len(b)-1] + parts := strings.Split(s, ",") + results := make([]int64, 0) + for _, n := range parts { + i, err := strconv.ParseInt(n, 10, 64) + if err != nil { + return err + } + results = append(results, i) + } + *a = Int64Array(results) + return nil +} + +// Value implements the driver.Valuer interface. +func (a Int64Array) Value() (driver.Value, error) { + if a == nil { + return nil, nil + } + + if n := len(a); n > 0 { + // There will be at least two curly brackets, N bytes of values, + // and N-1 bytes of delimiters. + b := make([]byte, 1, 1+2*n) + b[0] = '{' + + b = strconv.AppendInt(b, a[0], 10) + for i := 1; i < n; i++ { + b = append(b, ',') + b = strconv.AppendInt(b, a[i], 10) + } + + return append(b, '}'), nil + } + + return []byte{'{', '}'}, nil +} diff --git a/util/sqlutil/sqlutil.go b/util/sqlutil/sqlutil.go index be01beddc5ad0bd9efcf8e51f66ce72e89cfcb8e..93427815168f626d965b3990c9b71fee3b0d21fc 100644 --- a/util/sqlutil/sqlutil.go +++ b/util/sqlutil/sqlutil.go @@ -23,14 +23,12 @@ package sqlutil import ( "database/sql" + "fmt" "reflect" "regexp" "strings" "github.com/jmoiron/sqlx/reflectx" - - "menteslibres.net/gosexy/to" - "upper.io/db" ) @@ -46,11 +44,6 @@ var ( nullStringType = reflect.TypeOf(sql.NullString{}) ) -// NormalizeColumn prepares a column for comparison against another column. -func NormalizeColumn(s string) string { - return strings.ToLower(reColumnCompareExclude.ReplaceAllString(s, "")) -} - // T type is commonly used by adapters to map database/sql values to Go values // using FieldValues() type T struct { @@ -60,23 +53,13 @@ type T struct { func (t *T) columnLike(s string) string { for _, name := range t.Columns { - if NormalizeColumn(s) == NormalizeColumn(name) { + if normalizeColumn(s) == normalizeColumn(name) { return name } } return s } -func marshal(v interface{}) (interface{}, error) { - if m, isMarshaler := v.(db.Marshaler); isMarshaler { - var err error - if v, err = m.MarshalDB(); err != nil { - return nil, err - } - } - return v, nil -} - func (t *T) FieldValues(item interface{}) ([]string, []interface{}, error) { fields := []string{} values := []interface{}{} @@ -95,14 +78,30 @@ func (t *T) FieldValues(item interface{}) ([]string, []interface{}, error) { case reflect.Struct: - fieldMap := t.Mapper.TypeMap(itemT).FieldMap() + fieldMap := t.Mapper.TypeMap(itemT).Names nfields := len(fieldMap) values = make([]interface{}, 0, nfields) fields = make([]string, 0, nfields) for _, fi := range fieldMap { - value := reflectx.FieldByIndexesReadOnly(itemV, fi.Index).Interface() + // log.Println("=>", fi.Name, fi.Options) + + fld := reflectx.FieldByIndexesReadOnly(itemV, fi.Index) + if fld.Kind() == reflect.Ptr && fld.IsNil() { + continue + } + + var value interface{} + if _, ok := fi.Options["stringarray"]; ok { + value = StringArray(fld.Interface().([]string)) + } else if _, ok := fi.Options["int64array"]; ok { + value = Int64Array(fld.Interface().([]int64)) + } else if _, ok := fi.Options["jsonb"]; ok { + value = JsonbType{fld.Interface()} + } else { + value = fld.Interface() + } if _, ok := fi.Options["omitempty"]; ok { if value == fi.Zero.Interface() { @@ -128,7 +127,7 @@ func (t *T) FieldValues(item interface{}) ([]string, []interface{}, error) { for i, keyV := range mkeys { valv := itemV.MapIndex(keyV) - fields[i] = t.columnLike(to.String(keyV.Interface())) + fields[i] = t.columnLike(fmt.Sprintf("%v", keyV.Interface())) v, err := marshal(valv.Interface()) if err != nil { @@ -137,6 +136,7 @@ func (t *T) FieldValues(item interface{}) ([]string, []interface{}, error) { values[i] = v } + default: return nil, nil, db.ErrExpectingMapOrStruct } @@ -144,6 +144,16 @@ func (t *T) FieldValues(item interface{}) ([]string, []interface{}, error) { return fields, values, nil } +func marshal(v interface{}) (interface{}, error) { + if m, isMarshaler := v.(db.Marshaler); isMarshaler { + var err error + if v, err = m.MarshalDB(); err != nil { + return nil, err + } + } + return v, nil +} + func reset(data interface{}) error { // Resetting element. v := reflect.ValueOf(data).Elem() @@ -153,6 +163,11 @@ func reset(data interface{}) error { return nil } +// normalizeColumn prepares a column for comparison against another column. +func normalizeColumn(s string) string { + return strings.ToLower(reColumnCompareExclude.ReplaceAllString(s, "")) +} + // NewMapper creates a reflectx.Mapper func NewMapper() *reflectx.Mapper { return reflectx.NewMapper("db")