new feature: interpolate args in sql

This commit is contained in:
Huan Du
2019-04-24 17:10:23 +08:00
parent a16d3abb01
commit 477f4420d9
6 changed files with 604 additions and 18 deletions
+26
View File
@@ -238,6 +238,32 @@ fmt.Println(args)
If we just want to use `${name}` syntax to refer named arguments, use `BuildNamed` instead. It disables all special syntax but `${name}` and `$$`.
### Interpolate `args` in the `sql` ###
Some SQL drivers doesn't actually implement `StmtExecContext#ExecContext`. They will fail when `len(args) > 0`. The only solution is to interpolate `args` in the `sql`, and execute the interpolated query with the driver.
*Security warning*: I try my best to escape special characters in interpolate methods, but it's still less secure than `Stmt` implemented by SQL drivers. If `Stmt` is well supported, it's highly recommended to use `Stmt` instead of this feature.
This feature is inspired by package `github.com/go-sql-driver/mysql`.
```go
sb := MySQL.NewSelectBuilder()
sb.Select("name").From("user").Where(
sb.NE("id", 1234),
sb.E("name", "Charmy Liu"),
sb.Like("desc", "%mother's day%"),
)
sql, args := sb.Build()
query, err := MySQL.Interpolate(sql, args)
fmt.Println(query)
fmt.Println(err)
// Output:
// SELECT name FROM user WHERE id <> 1234 AND name = 'Charmy Liu' AND desc LIKE '%mother\'s day%'
// <nil>
```
## FAQ ##
### What's the difference between this package and `squirrel` ###
+20 -1
View File
@@ -23,9 +23,28 @@ type Args struct {
onlyNamed bool
}
func init() {
// Predefine some $n args to avoid additional memory allocation.
predefinedArgs = make([]string, 0, maxPredefinedArgs)
for i := 0; i < maxPredefinedArgs; i++ {
predefinedArgs = append(predefinedArgs, fmt.Sprintf("$%v", i))
}
}
const maxPredefinedArgs = 1024
var predefinedArgs []string
// Add adds an arg to Args and returns a placeholder.
func (args *Args) Add(arg interface{}) string {
return fmt.Sprintf("$%v", args.add(arg))
idx := args.add(arg)
if idx < maxPredefinedArgs {
return predefinedArgs[idx]
}
return fmt.Sprintf("$%v", idx)
}
func (args *Args) add(arg interface{}) int {
+16 -16
View File
@@ -15,7 +15,7 @@ type Cond struct {
// Equal represents "field = value".
func (c *Cond) Equal(field string, value interface{}) string {
return fmt.Sprintf("%v = %v", Escape(field), c.Args.Add(value))
return fmt.Sprintf("%s = %s", Escape(field), c.Args.Add(value))
}
// E is an alias of Equal.
@@ -25,7 +25,7 @@ func (c *Cond) E(field string, value interface{}) string {
// NotEqual represents "field != value".
func (c *Cond) NotEqual(field string, value interface{}) string {
return fmt.Sprintf("%v <> %v", Escape(field), c.Args.Add(value))
return fmt.Sprintf("%s <> %s", Escape(field), c.Args.Add(value))
}
// NE is an alias of NotEqual.
@@ -35,7 +35,7 @@ func (c *Cond) NE(field string, value interface{}) string {
// GreaterThan represents "field > value".
func (c *Cond) GreaterThan(field string, value interface{}) string {
return fmt.Sprintf("%v > %v", Escape(field), c.Args.Add(value))
return fmt.Sprintf("%s > %s", Escape(field), c.Args.Add(value))
}
// G is an alias of GreaterThan.
@@ -45,7 +45,7 @@ func (c *Cond) G(field string, value interface{}) string {
// GreaterEqualThan represents "field >= value".
func (c *Cond) GreaterEqualThan(field string, value interface{}) string {
return fmt.Sprintf("%v >= %v", Escape(field), c.Args.Add(value))
return fmt.Sprintf("%s >= %s", Escape(field), c.Args.Add(value))
}
// GE is an alias of GreaterEqualThan.
@@ -55,7 +55,7 @@ func (c *Cond) GE(field string, value interface{}) string {
// LessThan represents "field < value".
func (c *Cond) LessThan(field string, value interface{}) string {
return fmt.Sprintf("%v < %v", Escape(field), c.Args.Add(value))
return fmt.Sprintf("%s < %s", Escape(field), c.Args.Add(value))
}
// L is an alias of LessThan.
@@ -65,7 +65,7 @@ func (c *Cond) L(field string, value interface{}) string {
// LessEqualThan represents "field <= value".
func (c *Cond) LessEqualThan(field string, value interface{}) string {
return fmt.Sprintf("%v <= %v", Escape(field), c.Args.Add(value))
return fmt.Sprintf("%s <= %s", Escape(field), c.Args.Add(value))
}
// LE is an alias of LessEqualThan.
@@ -81,7 +81,7 @@ func (c *Cond) In(field string, value ...interface{}) string {
vs = append(vs, c.Args.Add(v))
}
return fmt.Sprintf("%v IN (%v)", Escape(field), strings.Join(vs, ", "))
return fmt.Sprintf("%s IN (%s)", Escape(field), strings.Join(vs, ", "))
}
// NotIn represents "field NOT IN (value...)".
@@ -92,47 +92,47 @@ func (c *Cond) NotIn(field string, value ...interface{}) string {
vs = append(vs, c.Args.Add(v))
}
return fmt.Sprintf("%v NOT IN (%v)", Escape(field), strings.Join(vs, ", "))
return fmt.Sprintf("%s NOT IN (%s)", Escape(field), strings.Join(vs, ", "))
}
// Like represents "field LIKE value".
func (c *Cond) Like(field string, value interface{}) string {
return fmt.Sprintf("%v LIKE %v", Escape(field), c.Args.Add(value))
return fmt.Sprintf("%s LIKE %s", Escape(field), c.Args.Add(value))
}
// NotLike represents "field NOT LIKE value".
func (c *Cond) NotLike(field string, value interface{}) string {
return fmt.Sprintf("%v NOT LIKE %v", Escape(field), c.Args.Add(value))
return fmt.Sprintf("%s NOT LIKE %s", Escape(field), c.Args.Add(value))
}
// IsNull represents "field IS NULL".
func (c *Cond) IsNull(field string) string {
return fmt.Sprintf("%v IS NULL", Escape(field))
return fmt.Sprintf("%s IS NULL", Escape(field))
}
// IsNotNull represents "field IS NOT NULL".
func (c *Cond) IsNotNull(field string) string {
return fmt.Sprintf("%v IS NOT NULL", Escape(field))
return fmt.Sprintf("%s IS NOT NULL", Escape(field))
}
// Between represents "field BETWEEN lower AND upper".
func (c *Cond) Between(field string, lower, upper interface{}) string {
return fmt.Sprintf("%v BETWEEN %v AND %v", Escape(field), c.Args.Add(lower), c.Args.Add(upper))
return fmt.Sprintf("%s BETWEEN %s AND %s", Escape(field), c.Args.Add(lower), c.Args.Add(upper))
}
// NotBetween represents "field NOT BETWEEN lower AND upper".
func (c *Cond) NotBetween(field string, lower, upper interface{}) string {
return fmt.Sprintf("%v NOT BETWEEN %v AND %v", Escape(field), c.Args.Add(lower), c.Args.Add(upper))
return fmt.Sprintf("%s NOT BETWEEN %s AND %s", Escape(field), c.Args.Add(lower), c.Args.Add(upper))
}
// Or represents OR logic like "expr1 OR expr2 OR expr3".
func (c *Cond) Or(orExpr ...string) string {
return fmt.Sprintf("(%v)", strings.Join(orExpr, " OR "))
return fmt.Sprintf("(%s)", strings.Join(orExpr, " OR "))
}
// And represents AND logic like "expr1 AND expr2 AND expr3".
func (c *Cond) And(andExpr ...string) string {
return fmt.Sprintf("(%v)", strings.Join(andExpr, " AND "))
return fmt.Sprintf("(%s)", strings.Join(andExpr, " AND "))
}
// Var returns a placeholder for value.
+32 -1
View File
@@ -3,7 +3,10 @@
package sqlbuilder
import "fmt"
import (
"errors"
"fmt"
)
// Supported flavors.
const (
@@ -18,6 +21,18 @@ var (
DefaultFlavor = MySQL
)
var (
// ErrInterpolateNotImplemented means the method or feature is not implemented right now.
ErrInterpolateNotImplemented = errors.New("go-sqlbuilder: interpolation for this flavor is not implemented")
// ErrInterpolateMissingArgs means there are some args missing in query, so it's not possible to
// prepare a query with such args.
ErrInterpolateMissingArgs = errors.New("go-sqlbuilder: not enough args when interpolating")
// ErrInterpolateUnsupportedArgs means that some types of the args are not supported.
ErrInterpolateUnsupportedArgs = errors.New("go-sqlbuilder: unsupported args when interpolating")
)
// Flavor is the flag to control the format of compiled sql.
type Flavor int
@@ -33,6 +48,22 @@ func (f Flavor) String() string {
return "<invalid>"
}
// Interpolate parses sql returned by `Args#Compile` or `Builder`,
// and interpolate args to replace placeholders in the sql.
//
// If there are some args missing in sql, e.g. the number of placeholders are larger than len(args),
// returns ErrMissingArgs error.
func (f Flavor) Interpolate(sql string, args []interface{}) (string, error) {
switch f {
case MySQL:
return mysqlInterpolate(sql, args...)
case PostgreSQL:
return postgresqlInterpolate(sql, args...)
}
return "", ErrInterpolateNotImplemented
}
// NewCreateTableBuilder creates a new CREATE TABLE builder with flavor.
func (f Flavor) NewCreateTableBuilder() *CreateTableBuilder {
b := newCreateTableBuilder()
+108
View File
@@ -5,7 +5,9 @@ package sqlbuilder
import (
"fmt"
"strconv"
"testing"
"time"
)
func TestFlavor(t *testing.T) {
@@ -22,6 +24,94 @@ func TestFlavor(t *testing.T) {
}
}
func TestFlavorInterpolate(t *testing.T) {
dt := time.Date(2019, 4, 24, 12, 23, 34, 123456789, time.FixedZone("CST", 8*60*60)) // 2019-04-24 12:23:34.987654321 CST
_, errOutOfRange := strconv.ParseInt("12345678901234567890", 10, 32)
cases := []struct {
flavor Flavor
sql string
args []interface{}
query string
err error
}{
{
MySQL,
"SELECT * FROM a WHERE name = ? AND state IN (?, ?, ?, ?, ?)", []interface{}{"I'm fine", 42, int8(8), int16(-16), int32(32), int64(64)},
"SELECT * FROM a WHERE name = 'I\\'m fine' AND state IN (42, 8, -16, 32, 64)", nil,
},
{
MySQL,
"SELECT * FROM `a?` WHERE name = \"?\" AND state IN (?, '?', ?, ?, ?, ?, ?)", []interface{}{"\r\n\b\t\x1a\x00\\\"'", uint(42), uint8(8), uint16(16), uint32(32), uint64(64), "useless"},
"SELECT * FROM `a?` WHERE name = \"?\" AND state IN ('\\r\\n\\b\\t\\Z\\0\\\\\\\"\\'', '?', 42, 8, 16, 32, 64)", nil,
},
{
MySQL,
"SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?", []interface{}{true, false, float32(1.234567), float64(9.87654321), []byte(nil), []byte("I'm bytes"), dt, time.Time{}, nil},
"SELECT TRUE, FALSE, 1.234567, 9.87654321, NULL, _binary'I\\'m bytes', '2019-04-24 12:23:34.123457', '0000-00-00', NULL", nil,
},
{
MySQL,
"SELECT '\\'?', \"\\\"?\", `\\`?`, \\?", []interface{}{MySQL},
"SELECT '\\'?', \"\\\"?\", `\\`?`, \\'MySQL'", nil,
},
{
MySQL,
"SELECT ?", nil,
"", ErrInterpolateMissingArgs,
},
{
MySQL,
"SELECT ?", []interface{}{complex(1, 2)},
"", ErrInterpolateUnsupportedArgs,
},
{
PostgreSQL,
"SELECT * FROM a WHERE name = $3 AND state IN ($2, $4, $1, $6, $5)", []interface{}{"I'm fine", 42, int8(8), int16(-16), int32(32), int64(64)},
"SELECT * FROM a WHERE name = 8 AND state IN (42, -16, 'I\\'m fine', 64, 32)", nil,
},
{
PostgreSQL,
"SELECT * FROM $abc$$1$abc$1$1 WHERE name = \"$1\" AND state IN ($2, '$1', $3, $6, $5, $4, $2) $3", []interface{}{"\r\n\b\t\x1a\x00\\\"'", uint(42), uint8(8), uint16(16), uint32(32), uint64(64), "useless"},
"SELECT * FROM $abc$$1$abc$1'\\r\\n\\b\\t\\Z\\0\\\\\\\"\\'' WHERE name = \"$1\" AND state IN (42, '$1', 8, 64, 32, 16, 42) 8", nil,
},
{
PostgreSQL,
"SELECT $1, $2, $3, $4, $5, $6, $7, $8, $9, $11, $a", []interface{}{true, false, float32(1.234567), float64(9.87654321), []byte(nil), []byte("I'm bytes"), dt, time.Time{}, nil, 10, 11, 12},
"SELECT TRUE, FALSE, 1.234567, 9.87654321, NULL, E'\\\\x49276D206279746573'::bytea, '2019-04-24 12:23:34.123457 CST', '0000-00-00', NULL, 11, $a", nil,
},
{
PostgreSQL,
"SELECT '\\'$1', \"\\\"$1\", `$1`, \\$1a, $$1$$, $a $b$ $a $ $1$b$1$1 $a$ $", []interface{}{MySQL},
"SELECT '\\'$1', \"\\\"$1\", `'MySQL'`, \\'MySQL'a, $$1$$, $a $b$ $a $ $1$b$1'MySQL' $a$ $", nil,
},
{
PostgreSQL,
"SELECT $1", nil,
"", ErrInterpolateMissingArgs,
},
{
PostgreSQL,
"SELECT $1", []interface{}{complex(1, 2)},
"", ErrInterpolateUnsupportedArgs,
},
{
PostgreSQL,
"SELECT $12345678901234567890", nil,
"", errOutOfRange,
},
}
for idx, c := range cases {
query, err := c.flavor.Interpolate(c.sql, c.args)
if query != c.query || (err != c.err && err.Error() != c.err.Error()) {
t.Fatalf("unexpected interpolate result. [idx:%v] [err:%v] [case:%#v]\n expected: %v\n actual: %v",
idx, err, c, c.query, query)
}
}
}
func ExampleFlavor() {
// Create a flavored builder.
sb := PostgreSQL.NewSelectBuilder()
@@ -38,3 +128,21 @@ func ExampleFlavor() {
// SELECT name FROM user WHERE id = $1 AND rank > $2
// [1234 3]
}
func ExampleFlavor_Interpolate() {
sb := MySQL.NewSelectBuilder()
sb.Select("name").From("user").Where(
sb.NE("id", 1234),
sb.E("name", "Charmy Liu"),
sb.Like("desc", "%mother's day%"),
)
sql, args := sb.Build()
query, err := MySQL.Interpolate(sql, args)
fmt.Println(query)
fmt.Println(err)
// Output:
// SELECT name FROM user WHERE id <> 1234 AND name = 'Charmy Liu' AND desc LIKE '%mother\'s day%'
// <nil>
}
+402
View File
@@ -0,0 +1,402 @@
// Copyright 2018 Huan Du. All rights reserved.
// Licensed under the MIT license that can be found in the LICENSE file.
package sqlbuilder
import (
"fmt"
"strconv"
"time"
"unicode"
"unicode/utf8"
"unsafe"
)
// mysqlInterpolate parses query and replace all "?" with encoded args.
// If there are more "?" than len(args), returns ErrMissingArgs.
// Otherwise, if there are less "?" than len(args), the redundant args are omitted.
func mysqlInterpolate(query string, args ...interface{}) (string, error) {
// Roughly estimate the size to avoid useless memory allocation and copy.
buf := make([]byte, 0, len(query)+len(args)*20)
var quote rune
var err error
cnt := 0
max := len(args)
escaping := false
offset := 0
target := query
r, sz := utf8.DecodeRuneInString(target)
for ; sz != 0; r, sz = utf8.DecodeRuneInString(target) {
offset += sz
target = query[offset:]
if escaping {
escaping = false
continue
}
switch r {
case '?':
if quote != 0 {
continue
}
if cnt >= max {
return "", ErrInterpolateMissingArgs
}
buf = append(buf, query[:offset-sz]...)
buf, err = encodeValue(buf, args[cnt], MySQL)
if err != nil {
return "", err
}
query = target
offset = 0
cnt++
case '\'':
if quote == '\'' {
quote = 0
continue
}
if quote == 0 {
quote = '\''
}
case '"':
if quote == '"' {
quote = 0
continue
}
if quote == 0 {
quote = '"'
}
case '`':
if quote == '`' {
quote = 0
continue
}
if quote == 0 {
quote = '`'
}
case '\\':
if quote != 0 {
escaping = true
}
}
}
buf = append(buf, query...)
return *(*string)(unsafe.Pointer(&buf)), nil
}
// postgresqlInterpolate parses query and replace all "$*" with encoded args.
// If there are more "$*" than len(args), returns ErrMissingArgs.
// Otherwise, if there are less "$*" than len(args), the redundant args are omitted.
func postgresqlInterpolate(query string, args ...interface{}) (string, error) {
// Roughly estimate the size to avoid useless memory allocation and copy.
buf := make([]byte, 0, len(query)+len(args)*20)
var quote rune
var dollarQuote string
var err error
var idx int64
max := len(args)
escaping := false
offset := 0
target := query
r, sz := utf8.DecodeRuneInString(target)
for ; sz != 0; r, sz = utf8.DecodeRuneInString(target) {
offset += sz
target = query[offset:]
if escaping {
escaping = false
continue
}
switch r {
case '$':
if quote != 0 {
if quote != '$' {
continue
}
// Try to find the end of dollar quote.
pos := offset
for r, sz = utf8.DecodeRuneInString(target); sz != 0 && r != '$'; r, sz = utf8.DecodeRuneInString(target) {
pos += sz
target = query[pos:]
}
if sz == 0 {
break
}
if r == '$' {
dq := query[offset : pos+sz]
offset = pos
target = query[offset:]
if dq == dollarQuote {
quote = 0
dollarQuote = ""
offset += sz
target = query[offset:]
}
continue
}
continue
}
oldSz := sz
pos := offset
r, sz = utf8.DecodeRuneInString(target)
if '1' <= r && r <= '9' {
// A placeholder is found.
pos += sz
target = query[pos:]
for r, sz = utf8.DecodeRuneInString(target); sz != 0 && '0' <= r && r <= '9'; r, sz = utf8.DecodeRuneInString(target) {
pos += sz
target = query[pos:]
}
idx, err = strconv.ParseInt(query[offset:pos], 10, strconv.IntSize)
if err != nil {
return "", err
}
if int(idx) >= max+1 {
return "", ErrInterpolateMissingArgs
}
buf = append(buf, query[:offset-oldSz]...)
buf, err = encodeValue(buf, args[idx-1], PostgreSQL)
if err != nil {
return "", err
}
query = target
offset = 0
if sz == 0 {
break
}
continue
}
// Try to find the beginning of dollar quote.
for ; sz != 0 && r != '$' && unicode.IsLetter(r); r, sz = utf8.DecodeRuneInString(target) {
pos += sz
target = query[pos:]
}
if sz == 0 {
break
}
if !unicode.IsLetter(r) && r != '$' {
continue
}
pos += sz
quote = '$'
dollarQuote = query[offset:pos]
offset = pos
target = query[offset:]
case '\'':
if quote == '\'' {
quote = 0
continue
}
if quote == 0 {
quote = '\''
}
case '"':
if quote == '"' {
quote = 0
continue
}
if quote == 0 {
quote = '"'
}
case '\\':
if quote == '\'' || quote == '"' {
escaping = true
}
}
}
buf = append(buf, query...)
return *(*string)(unsafe.Pointer(&buf)), nil
}
func encodeValue(buf []byte, arg interface{}, flavor Flavor) ([]byte, error) {
switch v := arg.(type) {
case nil:
buf = append(buf, "NULL"...)
case bool:
if v {
buf = append(buf, "TRUE"...)
} else {
buf = append(buf, "FALSE"...)
}
case int:
buf = strconv.AppendInt(buf, int64(v), 10)
case int8:
buf = strconv.AppendInt(buf, int64(v), 10)
case int16:
buf = strconv.AppendInt(buf, int64(v), 10)
case int32:
buf = strconv.AppendInt(buf, int64(v), 10)
case int64:
buf = strconv.AppendInt(buf, v, 10)
case uint:
buf = strconv.AppendUint(buf, uint64(v), 10)
case uint8:
buf = strconv.AppendUint(buf, uint64(v), 10)
case uint16:
buf = strconv.AppendUint(buf, uint64(v), 10)
case uint32:
buf = strconv.AppendUint(buf, uint64(v), 10)
case uint64:
buf = strconv.AppendUint(buf, v, 10)
case float32:
buf = strconv.AppendFloat(buf, float64(v), 'g', -1, 32)
case float64:
buf = strconv.AppendFloat(buf, v, 'g', -1, 64)
case []byte:
if v == nil {
buf = append(buf, "NULL"...)
break
}
switch flavor {
case MySQL:
buf = append(buf, "_binary"...)
buf = quoteStringValue(buf, *(*string)(unsafe.Pointer(&v)), flavor)
case PostgreSQL:
hex := make([]byte, 0, 2)
buf = append(buf, "E'\\\\x"...)
for _, b := range v {
runes := strconv.AppendInt(hex, int64(b), 16)
buf = append(buf, byte(unicode.ToUpper(rune(runes[0]))))
buf = append(buf, byte(unicode.ToUpper(rune(runes[1]))))
}
buf = append(buf, "'::bytea"...)
}
case string:
buf = quoteStringValue(buf, v, flavor)
case time.Time:
if v.IsZero() {
buf = append(buf, "'0000-00-00'"...)
break
}
// In SQL standard, the precision of fractional seconds in time literal is up to 6 digits.
// Round up v.
v = v.Add(500 * time.Nanosecond)
switch flavor {
case MySQL:
buf = quoteStringValue(buf, v.Format("2006-01-02 15:04:05.999999"), flavor)
case PostgreSQL:
buf = quoteStringValue(buf, v.Format("2006-01-02 15:04:05.999999 MST"), flavor)
}
case fmt.Stringer:
buf = quoteStringValue(buf, v.String(), flavor)
default:
return nil, ErrInterpolateUnsupportedArgs
}
return buf, nil
}
func quoteStringValue(buf []byte, s string, flavor Flavor) []byte {
buf = append(buf, '\'')
r, sz := utf8.DecodeRuneInString(s)
for ; sz != 0; r, sz = utf8.DecodeRuneInString(s) {
switch r {
case '\x00':
buf = append(buf, "\\0"...)
case '\b':
buf = append(buf, "\\b"...)
case '\n':
buf = append(buf, "\\n"...)
case '\r':
buf = append(buf, "\\r"...)
case '\t':
buf = append(buf, "\\t"...)
case '\x1a':
buf = append(buf, "\\Z"...)
case '\'':
buf = append(buf, "\\'"...)
case '"':
buf = append(buf, "\\\""...)
case '\\':
buf = append(buf, "\\\\"...)
default:
buf = append(buf, s[:sz]...)
}
s = s[sz:]
}
buf = append(buf, '\'')
return buf
}