diff --git a/README.md b/README.md index 027d224..48dbc63 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # gocqlx [![GoDoc](http://img.shields.io/badge/go-documentation-blue.svg?style=flat-square)](http://godoc.org/github.com/scylladb/gocqlx) [![Go Report Card](https://goreportcard.com/badge/github.com/scylladb/gocqlx)](https://goreportcard.com/report/github.com/scylladb/gocqlx) [![Build Status](https://travis-ci.org/scylladb/gocqlx.svg?branch=master)](https://travis-ci.org/scylladb/gocqlx) -Package `gocqlx` is a `gocql` extension, similar to what `sqlx` is to `database/sql`. +Package `gocqlx` is a Scylla / Cassandra productivity toolkit for `gocql`, it's +similar to what `sqlx` is to `database/sql`. It contains wrappers over `gocql` types that provide convenience methods which are useful in the development of database driven applications. Under the @@ -12,43 +13,97 @@ hood it uses `sqlx/reflectx` package so `sqlx` models will also work with `gocql ## Features -Read all rows into a slice. +Fast, boilerplate free and flexible `SELECTS`, `INSERTS`, `UPDATES` and `DELETES`. ```go -var v []*Item -if err := gocqlx.Select(&v, session.Query(`SELECT * FROM items WHERE id = ?`, id)); err != nil { - log.Fatal("select failed", err) +type Person struct { + FirstName string // no need to add `db:"first_name"` etc. + LastName string + Email []string +} + +p := &Person{ + "Patricia", + "Citizen", + []string{"patricia.citzen@gocqlx_test.com"}, +} + +// Insert +{ + q := Query(qb.Insert("person").Columns("first_name", "last_name", "email").ToCql()) + if err := q.BindStruct(p); err != nil { + t.Fatal("bind:", err) + } + mustExec(q.Query) +} + +// Update +{ + p.Email = append(p.Email, "patricia1.citzen@gocqlx_test.com") + + q := Query(qb.Update("person").Set("email").Where(qb.Eq("first_name"), qb.Eq("last_name")).ToCql()) + if err := q.BindStruct(p); err != nil { + t.Fatal("bind:", err) + } + mustExec(q.Query) +} + +// Select +{ + q := Query(qb.Select("person").Where(qb.In("first_name")).ToCql()) + m := map[string]interface{}{ + "first_name": []string{"Patricia", "John"}, + } + if err := q.BindMap(m); err != nil { + t.Fatal("bind:", err) + } + + var people []Person + if err := gocqlx.Select(&people, q.Query); err != nil { + t.Fatal(err) + } + t.Log(people) + + // [{Patricia Citizen [patricia.citzen@gocqlx_test.com patricia1.citzen@gocqlx_test.com]} {John Doe [johndoeDNE@gmail.net]}] } ``` -Read a single row into a struct. +For more details see [example test](https://github.com/scylladb/gocqlx/blob/master/example_test.go). -```go -var v Item -if err := gocqlx.Get(&v, session.Query(`SELECT * FROM items WHERE id = ?`, id)); err != nil { - log.Fatal("get failed", err) -} +## Performance + +Gocqlx is fast, below is a benchmark result comparing `gocqlx` to raw `gocql` on +my machine, see the benchmark [here](https://github.com/scylladb/gocqlx/blob/master/benchmark_test.go). + +For query binding gocqlx is faster as it does not require parameter rewriting +while binding. For get and insert the performance is comparable. + +``` +BenchmarkE2EGocqlInsert-4 1000 1580420 ns/op 2624 B/op 59 allocs/op +BenchmarkE2EGocqlxInsert-4 2000 648769 ns/op 1557 B/op 34 allocs/op +BenchmarkE2EGocqlGet-4 3000 664618 ns/op 1086 B/op 29 allocs/op +BenchmarkE2EGocqlxGet-4 3000 631415 ns/op 1440 B/op 32 allocs/op +BenchmarkE2EGocqlSelect-4 50 35646283 ns/op 34072 B/op 922 allocs/op +BenchmarkE2EGocqlxSelect-4 50 37128897 ns/op 28304 B/op 933 allocs/op ``` -Bind named query parameters from a struct or map. +Gocqlx comes with automatic snake case support for field names and does not +require manual tagging. This is also fast, below is a comparison to +`strings.ToLower` function (`sqlx` default). -```go -stmt, names, err := gocqlx.CompileNamedQuery([]byte("INSERT INTO items (id, name) VALUES (:id, :name)")) -if err != nil { - t.Fatal("compile:", err) -} -q := gocqlx.Queryx{ - Query: session.Query(stmt), - Names: names, -} -if err := q.BindStruct(&Item{"id", "name"}); err != nil { - t.Fatal("bind:", err) -} -if err := q.Query.Exec(); err != nil { - log.Fatal("get failed", err) -} +``` +BenchmarkSnakeCase-4 10000000 124 ns/op 32 B/op 2 allocs/op +BenchmarkToLower-4 100000000 57.9 ns/op 0 B/op 0 allocs/op ``` -## Example +Building queries is fast and low on allocations too. -See [example test](https://github.com/scylladb/gocqlx/blob/master/example_test.go). +``` +BenchmarkCmp-4 3000000 464 ns/op 112 B/op 3 allocs/op +BenchmarkDeleteBuilder-4 10000000 214 ns/op 112 B/op 2 allocs/op +BenchmarkInsertBuilder-4 20000000 103 ns/op 64 B/op 1 allocs/op +BenchmarkSelectBuilder-4 10000000 214 ns/op 112 B/op 2 allocs/op +BenchmarkUpdateBuilder-4 10000000 212 ns/op 112 B/op 2 allocs/op +``` + +Enyoy! diff --git a/benchmark_test.go b/benchmark_test.go new file mode 100644 index 0000000..f7755b1 --- /dev/null +++ b/benchmark_test.go @@ -0,0 +1,239 @@ +// +build integration + +package gocqlx_test + +import ( + "encoding/json" + "os" + "testing" + + "github.com/gocql/gocql" + "github.com/scylladb/gocqlx" + "github.com/scylladb/gocqlx/qb" +) + +type benchPerson struct { + ID int `json:"id"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Email []string `json:"email"` + Gender string `json:"gender"` + IPAddress string `json:"ip_address"` +} + +var benchPersonSchema = ` +CREATE TABLE IF NOT EXISTS gocqlx_test.bench_person ( + id int, + first_name text, + last_name text, + email list, + gender text, + ip_address text, + PRIMARY KEY(id) +)` + +var benchPersonCols = []string{"id", "first_name", "last_name", "email", "gender", "ip_address"} + +func loadFixtures() []*benchPerson { + f, err := os.Open("test-fixtures/people.json") + if err != nil { + panic(err) + } + defer func() { + if err := f.Close(); err != nil { + panic(err) + } + }() + + var v []*benchPerson + if err := json.NewDecoder(f).Decode(&v); err != nil { + panic(err) + } + + return v +} + +// +// Insert +// + +// BenchmarkE2EGocqlInsert performs standard insert. +func BenchmarkE2EGocqlInsert(b *testing.B) { + people := loadFixtures() + session := createSession(b) + defer session.Close() + + if err := createTable(session, benchPersonSchema); err != nil { + b.Fatal(err) + } + + stmt, _ := qb.Insert("gocqlx_test.bench_person").Columns(benchPersonCols...).ToCql() + q := session.Query(stmt) + defer q.Release() + + b.ResetTimer() + for i := 0; i < b.N; i++ { + // prepare + p := people[i%len(people)] + if err := q.Bind(p.ID, p.FirstName, p.LastName, p.Email, p.Gender, p.IPAddress).Exec(); err != nil { + b.Fatal(err) + } + // insert + if err := q.Exec(); err != nil { + b.Fatal(err) + } + } +} + +// BenchmarkE2EGocqlInsert performs insert with struct binding. +func BenchmarkE2EGocqlxInsert(b *testing.B) { + people := loadFixtures() + session := createSession(b) + defer session.Close() + + if err := createTable(session, benchPersonSchema); err != nil { + b.Fatal(err) + } + + stmt, names := qb.Insert("gocqlx_test.bench_person").Columns(benchPersonCols...).ToCql() + q := gocqlx.Query(session.Query(stmt), names) + defer q.Release() + + b.ResetTimer() + + for i := 0; i < b.N; i++ { + // prepare + p := people[i%len(people)] + if err := q.BindStruct(p); err != nil { + b.Fatal("bind:", err) + } + // insert + if err := q.Exec(); err != nil { + b.Fatal(err) + } + } +} + +// +// Get +// + +// BenchmarkE2EGocqlGet performs standard scan. +func BenchmarkE2EGocqlGet(b *testing.B) { + people := loadFixtures() + session := createSession(b) + defer session.Close() + + initTable(b, session, people) + + stmt, _ := qb.Select("gocqlx_test.bench_person").Columns(benchPersonCols...).Where(qb.Eq("id")).Limit(1).ToCql() + var p benchPerson + + b.ResetTimer() + for i := 0; i < b.N; i++ { + // prepare + q := session.Query(stmt) + q.Bind(people[i%len(people)].ID) + // scan + if err := q.Scan(&p.ID, &p.FirstName, &p.LastName, &p.Email, &p.Gender, &p.IPAddress); err != nil { + b.Fatal(err) + } + // release + q.Release() + } +} + +// BenchmarkE2EGocqlxGet performs get. +func BenchmarkE2EGocqlxGet(b *testing.B) { + people := loadFixtures() + session := createSession(b) + defer session.Close() + + initTable(b, session, people) + + stmt, _ := qb.Select("gocqlx_test.bench_person").Columns(benchPersonCols...).Where(qb.Eq("id")).Limit(1).ToCql() + var p benchPerson + + b.ResetTimer() + for i := 0; i < b.N; i++ { + // prepare + q := session.Query(stmt) + q.Bind(people[i%len(people)].ID) + // get + gocqlx.Get(&p, q) + } +} + +// +// Select +// + +// BenchmarkE2EGocqlSelect performs standard loop scan. +func BenchmarkE2EGocqlSelect(b *testing.B) { + people := loadFixtures() + session := createSession(b) + defer session.Close() + + initTable(b, session, people) + + stmt, _ := qb.Select("gocqlx_test.bench_person").Columns(benchPersonCols...).Limit(100).ToCql() + + b.ResetTimer() + for i := 0; i < b.N; i++ { + // prepare + v := make([]*benchPerson, 100) + q := session.Query(stmt) + i := q.Iter() + // loop scan + p := new(benchPerson) + for i.Scan(&p.ID, &p.FirstName, &p.LastName, &p.Email, &p.Gender, &p.IPAddress) { + v = append(v, p) + p = new(benchPerson) + } + if err := i.Close(); err != nil { + b.Fatal(err) + } + // release + q.Release() + } +} + +// BenchmarkE2EGocqlSelect performs select. +func BenchmarkE2EGocqlxSelect(b *testing.B) { + people := loadFixtures() + session := createSession(b) + defer session.Close() + + initTable(b, session, people) + + stmt, _ := qb.Select("gocqlx_test.bench_person").Columns(benchPersonCols...).Limit(100).ToCql() + + b.ResetTimer() + for i := 0; i < b.N; i++ { + // prepare + q := session.Query(stmt) + var v []*benchPerson + // select + if err := gocqlx.Select(&v, q); err != nil { + b.Fatal(err) + } + } +} + +func initTable(b *testing.B, session *gocql.Session, people []*benchPerson) { + if err := createTable(session, benchPersonSchema); err != nil { + b.Fatal(err) + } + + stmt, names := qb.Insert("gocqlx_test.bench_person").Columns(benchPersonCols...).ToCql() + q := gocqlx.Query(session.Query(stmt), names) + + for _, p := range people { + if err := q.BindStruct(p); err != nil { + b.Fatal(err) + } + if err := q.Exec(); err != nil { + b.Fatal(err) + } + } +} diff --git a/common_test.go b/common_test.go index dc77158..77b79e6 100644 --- a/common_test.go +++ b/common_test.go @@ -72,7 +72,6 @@ func createKeyspace(tb testing.TB, cluster *gocql.ClusterConfig, keyspace string panic(err) } defer session.Close() - defer tb.Log("closing keyspace session") err = createTable(session, `DROP KEYSPACE IF EXISTS `+keyspace) if err != nil { diff --git a/doc.go b/doc.go index e7d64bf..e411ae0 100644 --- a/doc.go +++ b/doc.go @@ -1,38 +1,7 @@ -// Package gocqlx is a gocql extension, similar to what sqlx is to database/sql. +// Package gocqlx is a Scylla / Cassandra productivity toolkit for `gocql`, it's +// similar to what `sqlx` is to `database/sql`. // // It contains wrappers over gocql types that provide convenience methods which // are useful in the development of database driven applications. Under the // hood it uses sqlx/reflectx package so sqlx models will also work with gocqlx. -// -// Example, read all rows into a slice -// -// var v []*Item -// if err := gocqlx.Select(&v, session.Query(`SELECT * FROM items WHERE id = ?`, id)); err != nil { -// log.Fatal("select failed", err) -// } -// -// Example, read a single row into a struct -// -// var v Item -// if err := gocqlx.Get(&v, session.Query(`SELECT * FROM items WHERE id = ?`, id)); err != nil { -// log.Fatal("get failed", err) -// } -// -// Example, bind named query parameters from a struct or map -// -// stmt, names, err := gocqlx.CompileNamedQuery([]byte("INSERT INTO items (id, name) VALUES (:id, :name)")) -// if err != nil { -// t.Fatal("compile:", err) -// } -// q := gocqlx.Queryx{ -// Query: session.Query(stmt), -// Names: names, -// } -// if err := q.BindStruct(&Item{"id", "name"}); err != nil { -// t.Fatal("bind:", err) -// } -// if err := q.Query.Exec(); err != nil { -// log.Fatal("get failed", err) -// } -// package gocqlx diff --git a/example_test.go b/example_test.go index 403b2c6..15c0f08 100644 --- a/example_test.go +++ b/example_test.go @@ -3,15 +3,15 @@ package gocqlx_test import ( - "fmt" "testing" "github.com/gocql/gocql" "github.com/scylladb/gocqlx" + "github.com/scylladb/gocqlx/qb" ) var personSchema = ` -CREATE TABLE gocqlx_test.person ( +CREATE TABLE IF NOT EXISTS gocqlx_test.person ( first_name text, last_name text, email list, @@ -19,7 +19,7 @@ CREATE TABLE gocqlx_test.person ( )` var placeSchema = ` -CREATE TABLE gocqlx_test.place ( +CREATE TABLE IF NOT EXISTS gocqlx_test.place ( country text, city text, code int, @@ -27,7 +27,7 @@ CREATE TABLE gocqlx_test.place ( )` // Field names are converted to camel case by default, no need to add -// `db:"first_name"`, if you want to disable a filed add `db:"-"` tag +// `db:"first_name"`, if you want to disable a filed add `db:"-"` tag. type Person struct { FirstName string LastName string @@ -46,13 +46,15 @@ func TestExample(t *testing.T) { mustExec := func(q *gocql.Query) { if err := q.Exec(); err != nil { - t.Fatal("insert:", q, err) + t.Fatal("query:", q, err) } } - // Fill person table + // Fill person table. { - mustExec(session.Query(personSchema)) + if err := createTable(session, personSchema); err != nil { + t.Fatal("create table:", err) + } q := session.Query("INSERT INTO gocqlx_test.person (first_name, last_name, email) VALUES (?, ?, ?)") mustExec(q.Bind("Jason", "Moiron", []string{"jmoiron@jmoiron.net"})) @@ -60,9 +62,11 @@ func TestExample(t *testing.T) { q.Release() } - // Fill place table + // Fill place table. { - mustExec(session.Query(placeSchema)) + if err := createTable(session, placeSchema); err != nil { + t.Fatal("create table:", err) + } q := session.Query("INSERT INTO gocqlx_test.place (country, city, code) VALUES (?, ?, ?)") mustExec(q.Bind("United States", "New York", 1)) @@ -71,72 +75,131 @@ func TestExample(t *testing.T) { q.Release() } - // Query the database, storing results in a []Person (wrapped in []interface{}) + // Query the database, storing results in a []Person (wrapped in []interface{}). { - people := []Person{} - if err := gocqlx.Select(&people, session.Query("SELECT * FROM person")); err != nil { + var people []Person + if err := gocqlx.Select(&people, session.Query("SELECT * FROM gocqlx_test.person")); err != nil { t.Fatal("select:", err) } + t.Log(people) - fmt.Printf("%#v\n%#v\n", people[0], people[1]) - // gocqlx_test.Person{FirstName:"John", LastName:"Doe", Email:[]string{"johndoeDNE@gmail.net"}} - // gocqlx_test.Person{FirstName:"Jason", LastName:"Moiron", Email:[]string{"jmoiron@jmoiron.net"}} + // [{John Doe [johndoeDNE@gmail.net]} {Jason Moiron [jmoiron@jmoiron.net]}] } - // Get a single result, a la QueryRow + // Get a single result. { var jason Person - if err := gocqlx.Get(&jason, session.Query("SELECT * FROM person WHERE first_name=?", "Jason")); err != nil { + if err := gocqlx.Get(&jason, session.Query("SELECT * FROM gocqlx_test.person WHERE first_name=?", "Jason")); err != nil { t.Fatal("get:", err) } - fmt.Printf("%#v\n", jason) - // gocqlx_test.Person{FirstName:"Jason", LastName:"Moiron", Email:[]string{"jmoiron@jmoiron.net"}} + t.Log(jason) + + // Jason Moiron [jmoiron@jmoiron.net]} } - // Loop through rows using only one struct + // Loop through rows using only one struct. { var place Place - iter := gocqlx.Iter(session.Query("SELECT * FROM place")) + iter := gocqlx.Iter(session.Query("SELECT * FROM gocqlx_test.place")) for iter.StructScan(&place) { - fmt.Printf("%#v\n", place) + t.Log(place) } if err := iter.Close(); err != nil { t.Fatal("iter:", err) } iter.ReleaseQuery() - // gocqlx_test.Place{Country:"Hong Kong", City:"", TelCode:852} - // gocqlx_test.Place{Country:"United States", City:"New York", TelCode:1} - // gocqlx_test.Place{Country:"Singapore", City:"", TelCode:65} + + // {Hong Kong 852} + // {United States New York 1} + // {Singapore 65} } - // Named queries, using `:name` as the bindvar + // Query builder, using DSL to build queries, using `:name` as the bindvar. { - stmt, names, err := gocqlx.CompileNamedQuery([]byte("INSERT INTO person (first_name, last_name, email) VALUES (:first_name, :last_name, :email)")) + // helper function for creating session queries + Query := gocqlx.SessionQuery(session) + + p := &Person{ + "Patricia", + "Citizen", + []string{"patricia.citzen@gocqlx_test.com"}, + } + + // Insert + { + q := Query(qb.Insert("gocqlx_test.person").Columns("first_name", "last_name", "email").ToCql()) + if err := q.BindStruct(p); err != nil { + t.Fatal("bind:", err) + } + mustExec(q.Query) + } + + // Update + { + p.Email = append(p.Email, "patricia1.citzen@gocqlx_test.com") + + q := Query(qb.Update("gocqlx_test.person").Set("email").Where(qb.Eq("first_name"), qb.Eq("last_name")).ToCql()) + if err := q.BindStruct(p); err != nil { + t.Fatal("bind:", err) + } + mustExec(q.Query) + } + + // Select + { + q := Query(qb.Select("gocqlx_test.person").Where(qb.In("first_name")).ToCql()) + m := map[string]interface{}{ + "first_name": []string{"Patricia", "John"}, + } + if err := q.BindMap(m); err != nil { + t.Fatal("bind:", err) + } + + var people []Person + if err := gocqlx.Select(&people, q.Query); err != nil { + t.Fatal("select:", err) + } + t.Log(people) + + // [{Patricia Citizen [patricia.citzen@gocqlx_test.com patricia1.citzen@gocqlx_test.com]} {John Doe [johndoeDNE@gmail.net]}] + } + } + + // Named queries, using `:name` as the bindvar. + { + // compile query to valid gocqlx query and list of named parameters + stmt, names, err := gocqlx.CompileNamedQuery([]byte("INSERT INTO gocqlx_test.person (first_name, last_name, email) VALUES (:first_name, :last_name, :email)")) if err != nil { t.Fatal("compile:", err) } + q := gocqlx.Query(session.Query(stmt), names) - q := gocqlx.Queryx{ - Query: session.Query(stmt), - Names: names, + // bind named parameters from a struct + { + p := &Person{ + "Jane", + "Citizen", + []string{"jane.citzen@gocqlx_test.com"}, + } + + if err := q.BindStruct(p); err != nil { + t.Fatal("bind:", err) + } + mustExec(q.Query) } - if err := q.BindStruct(&Person{ - "Jane", - "Citizen", - []string{"jane.citzen@gocqlx_test.com"}, - }); err != nil { - t.Fatal("bind:", err) - } - mustExec(q.Query) + // bind named parameters from a map + { + m := map[string]interface{}{ + "first_name": "Bin", + "last_name": "Smuth", + "email": []string{"bensmith@allblacks.nz"}, + } - if err := q.BindMap(map[string]interface{}{ - "first_name": "Bin", - "last_name": "Smuth", - "email": []string{"bensmith@allblacks.nz"}, - }); err != nil { - t.Fatal("bind:", err) + if err := q.BindMap(m); err != nil { + t.Fatal("bind:", err) + } + mustExec(q.Query) } - mustExec(q.Query) } } diff --git a/mapper_test.go b/mapper_test.go index 98883a9..edeb54c 100644 --- a/mapper_test.go +++ b/mapper_test.go @@ -69,16 +69,12 @@ func TestSnakeCase(t *testing.T) { func BenchmarkSnakeCase(b *testing.B) { for i := 0; i < b.N; i++ { - for _, test := range snakeTable { - snakeCase(test.N) - } + snakeCase(snakeTable[b.N%len(snakeTable)].N) } } func BenchmarkToLower(b *testing.B) { for i := 0; i < b.N; i++ { - for _, test := range snakeTable { - strings.ToLower(test.N) - } + strings.ToLower(snakeTable[b.N%len(snakeTable)].N) } } diff --git a/qb/cmp.go b/qb/cmp.go new file mode 100644 index 0000000..3c7f21e --- /dev/null +++ b/qb/cmp.go @@ -0,0 +1,187 @@ +package qb + +import "bytes" + +// op specifies Cmd operation type. +type op byte + +const ( + eq op = iota + lt + leq + gt + geq + in + cnt +) + +// Cmp if a filtering comparator that is used in WHERE and IF clauses. +type Cmp struct { + op op + column string + name string +} + +func (cmp Cmp) writeCql(cql *bytes.Buffer) string { + cql.WriteString(cmp.column) + switch cmp.op { + case eq: + cql.WriteByte('=') + case lt: + cql.WriteByte('<') + case leq: + cql.WriteByte('<') + cql.WriteByte('=') + case gt: + cql.WriteByte('>') + case geq: + cql.WriteByte('>') + cql.WriteByte('=') + case in: + cql.WriteString(" IN ") + case cnt: + cql.WriteString(" CONTAINS ") + } + cql.WriteByte('?') + + return cmp.name +} + +// Eq produces column=?. +func Eq(column string) Cmp { + return Cmp{ + op: eq, + column: column, + name: column, + } +} + +// EqNamed produces column=? with a custom parameter name. +func EqNamed(column, name string) Cmp { + return Cmp{ + op: eq, + column: column, + name: name, + } +} + +// Lt produces column?. +func Gt(column string) Cmp { + return Cmp{ + op: gt, + column: column, + name: column, + } +} + +// GtNamed produces column>? with a custom parameter name. +func GtNamed(column, name string) Cmp { + return Cmp{ + op: gt, + column: column, + name: name, + } +} + +// GtOrEq produces column>=?. +func GtOrEq(column string) Cmp { + return Cmp{ + op: geq, + column: column, + name: column, + } +} + +// GtOrEqNamed produces column>=? with a custom parameter name. +func GtOrEqNamed(column, name string) Cmp { + return Cmp{ + op: geq, + column: column, + name: name, + } +} + +// In produces column IN ?. +func In(column string) Cmp { + return Cmp{ + op: in, + column: column, + name: column, + } +} + +// InNamed produces column IN ? with a custom parameter name. +func InNamed(column, name string) Cmp { + return Cmp{ + op: in, + column: column, + name: name, + } +} + +// Contains produces column CONTAINS ?. +func Contains(column string) Cmp { + return Cmp{ + op: cnt, + column: column, + name: column, + } +} + +// ContainsNamed produces column CONTAINS ? with a custom parameter name. +func ContainsNamed(column, name string) Cmp { + return Cmp{ + op: cnt, + column: column, + name: name, + } +} + +type cmps []Cmp + +func (cs cmps) writeCql(cql *bytes.Buffer) (names []string) { + for i, c := range cs { + names = append(names, c.writeCql(cql)) + if i < len(cs)-1 { + cql.WriteString(" AND ") + } + } + cql.WriteByte(' ') + return +} diff --git a/qb/cmp_test.go b/qb/cmp_test.go new file mode 100644 index 0000000..d0870eb --- /dev/null +++ b/qb/cmp_test.go @@ -0,0 +1,114 @@ +package qb + +import ( + "bytes" + "testing" + + "github.com/google/go-cmp/cmp" +) + +func TestCmp(t *testing.T) { + table := []struct { + C Cmp + S string + N string + }{ + { + C: Eq("eq"), + S: "eq=?", + N: "eq", + }, + { + C: EqNamed("eq", "name"), + S: "eq=?", + N: "name", + }, + { + C: Lt("lt"), + S: "lt?", + N: "gt", + }, + { + C: GtNamed("gt", "name"), + S: "gt>?", + N: "name", + }, + { + C: GtOrEq("gt"), + S: "gt>=?", + N: "gt", + }, + { + C: GtOrEqNamed("gt", "name"), + S: "gt>=?", + N: "name", + }, + { + C: In("in"), + S: "in IN ?", + N: "in", + }, + { + C: InNamed("in", "name"), + S: "in IN ?", + N: "name", + }, + { + C: Contains("cnt"), + S: "cnt CONTAINS ?", + N: "cnt", + }, + { + C: ContainsNamed("cnt", "name"), + S: "cnt CONTAINS ?", + N: "name", + }, + } + + buf := bytes.Buffer{} + for _, test := range table { + buf.Reset() + name := test.C.writeCql(&buf) + if diff := cmp.Diff(test.S, buf.String()); diff != "" { + t.Error(diff) + } + if diff := cmp.Diff(test.N, name); diff != "" { + t.Error(diff) + } + } +} + +func BenchmarkCmp(b *testing.B) { + buf := bytes.Buffer{} + b.ResetTimer() + for i := 0; i < b.N; i++ { + buf.Reset() + c := cmps{ + Eq("id"), + Lt("user_uuid"), + LtOrEq("firstname"), + Gt("stars"), + } + c.writeCql(&buf) + } +} diff --git a/qb/delete.go b/qb/delete.go new file mode 100644 index 0000000..21c92e1 --- /dev/null +++ b/qb/delete.go @@ -0,0 +1,90 @@ +package qb + +// DELETE reference: +// https://cassandra.apache.org/doc/latest/cql/dml.html#delete + +import ( + "bytes" + "time" +) + +// DeleteBuilder builds CQL DELETE statements. +type DeleteBuilder struct { + table string + columns columns + using using + where where + _if _if + exists bool +} + +// Delete returns a new DeleteBuilder with the given table name. +func Delete(table string) *DeleteBuilder { + return &DeleteBuilder{ + table: table, + } +} + +// ToCql builds the query into a CQL string and named args. +func (b *DeleteBuilder) ToCql() (stmt string, names []string) { + cql := bytes.Buffer{} + + cql.WriteString("DELETE ") + if len(b.columns) > 0 { + b.columns.writeCql(&cql) + cql.WriteByte(' ') + } + cql.WriteString("FROM ") + cql.WriteString(b.table) + cql.WriteByte(' ') + + b.using.writeCql(&cql) + + names = append(names, b.where.writeCql(&cql)...) + names = append(names, b._if.writeCql(&cql)...) + + if b.exists { + cql.WriteString("IF EXISTS ") + } + + stmt = cql.String() + return +} + +// From sets the table to be deleted from. +func (b *DeleteBuilder) From(table string) *DeleteBuilder { + b.table = table + return b +} + +// Columns adds delete columns to the query. +func (b *DeleteBuilder) Columns(columns ...string) *DeleteBuilder { + b.columns = append(b.columns, columns...) + return b +} + +// Timestamp sets a USING TIMESTAMP clause on the query. +func (b *DeleteBuilder) Timestamp(t time.Time) *DeleteBuilder { + b.using.timestamp = t + return b +} + +// Where adds an expression to the WHERE clause of the query. Expressions are +// ANDed together in the generated CQL. +func (b *DeleteBuilder) Where(w ...Cmp) *DeleteBuilder { + b.where = append(b.where, w...) + return b +} + +// If adds an expression to the IF clause of the query. Expressions are ANDed +// together in the generated CQL. +func (b *DeleteBuilder) If(w ...Cmp) *DeleteBuilder { + b._if = append(b._if, w...) + return b +} + +// Existing sets a IF EXISTS clause on the query. +func (b *DeleteBuilder) Existing() *DeleteBuilder { + b.exists = true + return b +} diff --git a/qb/delete_test.go b/qb/delete_test.go new file mode 100644 index 0000000..db01ca1 --- /dev/null +++ b/qb/delete_test.go @@ -0,0 +1,78 @@ +package qb + +import ( + "testing" + "time" + + "github.com/google/go-cmp/cmp" +) + +func TestDeleteBuilder(t *testing.T) { + w := EqNamed("id", "expr") + + table := []struct { + B *DeleteBuilder + N []string + S string + }{ + // Basic test for delete + { + B: Delete("cycling.cyclist_name").Where(w), + S: "DELETE FROM cycling.cyclist_name WHERE id=? ", + N: []string{"expr"}, + }, + // Change table name + { + B: Delete("cycling.cyclist_name").Where(w).From("Foobar"), + S: "DELETE FROM Foobar WHERE id=? ", + N: []string{"expr"}, + }, + // Add column + { + B: Delete("cycling.cyclist_name").Where(w).Columns("stars"), + S: "DELETE stars FROM cycling.cyclist_name WHERE id=? ", + N: []string{"expr"}, + }, + // Add WHERE + { + B: Delete("cycling.cyclist_name").Where(w, Gt("firstname")), + S: "DELETE FROM cycling.cyclist_name WHERE id=? AND firstname>? ", + N: []string{"expr", "firstname"}, + }, + // Add IF + { + B: Delete("cycling.cyclist_name").Where(w).If(Gt("firstname")), + S: "DELETE FROM cycling.cyclist_name WHERE id=? IF firstname>? ", + N: []string{"expr", "firstname"}, + }, + // Add TIMESTAMP + { + B: Delete("cycling.cyclist_name").Where(w).Timestamp(time.Unix(0, 0).Add(time.Microsecond * 123456789)), + S: "DELETE FROM cycling.cyclist_name USING TIMESTAMP 123456789 WHERE id=? ", + N: []string{"expr"}, + }, + // Add IF EXISTS + { + B: Delete("cycling.cyclist_name").Where(w).Existing(), + S: "DELETE FROM cycling.cyclist_name WHERE id=? IF EXISTS ", + N: []string{"expr"}, + }, + } + + for _, test := range table { + stmt, names := test.B.ToCql() + if diff := cmp.Diff(test.S, stmt); diff != "" { + t.Error(diff) + } + if diff := cmp.Diff(test.N, names); diff != "" { + t.Error(diff) + } + } +} + +func BenchmarkDeleteBuilder(b *testing.B) { + b.ResetTimer() + for i := 0; i < b.N; i++ { + Delete("cycling.cyclist_name").Columns("id", "user_uuid", "firstname", "stars").Where(Eq("id")) + } +} diff --git a/qb/doc.go b/qb/doc.go new file mode 100644 index 0000000..ee4b830 --- /dev/null +++ b/qb/doc.go @@ -0,0 +1,4 @@ +// Package qb provides CQL (Scylla / Cassandra query language) query builders. +// The builders create CQL statement and a list of named parameters that can +// later be bound using github.com/scylladb/gocqlx. +package qb diff --git a/qb/expr.go b/qb/expr.go new file mode 100644 index 0000000..e9d98b4 --- /dev/null +++ b/qb/expr.go @@ -0,0 +1,65 @@ +package qb + +import ( + "bytes" + "fmt" + "time" +) + +type columns []string + +func (cols columns) writeCql(cql *bytes.Buffer) { + for i, c := range cols { + cql.WriteString(c) + if i < len(cols)-1 { + cql.WriteByte(',') + } + } +} + +type using struct { + timestamp time.Time + ttl time.Duration +} + +func (u using) writeCql(cql *bytes.Buffer) { + ts := !u.timestamp.IsZero() + + if ts { + cql.WriteString("USING TIMESTAMP ") + cql.WriteString(fmt.Sprint(u.timestamp.UnixNano() / 1000)) + cql.WriteByte(' ') + } + + if u.ttl != 0 { + if ts { + cql.WriteString("AND TTL ") + } else { + cql.WriteString("USING TTL ") + } + cql.WriteString(fmt.Sprint(int(u.ttl.Seconds()))) + cql.WriteByte(' ') + } +} + +type where cmps + +func (w where) writeCql(cql *bytes.Buffer) (names []string) { + if len(w) == 0 { + return + } + + cql.WriteString("WHERE ") + return cmps(w).writeCql(cql) +} + +type _if cmps + +func (w _if) writeCql(cql *bytes.Buffer) (names []string) { + if len(w) == 0 { + return + } + + cql.WriteString("IF ") + return cmps(w).writeCql(cql) +} diff --git a/qb/insert.go b/qb/insert.go new file mode 100644 index 0000000..86a98a3 --- /dev/null +++ b/qb/insert.go @@ -0,0 +1,82 @@ +package qb + +// INSERT reference: +// https://cassandra.apache.org/doc/latest/cql/dml.html#insert + +import ( + "bytes" + "time" +) + +// InsertBuilder builds CQL INSERT statements. +type InsertBuilder struct { + table string + columns columns + unique bool + using using +} + +// Insert returns a new InsertBuilder with the given table name. +func Insert(table string) *InsertBuilder { + return &InsertBuilder{ + table: table, + } +} + +// ToCql builds the query into a CQL string and named args. +func (b *InsertBuilder) ToCql() (stmt string, names []string) { + cql := bytes.Buffer{} + + cql.WriteString("INSERT ") + + cql.WriteString("INTO ") + cql.WriteString(b.table) + cql.WriteByte(' ') + + cql.WriteByte('(') + b.columns.writeCql(&cql) + cql.WriteString(") ") + + cql.WriteString("VALUES (") + placeholders(&cql, len(b.columns)) + cql.WriteString(") ") + + b.using.writeCql(&cql) + + if b.unique { + cql.WriteString("IF NOT EXISTS ") + } + + stmt, names = cql.String(), b.columns + return +} + +// Into sets the INTO clause of the query. +func (b *InsertBuilder) Into(table string) *InsertBuilder { + b.table = table + return b +} + +// Columns adds insert columns to the query. +func (b *InsertBuilder) Columns(columns ...string) *InsertBuilder { + b.columns = append(b.columns, columns...) + return b +} + +// Unique sets a IF NOT EXISTS clause on the query. +func (b *InsertBuilder) Unique() *InsertBuilder { + b.unique = true + return b +} + +// Timestamp sets a USING TIMESTAMP clause on the query. +func (b *InsertBuilder) Timestamp(t time.Time) *InsertBuilder { + b.using.timestamp = t + return b +} + +// TTL sets a USING TTL clause on the query. +func (b *InsertBuilder) TTL(d time.Duration) *InsertBuilder { + b.using.ttl = d + return b +} diff --git a/qb/insert_test.go b/qb/insert_test.go new file mode 100644 index 0000000..02299eb --- /dev/null +++ b/qb/insert_test.go @@ -0,0 +1,71 @@ +package qb + +import ( + "testing" + "time" + + "github.com/google/go-cmp/cmp" +) + +func TestInsertBuilder(t *testing.T) { + table := []struct { + B *InsertBuilder + N []string + S string + }{ + + // Basic test for insert + { + B: Insert("cycling.cyclist_name").Columns("id", "user_uuid", "firstname"), + S: "INSERT INTO cycling.cyclist_name (id,user_uuid,firstname) VALUES (?,?,?) ", + N: []string{"id", "user_uuid", "firstname"}, + }, + // Change table name + { + B: Insert("cycling.cyclist_name").Columns("id", "user_uuid", "firstname").Into("Foobar"), + S: "INSERT INTO Foobar (id,user_uuid,firstname) VALUES (?,?,?) ", + N: []string{"id", "user_uuid", "firstname"}, + }, + // Add columns + { + B: Insert("cycling.cyclist_name").Columns("id", "user_uuid", "firstname").Columns("stars"), + S: "INSERT INTO cycling.cyclist_name (id,user_uuid,firstname,stars) VALUES (?,?,?,?) ", + N: []string{"id", "user_uuid", "firstname", "stars"}, + }, + // Add TTL + { + B: Insert("cycling.cyclist_name").Columns("id", "user_uuid", "firstname").TTL(time.Second * 86400), + S: "INSERT INTO cycling.cyclist_name (id,user_uuid,firstname) VALUES (?,?,?) USING TTL 86400 ", + N: []string{"id", "user_uuid", "firstname"}, + }, + // Add TIMESTAMP + { + B: Insert("cycling.cyclist_name").Columns("id", "user_uuid", "firstname").Timestamp(time.Unix(0, 0).Add(time.Microsecond * 123456789)), + S: "INSERT INTO cycling.cyclist_name (id,user_uuid,firstname) VALUES (?,?,?) USING TIMESTAMP 123456789 ", + N: []string{"id", "user_uuid", "firstname"}, + }, + // Add IF NOT EXISTS + { + B: Insert("cycling.cyclist_name").Columns("id", "user_uuid", "firstname").Unique(), + S: "INSERT INTO cycling.cyclist_name (id,user_uuid,firstname) VALUES (?,?,?) IF NOT EXISTS ", + N: []string{"id", "user_uuid", "firstname"}, + }, + } + + for _, test := range table { + stmt, names := test.B.ToCql() + if diff := cmp.Diff(test.S, stmt); diff != "" { + t.Error(diff) + } + if diff := cmp.Diff(test.N, names); diff != "" { + t.Error(diff) + } + } +} + +func BenchmarkInsertBuilder(b *testing.B) { + b.ResetTimer() + for i := 0; i < b.N; i++ { + Insert("cycling.cyclist_name").Columns("id", "user_uuid", "firstname", "stars") + } +} diff --git a/qb/qb.go b/qb/qb.go new file mode 100644 index 0000000..ed05c7c --- /dev/null +++ b/qb/qb.go @@ -0,0 +1,7 @@ +package qb + +// Builder is interface implemented by all the builders. +type Builder interface { + // ToCql builds the query into a CQL string and named args. + ToCql() (stmt string, names []string) +} diff --git a/qb/select.go b/qb/select.go new file mode 100644 index 0000000..7721a72 --- /dev/null +++ b/qb/select.go @@ -0,0 +1,156 @@ +package qb + +// SELECT reference: +// https://cassandra.apache.org/doc/latest/cql/dml.html#select + +import ( + "bytes" + "fmt" +) + +// Order specifies sorting order. +type Order bool + +const ( + // ASC is ascending order + ASC Order = true + // DESC is descending order + DESC = false +) + +// SelectBuilder builds CQL SELECT statements. +type SelectBuilder struct { + table string + columns columns + distinct columns + where where + groupBy columns + orderBy string + order Order + limit uint + limitPerPartition uint + allowFiltering bool +} + +// Select returns a new SelectBuilder with the given table name. +func Select(table string) *SelectBuilder { + return &SelectBuilder{ + table: table, + } +} + +// ToCql builds the query into a CQL string and named args. +func (b *SelectBuilder) ToCql() (stmt string, names []string) { + cql := bytes.Buffer{} + + cql.WriteString("SELECT ") + switch { + case len(b.distinct) > 0: + cql.WriteString("DISTINCT ") + b.distinct.writeCql(&cql) + case len(b.groupBy) > 0: + b.groupBy.writeCql(&cql) + cql.WriteByte(',') + b.columns.writeCql(&cql) + case len(b.columns) == 0: + cql.WriteByte('*') + default: + b.columns.writeCql(&cql) + } + cql.WriteString(" FROM ") + cql.WriteString(b.table) + cql.WriteByte(' ') + + names = b.where.writeCql(&cql) + + if len(b.groupBy) > 0 { + cql.WriteString("GROUP BY ") + b.groupBy.writeCql(&cql) + cql.WriteByte(' ') + } + + if b.orderBy != "" { + cql.WriteString("ORDER BY ") + cql.WriteString(b.orderBy) + if b.order { + cql.WriteString(" ASC ") + } else { + cql.WriteString(" DESC ") + } + } + + if b.limit != 0 { + cql.WriteString("LIMIT ") + cql.WriteString(fmt.Sprint(b.limit)) + cql.WriteByte(' ') + } + + if b.limitPerPartition != 0 { + cql.WriteString("PER PARTITION LIMIT ") + cql.WriteString(fmt.Sprint(b.limitPerPartition)) + cql.WriteByte(' ') + } + + if b.allowFiltering { + cql.WriteString("ALLOW FILTERING ") + } + + stmt = cql.String() + return +} + +// From sets the table to be selected from. +func (b *SelectBuilder) From(table string) *SelectBuilder { + b.table = table + return b +} + +// Columns adds result columns to the query. +func (b *SelectBuilder) Columns(columns ...string) *SelectBuilder { + b.columns = append(b.columns, columns...) + return b +} + +// Distinct sets DISTINCT clause on the query. +func (b *SelectBuilder) Distinct(columns ...string) *SelectBuilder { + b.distinct = append(b.distinct, columns...) + return b +} + +// Where adds an expression to the WHERE clause of the query. Expressions are +// ANDed together in the generated CQL. +func (b *SelectBuilder) Where(w ...Cmp) *SelectBuilder { + b.where = append(b.where, w...) + return b +} + +// GroupBy sets GROUP BY clause on the query. Columns must be a primary key, +// this will automatically add the the columns as first selectors. +func (b *SelectBuilder) GroupBy(columns ...string) *SelectBuilder { + b.groupBy = append(b.groupBy, columns...) + return b +} + +// OrderBy sets ORDER BY clause on the query. +func (b *SelectBuilder) OrderBy(column string, o Order) *SelectBuilder { + b.orderBy, b.order = column, o + return b +} + +// Limit sets a LIMIT clause on the query. +func (b *SelectBuilder) Limit(limit uint) *SelectBuilder { + b.limit = limit + return b +} + +// LimitPerPartition sets a PER PARTITION LIMIT clause on the query. +func (b *SelectBuilder) LimitPerPartition(limit uint) *SelectBuilder { + b.limitPerPartition = limit + return b +} + +// AllowFiltering sets a ALLOW FILTERING clause on the query. +func (b *SelectBuilder) AllowFiltering() *SelectBuilder { + b.allowFiltering = true + return b +} diff --git a/qb/select_test.go b/qb/select_test.go new file mode 100644 index 0000000..ee0371c --- /dev/null +++ b/qb/select_test.go @@ -0,0 +1,90 @@ +package qb + +import ( + "testing" + + "github.com/google/go-cmp/cmp" +) + +func TestSelectBuilder(t *testing.T) { + w := EqNamed("id", "expr") + + table := []struct { + B *SelectBuilder + N []string + S string + }{ + // Basic test for select * + { + B: Select("cycling.cyclist_name"), + S: "SELECT * FROM cycling.cyclist_name ", + }, + // Basic test for select columns + { + B: Select("cycling.cyclist_name").Columns("id", "user_uuid", "firstname"), + S: "SELECT id,user_uuid,firstname FROM cycling.cyclist_name ", + }, + // Basic test for select distinct + { + B: Select("cycling.cyclist_name").Distinct("id"), + S: "SELECT DISTINCT id FROM cycling.cyclist_name ", + }, + // Change table name + { + B: Select("cycling.cyclist_name").From("Foobar"), + S: "SELECT * FROM Foobar ", + }, + // Add WHERE + { + B: Select("cycling.cyclist_name").Where(w, Gt("firstname")), + S: "SELECT * FROM cycling.cyclist_name WHERE id=? AND firstname>? ", + N: []string{"expr", "firstname"}, + }, + // Add GROUP BY + { + B: Select("cycling.cyclist_name").Columns("MAX(stars) as max_stars").GroupBy("id"), + S: "SELECT id,MAX(stars) as max_stars FROM cycling.cyclist_name GROUP BY id ", + }, + // Add ORDER BY + { + B: Select("cycling.cyclist_name").Where(w).OrderBy("firstname", ASC), + S: "SELECT * FROM cycling.cyclist_name WHERE id=? ORDER BY firstname ASC ", + N: []string{"expr"}, + }, + // Add LIMIT + { + B: Select("cycling.cyclist_name").Where(w).Limit(10), + S: "SELECT * FROM cycling.cyclist_name WHERE id=? LIMIT 10 ", + N: []string{"expr"}, + }, + // Add PER PARTITION LIMIT + { + B: Select("cycling.cyclist_name").Where(w).LimitPerPartition(10), + S: "SELECT * FROM cycling.cyclist_name WHERE id=? PER PARTITION LIMIT 10 ", + N: []string{"expr"}, + }, + // Add ALLOW FILTERING + { + B: Select("cycling.cyclist_name").Where(w).AllowFiltering(), + S: "SELECT * FROM cycling.cyclist_name WHERE id=? ALLOW FILTERING ", + N: []string{"expr"}, + }, + } + + for _, test := range table { + stmt, names := test.B.ToCql() + if diff := cmp.Diff(test.S, stmt); diff != "" { + t.Error(diff) + } + if diff := cmp.Diff(test.N, names); diff != "" { + t.Error(diff) + } + } +} + +func BenchmarkSelectBuilder(b *testing.B) { + b.ResetTimer() + for i := 0; i < b.N; i++ { + Select("cycling.cyclist_name").Columns("id", "user_uuid", "firstname", "stars").Where(Eq("id")) + } +} diff --git a/qb/update.go b/qb/update.go new file mode 100644 index 0000000..4fd433c --- /dev/null +++ b/qb/update.go @@ -0,0 +1,102 @@ +package qb + +// UPDATE reference: +// https://cassandra.apache.org/doc/latest/cql/dml.html#update + +import ( + "bytes" + "time" +) + +// UpdateBuilder builds CQL UPDATE statements. +type UpdateBuilder struct { + table string + using using + columns columns + where where + _if _if + exists bool +} + +// Update returns a new UpdateBuilder with the given table name. +func Update(table string) *UpdateBuilder { + return &UpdateBuilder{ + table: table, + } +} + +// ToCql builds the query into a CQL string and named args. +func (b *UpdateBuilder) ToCql() (stmt string, names []string) { + cql := bytes.Buffer{} + + cql.WriteString("UPDATE ") + cql.WriteString(b.table) + cql.WriteByte(' ') + + b.using.writeCql(&cql) + + cql.WriteString("SET ") + for i, c := range b.columns { + cql.WriteString(c) + cql.WriteString("=?") + if i < len(b.columns)-1 { + cql.WriteByte(',') + } + } + names = append(names, b.columns...) + cql.WriteByte(' ') + + names = append(names, b.where.writeCql(&cql)...) + names = append(names, b._if.writeCql(&cql)...) + + if b.exists { + cql.WriteString("IF EXISTS ") + } + + stmt = cql.String() + return +} + +// Table sets the table to be updated. +func (b *UpdateBuilder) Table(table string) *UpdateBuilder { + b.table = table + return b +} + +// Timestamp sets a USING TIMESTAMP clause on the query. +func (b *UpdateBuilder) Timestamp(t time.Time) *UpdateBuilder { + b.using.timestamp = t + return b +} + +// TTL sets a USING TTL clause on the query. +func (b *UpdateBuilder) TTL(d time.Duration) *UpdateBuilder { + b.using.ttl = d + return b +} + +// Set adds SET clauses to the query. +func (b *UpdateBuilder) Set(columns ...string) *UpdateBuilder { + b.columns = append(b.columns, columns...) + return b +} + +// Where adds an expression to the WHERE clause of the query. Expressions are +// ANDed together in the generated CQL. +func (b *UpdateBuilder) Where(w ...Cmp) *UpdateBuilder { + b.where = append(b.where, w...) + return b +} + +// If adds an expression to the IF clause of the query. Expressions are ANDed +// together in the generated CQL. +func (b *UpdateBuilder) If(w ...Cmp) *UpdateBuilder { + b._if = append(b._if, w...) + return b +} + +// Existing sets a IF EXISTS clause on the query. +func (b *UpdateBuilder) Existing() *UpdateBuilder { + b.exists = true + return b +} diff --git a/qb/update_test.go b/qb/update_test.go new file mode 100644 index 0000000..ba77154 --- /dev/null +++ b/qb/update_test.go @@ -0,0 +1,84 @@ +package qb + +import ( + "testing" + "time" + + "github.com/google/go-cmp/cmp" +) + +func TestUpdateBuilder(t *testing.T) { + w := EqNamed("id", "expr") + + table := []struct { + B *UpdateBuilder + N []string + S string + }{ + // Basic test for update + { + B: Update("cycling.cyclist_name").Set("id", "user_uuid", "firstname").Where(w), + S: "UPDATE cycling.cyclist_name SET id=?,user_uuid=?,firstname=? WHERE id=? ", + N: []string{"id", "user_uuid", "firstname", "expr"}, + }, + // Change table name + { + B: Update("cycling.cyclist_name").Set("id", "user_uuid", "firstname").Where(w).Table("Foobar"), + S: "UPDATE Foobar SET id=?,user_uuid=?,firstname=? WHERE id=? ", + N: []string{"id", "user_uuid", "firstname", "expr"}, + }, + // Add SET + { + B: Update("cycling.cyclist_name").Set("id", "user_uuid", "firstname").Where(w).Set("stars"), + S: "UPDATE cycling.cyclist_name SET id=?,user_uuid=?,firstname=?,stars=? WHERE id=? ", + N: []string{"id", "user_uuid", "firstname", "stars", "expr"}, + }, + // Add WHERE + { + B: Update("cycling.cyclist_name").Set("id", "user_uuid", "firstname").Where(w, Gt("firstname")), + S: "UPDATE cycling.cyclist_name SET id=?,user_uuid=?,firstname=? WHERE id=? AND firstname>? ", + N: []string{"id", "user_uuid", "firstname", "expr", "firstname"}, + }, + // Add IF + { + B: Update("cycling.cyclist_name").Set("id", "user_uuid", "firstname").Where(w).If(Gt("firstname")), + S: "UPDATE cycling.cyclist_name SET id=?,user_uuid=?,firstname=? WHERE id=? IF firstname>? ", + N: []string{"id", "user_uuid", "firstname", "expr", "firstname"}, + }, + // Add TTL + { + B: Update("cycling.cyclist_name").Set("id", "user_uuid", "firstname").Where(w).TTL(time.Second * 86400), + S: "UPDATE cycling.cyclist_name USING TTL 86400 SET id=?,user_uuid=?,firstname=? WHERE id=? ", + N: []string{"id", "user_uuid", "firstname", "expr"}, + }, + // Add TIMESTAMP + { + B: Update("cycling.cyclist_name").Set("id", "user_uuid", "firstname").Where(w).Timestamp(time.Unix(0, 0).Add(time.Microsecond * 123456789)), + S: "UPDATE cycling.cyclist_name USING TIMESTAMP 123456789 SET id=?,user_uuid=?,firstname=? WHERE id=? ", + N: []string{"id", "user_uuid", "firstname", "expr"}, + }, + // Add IF EXISTS + { + B: Update("cycling.cyclist_name").Set("id", "user_uuid", "firstname").Where(w).Existing(), + S: "UPDATE cycling.cyclist_name SET id=?,user_uuid=?,firstname=? WHERE id=? IF EXISTS ", + N: []string{"id", "user_uuid", "firstname", "expr"}, + }, + } + + for _, test := range table { + stmt, names := test.B.ToCql() + if diff := cmp.Diff(test.S, stmt); diff != "" { + t.Error(diff) + } + if diff := cmp.Diff(test.N, names); diff != "" { + t.Error(diff) + } + } +} + +func BenchmarkUpdateBuilder(b *testing.B) { + b.ResetTimer() + for i := 0; i < b.N; i++ { + Update("cycling.cyclist_name").Set("id", "user_uuid", "firstname", "stars").Where(Eq("id")) + } +} diff --git a/qb/utils.go b/qb/utils.go new file mode 100644 index 0000000..c89dc5e --- /dev/null +++ b/qb/utils.go @@ -0,0 +1,18 @@ +package qb + +import ( + "bytes" +) + +// placeholders returns a string with count ? placeholders joined with commas. +func placeholders(cql *bytes.Buffer, count int) { + if count < 1 { + return + } + + for i := 0; i < count-1; i++ { + cql.WriteByte('?') + cql.WriteByte(',') + } + cql.WriteByte('?') +} diff --git a/queryx.go b/queryx.go index 557aff0..d7e7da0 100644 --- a/queryx.go +++ b/queryx.go @@ -82,14 +82,18 @@ type Queryx struct { Mapper *reflectx.Mapper } +// Query creates a new Queryx from gocql.Query using a default mapper. +func Query(q *gocql.Query, names []string) Queryx { + return Queryx{ + Query: q, + Names: names, + Mapper: DefaultMapper, + } +} + // BindStruct binds query named parameters using mapper. func (q Queryx) BindStruct(arg interface{}) error { - m := q.Mapper - if m == nil { - m = DefaultMapper - } - - arglist, err := bindStructArgs(q.Names, arg, m) + arglist, err := bindStructArgs(q.Names, arg, q.Mapper) if err != nil { return err } @@ -144,3 +148,13 @@ func bindMapArgs(names []string, arg map[string]interface{}) ([]interface{}, err } return arglist, nil } + +// QueryFunc creates Queryx from qb.Builder.ToCql() output. +type QueryFunc func(stmt string, names []string) Queryx + +// SessionQuery creates QueryFunc that's session aware. +func SessionQuery(session *gocql.Session) QueryFunc { + return func(stmt string, names []string) Queryx { + return Query(session.Query(stmt), names) + } +} diff --git a/queryx_test.go b/queryx_test.go index f0be41c..3130219 100644 --- a/queryx_test.go +++ b/queryx_test.go @@ -12,7 +12,7 @@ func TestCompileQuery(t *testing.T) { Q, R string V []string }{ - // basic test for named parameters, invalid char ',' terminating + // Basic test for named parameters, invalid char ',' terminating { Q: `INSERT INTO foo (a,b,c,d) VALUES (:name, :age, :first, :last)`, R: `INSERT INTO foo (a,b,c,d) VALUES (?, ?, ?, ?)`, @@ -59,10 +59,10 @@ func TestCompileQuery(t *testing.T) { } func BenchmarkCompileNamedQuery(b *testing.B) { - q1 := `INSERT INTO foo (a, b, c, d) VALUES (:name, :age, :first, :last)` + q := []byte("INSERT INTO cycling.cyclist_name (id, user_uuid, firstname, stars) VALUES (:id, :user_uuid, :firstname, :stars)") b.ResetTimer() for i := 0; i < b.N; i++ { - CompileNamedQuery([]byte(q1)) + CompileNamedQuery(q) } } @@ -101,10 +101,7 @@ func TestBindStruct(t *testing.T) { } func BenchmarkBindStruct(b *testing.B) { - q := Queryx{ - Query: &gocql.Query{}, - Names: []string{"name", "age", "first", "last"}, - } + q := Query(&gocql.Query{}, []string{"name", "age", "first", "last"}) type t struct { Name string Age int