From e046251061a868a31b535e9794f4308f229560c5 Mon Sep 17 00:00:00 2001 From: August Vi Date: Sat, 10 Oct 2026 21:28:23 -0700 Subject: [PATCH 1/2] Scan SQL for placeholders and named parameters while skipping literals, identifiers, comments and casts. --- bind.go | 102 +++++++++++------- named.go | 137 ++++++++---------------- named_context.go | 2 +- named_test.go | 49 +++++---- scan.go | 273 +++++++++++++++++++++++++++++++++++++++++++++++ scan_test.go | 233 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 640 insertions(+), 156 deletions(-) create mode 100644 scan.go create mode 100644 scan_test.go diff --git a/bind.go b/bind.go index 1818037..309438c 100644 --- a/bind.go +++ b/bind.go @@ -53,40 +53,62 @@ func BindDriver(driverName string, bindType int) { binds.Store(driverName, bindType) } -// FIXME: this should be able to be tolerant of escaped ?'s in queries without -// losing much speed, and should be to avoid confusion. - // Rebind a query from the default bindtype (QUESTION) to the target bindtype. +// A '?' inside a string literal, a quoted identifier or a comment is left +// alone, and '??' is the escape for a literal '?' with every bind type. func Rebind(bindType int, query string) string { switch bindType { case QUESTION, UNKNOWN: - return query + if !strings.Contains(query, "??") { + return query + } } // Add space enough for 10 params before we have to allocate rqb := make([]byte, 0, len(query)+10) - var i, j int - - for i = strings.Index(query, "?"); i != -1; i = strings.Index(query, "?") { - rqb = append(rqb, query[:i]...) - - switch bindType { - case DOLLAR: - rqb = append(rqb, '$') - case NAMED: - rqb = append(rqb, ':', 'a', 'r', 'g') - case AT: - rqb = append(rqb, '@', 'p') + sc := sqlScanner{q: query, opt: scanOptsFor(bindType)} + last, j := 0, 0 + for { + kind, start, end := sc.next() + if kind == tokEOF { + break + } + switch kind { + case tokQuestion: + rqb = append(rqb, query[last:start]...) + switch bindType { + case DOLLAR: + rqb = append(rqb, '$') + case NAMED: + rqb = append(rqb, ':', 'a', 'r', 'g') + case AT: + rqb = append(rqb, '@', 'p') + default: + rqb = append(rqb, '?') + last = end + continue + } + j++ + rqb = strconv.AppendInt(rqb, int64(j), 10) + last = end + case tokQuestionPair: + rqb = append(rqb, query[last:start]...) + rqb = append(rqb, '?') + last = end } + } - j++ - rqb = strconv.AppendInt(rqb, int64(j), 10) + return string(append(rqb, query[last:]...)) +} - query = query[i+1:] +// scanOptsFor returns the scanner options for a bind type: SQL Server's +// [bracketed] identifiers are only recognised for AT. +func scanOptsFor(bindType int) scanOpts { + if bindType == AT { + return optBrackets } - - return string(append(rqb, query...)) + return 0 } // Experimental implementation of Rebind which uses a bytes.Buffer. The code is @@ -143,7 +165,8 @@ func asSliceForIn(i interface{}) (v reflect.Value, ok bool) { // targeting PostgreSQL or other drivers that normally use a different // placeholder style. In always returns a query that still uses `?`; call // db.Rebind on the result before Query/Exec so the placeholders match the -// driver. +// driver. A `?` inside a string literal, a quoted identifier or a comment is +// not a bindVar, and `??` is a literal `?`. // // An empty or nil slice expands to the SQL literal NULL and binds no // argument, so `x IN (?)` becomes `x IN (NULL)`, which matches no row on any @@ -205,9 +228,18 @@ func In(query string, args ...interface{}) (string, []interface{}, error) { var buf strings.Builder buf.Grow(len(query) + len(", ?")*flatArgsCount) - var arg, offset int - - for i := strings.IndexByte(query[offset:], '?'); i != -1; i = strings.IndexByte(query[offset:], '?') { + // walk the query with the scanner so that a '?' inside a string literal + // or a comment is not taken for a bindvar; '??' and ':name' pass through + sc := sqlScanner{q: query} + arg, last := 0, 0 + for { + kind, start, end := sc.next() + if kind == tokEOF { + break + } + if kind != tokQuestion { + continue + } if arg >= len(meta) { // if an argument wasn't passed, lets return an error; this is // not actually how database/sql Exec/Query works, but since we are @@ -221,38 +253,30 @@ func In(query string, args ...interface{}) (string, []interface{}, error) { // an empty slice becomes the literal NULL, which binds nothing if argMeta.empty { - buf.WriteString(query[:offset+i]) + buf.WriteString(query[last:start]) buf.WriteString("NULL") - query = query[offset+i+1:] - offset = 0 + last = end continue } - // not a slice, continue. - // our questionmark will either be written before the next expansion - // of a slice or after the loop when writing the rest of the query + // not a slice: its '?' is copied along with the surrounding text if argMeta.length == 0 { - offset = offset + i + 1 newArgs = append(newArgs, argMeta.i) continue } // write everything up to and including our ? character - buf.WriteString(query[:offset+i+1]) + buf.WriteString(query[last:end]) for si := 1; si < argMeta.length; si++ { buf.WriteString(", ?") } newArgs = appendReflectSlice(newArgs, argMeta.v, argMeta.length) - - // slice the query and reset the offset. this avoids some bookkeeping for - // the write after the loop - query = query[offset+i+1:] - offset = 0 + last = end } - buf.WriteString(query) + buf.WriteString(query[last:]) if arg < len(meta) { return "", nil, errors.New("number of bindVars less than number arguments") diff --git a/named.go b/named.go index 380855f..26d6670 100644 --- a/named.go +++ b/named.go @@ -20,7 +20,6 @@ import ( "regexp" "strconv" "strings" - "unicode" "github.com/secondwindoss/sqlx/reflectx" ) @@ -132,7 +131,7 @@ type namedPreparer interface { func prepareNamed(p namedPreparer, query string) (*NamedStmt, error) { bindType := BindType(p.DriverName()) - q, args, err := compileNamedQuery([]byte(query), bindType) + q, args, err := compileNamedQuery(query, bindType) if err != nil { return nil, err } @@ -223,7 +222,7 @@ func bindMapArgs(names []string, arg map[string]interface{}) ([]interface{}, err // The rules for binding field names to parameter names follow the same // conventions as for StructScan, including obeying the `db` struct tags. func bindStruct(bindType int, query string, arg interface{}, m *reflectx.Mapper) (string, []interface{}, error) { - bound, names, err := compileNamedQuery([]byte(query), bindType) + bound, names, err := compileNamedQuery(query, bindType) if err != nil { return "", []interface{}{}, err } @@ -303,7 +302,7 @@ func fixBound(bound string, loop int) string { func bindArray(bindType int, query string, arg interface{}, m *reflectx.Mapper) (string, []interface{}, error) { // do the initial binding with QUESTION; if bindType is not question, // we can rebind it at the end. - bound, names, err := compileNamedQuery([]byte(query), QUESTION) + bound, names, err := compileNamedQuery(query, QUESTION) if err != nil { return "", []interface{}{}, err } @@ -332,7 +331,7 @@ func bindArray(bindType int, query string, arg interface{}, m *reflectx.Mapper) // bindMap binds a named parameter query with a map of arguments. func bindMap(bindType int, query string, args map[string]interface{}) (string, []interface{}, error) { - bound, names, err := compileNamedQuery([]byte(query), bindType) + bound, names, err := compileNamedQuery(query, bindType) if err != nil { return "", []interface{}{}, err } @@ -343,96 +342,52 @@ func bindMap(bindType int, query string, args map[string]interface{}) (string, [ // -- Compilation of Named Queries -// Allow digits and letters in bind params; additionally runes are -// checked against underscores, meaning that bind params can have be -// alphanumeric with underscores. Mind the difference between unicode -// digits and numbers, where '5' is a digit but '五' is not. -var allowedBindRunes = []*unicode.RangeTable{unicode.Letter, unicode.Digit} - -// FIXME: this function isn't safe for unicode named params, as a failing test -// can testify. This is not a regression but a failure of the original code -// as well. It should be modified to range over runes in a string rather than -// bytes, even though this is less convenient and slower. Hopefully the -// addition of the prepared NamedStmt (which will only do this once) will make -// up for the slightly slower ad-hoc NamedExec/NamedQuery. - -// compile a NamedQuery into an unbound query (using the '?' bindvar) and -// a list of names. -func compileNamedQuery(qs []byte, bindType int) (query string, names []string, err error) { - names = make([]string, 0, 10) - rebound := make([]byte, 0, len(qs)) - - inName := false - last := len(qs) - 1 - currentVar := 1 - name := make([]byte, 0, 10) - - for i, b := range qs { - // a ':' while we're in a name is an error - if b == ':' { - // if this is the second ':' in a '::' escape sequence, append a ':' - if inName && i > 0 && qs[i-1] == ':' { - rebound = append(rebound, ':') - inName = false - continue - } else if inName { - err = errors.New("unexpected `:` while reading named param at " + strconv.Itoa(i)) - return query, names, err - } - inName = true - name = []byte{} - } else if inName && i > 0 && b == '=' && len(name) == 0 { - rebound = append(rebound, ':', '=') - inName = false +// compileNamedQuery turns a query with :name parameters into one with the +// bindvars of bindType and returns the parameter names in order. A name +// starts with a letter, an underscore or a non-ASCII character and continues +// with letters, digits, underscores, non-ASCII characters and dots. A '::' +// cast, a ':' inside a string literal, a quoted identifier or a comment, and +// any other ':' not followed by a name pass through verbatim. Two names with +// nothing between them (":first:name") are an error. +func compileNamedQuery(query string, bindType int) (string, []string, error) { + names := make([]string, 0, 10) + rebound := make([]byte, 0, len(query)+10) + + sc := sqlScanner{q: query, opt: scanOptsFor(bindType)} + last, prevEnd, currentVar := 0, -1, 1 + for { + kind, start, end := sc.next() + if kind == tokEOF { + break + } + if kind != tokName { continue - // if we're in a name, and this is an allowed character, continue - } else if inName && (unicode.IsOneOf(allowedBindRunes, rune(b)) || b == '_' || b == '.') && i != last { - // append the byte to the name if we are in a name and not on the last byte - name = append(name, b) - // if we're in a name and it's not an allowed character, the name is done - } else if inName { - inName = false - // if this is the final byte of the string and it is part of the name, then - // make sure to add it to the name - if i == last && unicode.IsOneOf(allowedBindRunes, rune(b)) { - name = append(name, b) - } - // add the string representation to the names list - names = append(names, string(name)) - // add a proper bindvar for the bindType - switch bindType { - // oracle only supports named type bind vars even for positional - case NAMED: - rebound = append(rebound, ':') - rebound = append(rebound, name...) - case QUESTION, UNKNOWN: - rebound = append(rebound, '?') - case DOLLAR: - rebound = append(rebound, '$') - for _, b := range strconv.Itoa(currentVar) { - rebound = append(rebound, byte(b)) - } - currentVar++ - case AT: - rebound = append(rebound, '@', 'p') - for _, b := range strconv.Itoa(currentVar) { - rebound = append(rebound, byte(b)) - } - currentVar++ - } - // add this byte to string unless it was not part of the name - if i != last { - rebound = append(rebound, b) - } else if !unicode.IsOneOf(allowedBindRunes, rune(b)) { - rebound = append(rebound, b) - } - } else { - // this is a normal byte and should just go onto the rebound query - rebound = append(rebound, b) } + if start == prevEnd { + return "", nil, errors.New("unexpected `:` while reading named param at " + strconv.Itoa(start)) + } + names = append(names, query[start+1:end]) + rebound = append(rebound, query[last:start]...) + switch bindType { + // oracle only supports named type bind vars even for positional + case NAMED: + rebound = append(rebound, query[start:end]...) + case DOLLAR: + rebound = append(rebound, '$') + rebound = strconv.AppendInt(rebound, int64(currentVar), 10) + currentVar++ + case AT: + rebound = append(rebound, '@', 'p') + rebound = strconv.AppendInt(rebound, int64(currentVar), 10) + currentVar++ + default: // QUESTION, UNKNOWN + rebound = append(rebound, '?') + } + last, prevEnd = end, end } + rebound = append(rebound, query[last:]...) - return string(rebound), names, err + return string(rebound), names, nil } // BindNamed binds a struct or a map to a query with named parameters. diff --git a/named_context.go b/named_context.go index 9ad23f4..7dc94d4 100644 --- a/named_context.go +++ b/named_context.go @@ -17,7 +17,7 @@ type namedPreparerContext interface { func prepareNamedContext(ctx context.Context, p namedPreparerContext, query string) (*NamedStmt, error) { bindType := BindType(p.DriverName()) - q, args, err := compileNamedQuery([]byte(query), bindType) + q, args, err := compileNamedQuery(query, bindType) if err != nil { return nil, err } diff --git a/named_test.go b/named_test.go index 15346e5..2504e99 100644 --- a/named_test.go +++ b/named_test.go @@ -32,18 +32,18 @@ func TestCompileQuery(t *testing.T) { }, { Q: `SELECT "::foo" FROM a WHERE first_name=:name1 AND last_name=:name2`, - R: `SELECT ":foo" FROM a WHERE first_name=? AND last_name=?`, - D: `SELECT ":foo" FROM a WHERE first_name=$1 AND last_name=$2`, - T: `SELECT ":foo" FROM a WHERE first_name=@p1 AND last_name=@p2`, - N: `SELECT ":foo" FROM a WHERE first_name=:name1 AND last_name=:name2`, + R: `SELECT "::foo" FROM a WHERE first_name=? AND last_name=?`, + D: `SELECT "::foo" FROM a WHERE first_name=$1 AND last_name=$2`, + T: `SELECT "::foo" FROM a WHERE first_name=@p1 AND last_name=@p2`, + N: `SELECT "::foo" FROM a WHERE first_name=:name1 AND last_name=:name2`, V: []string{"name1", "name2"}, }, { Q: `SELECT 'a::b::c' || first_name, '::::ABC::_::' FROM person WHERE first_name=:first_name AND last_name=:last_name`, - R: `SELECT 'a:b:c' || first_name, '::ABC:_:' FROM person WHERE first_name=? AND last_name=?`, - D: `SELECT 'a:b:c' || first_name, '::ABC:_:' FROM person WHERE first_name=$1 AND last_name=$2`, - T: `SELECT 'a:b:c' || first_name, '::ABC:_:' FROM person WHERE first_name=@p1 AND last_name=@p2`, - N: `SELECT 'a:b:c' || first_name, '::ABC:_:' FROM person WHERE first_name=:first_name AND last_name=:last_name`, + R: `SELECT 'a::b::c' || first_name, '::::ABC::_::' FROM person WHERE first_name=? AND last_name=?`, + D: `SELECT 'a::b::c' || first_name, '::::ABC::_::' FROM person WHERE first_name=$1 AND last_name=$2`, + T: `SELECT 'a::b::c' || first_name, '::::ABC::_::' FROM person WHERE first_name=@p1 AND last_name=@p2`, + N: `SELECT 'a::b::c' || first_name, '::::ABC::_::' FROM person WHERE first_name=:first_name AND last_name=:last_name`, V: []string{"first_name", "last_name"}, }, { @@ -54,20 +54,19 @@ func TestCompileQuery(t *testing.T) { T: `SELECT @name := "name", @p1, @p2, @p3`, V: []string{"age", "first", "last"}, }, - /* This unicode awareness test sadly fails, because of our byte-wise worldview. - * We could certainly iterate by Rune instead, though it's a great deal slower, - * it's probably the RightWay(tm) + // names may contain non-ASCII characters { Q: `INSERT INTO foo (a,b,c,d) VALUES (:あ, :b, :キコ, :名前)`, R: `INSERT INTO foo (a,b,c,d) VALUES (?, ?, ?, ?)`, D: `INSERT INTO foo (a,b,c,d) VALUES ($1, $2, $3, $4)`, - N: []string{"name", "age", "first", "last"}, + T: `INSERT INTO foo (a,b,c,d) VALUES (@p1, @p2, @p3, @p4)`, + N: `INSERT INTO foo (a,b,c,d) VALUES (:あ, :b, :キコ, :名前)`, + V: []string{"あ", "b", "キコ", "名前"}, }, - */ } for _, test := range table { - qr, names, err := compileNamedQuery([]byte(test.Q), QUESTION) + qr, names, err := compileNamedQuery(test.Q, QUESTION) if err != nil { t.Error(err) } @@ -83,17 +82,17 @@ func TestCompileQuery(t *testing.T) { } } } - qd, _, _ := compileNamedQuery([]byte(test.Q), DOLLAR) + qd, _, _ := compileNamedQuery(test.Q, DOLLAR) if qd != test.D { t.Errorf("\nexpected: `%s`\ngot: `%s`", test.D, qd) } - qt, _, _ := compileNamedQuery([]byte(test.Q), AT) + qt, _, _ := compileNamedQuery(test.Q, AT) if qt != test.T { t.Errorf("\nexpected: `%s`\ngot: `%s`", test.T, qt) } - qq, _, _ := compileNamedQuery([]byte(test.Q), NAMED) + qq, _, _ := compileNamedQuery(test.Q, NAMED) if qq != test.N { t.Errorf("\nexpected: `%s`\ngot: `%s`\n(len: %d vs %d)", test.N, qq, len(test.N), len(qq)) } @@ -123,10 +122,9 @@ func (t Test) Errorf(err error, format string, args ...interface{}) { } func TestEscapedColons(t *testing.T) { - t.Skip("not sure it is possible to support this in general case without an SQL parser") var qs = `SELECT * FROM testtable WHERE timeposted BETWEEN (now() AT TIME ZONE 'utc') AND (now() AT TIME ZONE 'utc') - interval '01:30:00') AND name = '\'this is a test\'' and id = :id` - _, _, err := compileNamedQuery([]byte(qs), DOLLAR) + _, _, err := compileNamedQuery(qs, DOLLAR) if err != nil { t.Error("Didn't handle colons correctly when inside a string") } @@ -304,16 +302,17 @@ func TestNamedQueries(t *testing.T) { t.Errorf("expected %s, got %s", sl.Email, p2.Email) } - _, _, err = db.BindNamed("SELECT * FROM person WHERE id=:id /*TODO: handle accidental colon in named query*/", struct { + // a colon inside a comment is not a parameter (upstream #394, #894) + q, args, err := db.BindNamed("SELECT * FROM person WHERE id=:id /*TODO: handle accidental colon in named query*/", struct { ID int `db:"id"` }{1}) - if err == nil || !strings.Contains(err.Error(), "sqlx.bindArgs: empty named parameter") { - t.Error("Expected an empty named parameter error with struct arg.") + if err != nil || len(args) != 1 || !strings.Contains(q, "/*TODO: handle accidental colon in named query*/") { + t.Errorf("colon inside a comment with a struct arg: q=%q args=%v err=%v", q, args, err) } - _, _, err = db.BindNamed("SELECT * FROM person WHERE id=:id /*TODO: handle accidental colon in named query*/", map[string]interface{}{"id": 1}) - if err == nil || !strings.Contains(err.Error(), "sqlx.bindMapArgs: empty named parameter") { - t.Error("Expected an empty named parameter error with map arg.") + q, args, err = db.BindNamed("SELECT * FROM person WHERE id=:id /*TODO: handle accidental colon in named query*/", map[string]interface{}{"id": 1}) + if err != nil || len(args) != 1 || !strings.Contains(q, "/*TODO: handle accidental colon in named query*/") { + t.Errorf("colon inside a comment with a map arg: q=%q args=%v err=%v", q, args, err) } }) } diff --git a/scan.go b/scan.go new file mode 100644 index 0000000..74a0612 --- /dev/null +++ b/scan.go @@ -0,0 +1,273 @@ +package sqlx + +// The SQL scanner shared by Rebind, In and compileNamedQuery. It reports only +// the tokens those callers care about (a '?' placeholder, the '??' escape and +// a ':name' parameter) and treats everything else as opaque text, so that a +// '?' or ':' inside a string literal, a quoted identifier, a comment or a +// Postgres dollar-quoted string is never taken for one. + +import "strings" + +type tokKind uint8 + +const ( + tokEOF tokKind = iota + tokQuestion // a single '?' placeholder; end-start == 1 + tokQuestionPair // '??', an escaped literal '?'; end-start == 2 + tokName // ':' followed by a name; the name is q[start+1:end] +) + +type scanOpts uint8 + +const ( + // optBrackets makes [...] a delimited identifier (SQL Server). Off by + // default because Postgres uses [ ] for ARRAY[...] and subscripts. + optBrackets scanOpts = 1 << iota +) + +// sqlScanner walks a query from left to right. It holds no heap state. +type sqlScanner struct { + q string + pos int + opt scanOpts +} + +// stopByte marks the bytes the scanner has to look at; every other byte is +// skipped in a tight loop. +var stopByte = [256]bool{ + '?': true, ':': true, '\'': true, '"': true, '`': true, + '-': true, '/': true, '$': true, '[': true, +} + +func isLetter(b byte) bool { return 'a' <= b|0x20 && b|0x20 <= 'z' } +func isDigit(b byte) bool { return '0' <= b && b <= '9' } + +// isIdentByte: a byte that can be part of an unquoted identifier (letters, +// digits, '_', '$', and any non-ASCII byte). +func isIdentByte(b byte) bool { return isLetter(b) || isDigit(b) || b == '_' || b == '$' || b >= 0x80 } + +// name rules for ':name': must start with a letter, '_' or a non-ASCII byte; +// may continue with letters, digits, '_' and non-ASCII bytes; a '.' is part +// of the name only when followed by another name byte (":place.id"). +func isNameStart(b byte) bool { return isLetter(b) || b == '_' || b >= 0x80 } +func isNameByte(b byte) bool { return isLetter(b) || isDigit(b) || b == '_' || b >= 0x80 } + +// next returns the next significant token. kind==tokEOF when the input is +// exhausted. start and end are byte offsets into the query. +func (s *sqlScanner) next() (kind tokKind, start, end int) { + q := s.q + n := len(q) + i := s.pos + for i < n { + b := q[i] + if !stopByte[b] { + i++ + continue + } + switch b { + case '?': + if i+1 < n && q[i+1] == '?' { + s.pos = i + 2 + return tokQuestionPair, i, i + 2 + } + s.pos = i + 1 + return tokQuestion, i, i + 1 + case ':': + if i+1 < n && q[i+1] == ':' { // '::' cast (or SQL Server geography::Point) + i += 2 + continue + } + if i+1 < n && isNameStart(q[i+1]) { + j := i + 2 + for j < n { + c := q[j] + if isNameByte(c) || (c == '.' && j+1 < n && isNameByte(q[j+1])) { + j++ + continue + } + break + } + s.pos = j + return tokName, i, j + } + i++ // ':=' , ' : ', ':1', ':)' ... are plain text + case '\'': + // E'...' (Postgres escape string): backslash escapes are certain. + esc := i > 0 && (q[i-1] == 'E' || q[i-1] == 'e') && (i < 2 || !isIdentByte(q[i-2])) + if j, ok := scanQuoted(q, i+1, '\'', esc); ok { + i = j + } else { + i++ // unterminated: not a string after all, treat the quote as text + } + case '"': + if j, ok := scanQuoted(q, i+1, '"', false); ok { + i = j + } else { + i++ + } + case '`': + if j, ok := scanBacktick(q, i+1); ok { + i = j + } else { + i++ + } + case '[': + if s.opt&optBrackets != 0 { + if j, ok := scanBracket(q, i+1); ok { + i = j + continue + } + } + i++ + case '-': + if i+1 < n && q[i+1] == '-' { + if j := strings.IndexByte(q[i:], '\n'); j >= 0 { + i += j + 1 + } else { + i = n + } + } else { + i++ + } + case '/': + if i+1 < n && q[i+1] == '*' { + i = skipBlockComment(q, i+2) + } else { + i++ + } + case '$': + if i == 0 || !isIdentByte(q[i-1]) { + if j, ok := scanDollarQuote(q, i); ok { + i = j + continue + } + } + i++ + } + } + s.pos = n + return tokEOF, n, n +} + +// scanQuoted scans a '...' or "..." literal whose opening quote is at i-1. +// A doubled quote is always an escaped quote. A backslash is an escape only +// (a) in Postgres E'...' strings (escapeStrings), or (b) when it is followed by +// the quote character and the number of quote characters remaining in the +// query after that pair is odd (the MySQL \' heuristic; see the design doc). +// It returns the offset just past the closing quote, or ok=false when the +// literal is unterminated. +func scanQuoted(q string, i int, quote byte, escapeStrings bool) (end int, ok bool) { + n := len(q) + for i < n { + switch q[i] { + case quote: + if i+1 < n && q[i+1] == quote { + i += 2 + continue + } + return i + 1, true + case '\\': + if i+1 >= n { + return 0, false + } + switch { + case escapeStrings: + i += 2 // E'..\x..': the next byte is always escaped + case q[i+1] == '\\': + i += 2 // '\\' reads the same under both rules + case q[i+1] == quote: + if strings.Count(q[i+2:], string(quote))%2 == 1 { + i += 2 // MySQL-style escaped quote: 'don\'t' + } else { + i++ // standard: the backslash is text and the quote closes: ESCAPE '\' + } + default: + i++ + } + default: + i++ + } + } + return 0, false +} + +// scanBacktick: MySQL/SQLite `identifier`; a doubled backtick is an escaped +// backtick, there are no backslash escapes. +func scanBacktick(q string, i int) (end int, ok bool) { + n := len(q) + for i < n { + if q[i] == '`' { + if i+1 < n && q[i+1] == '`' { + i += 2 + continue + } + return i + 1, true + } + i++ + } + return 0, false +} + +// scanBracket: SQL Server [identifier]; ]] is an escaped ]. +func scanBracket(q string, i int) (end int, ok bool) { + n := len(q) + for i < n { + if q[i] == ']' { + if i+1 < n && q[i+1] == ']' { + i += 2 + continue + } + return i + 1, true + } + i++ + } + return 0, false +} + +// skipBlockComment skips a /* */ comment whose opener ends at i. Comments +// nest (SQL standard, Postgres, SQL Server). An unterminated comment runs to +// the end of the input (SQLite rule; Postgres/MySQL reject the query anyway). +func skipBlockComment(q string, i int) int { + n := len(q) + depth := 1 + for i+1 < n { + if q[i] == '/' && q[i+1] == '*' { + depth++ + i += 2 + continue + } + if q[i] == '*' && q[i+1] == '/' { + depth-- + i += 2 + if depth == 0 { + return i + } + continue + } + i++ + } + return n +} + +// scanDollarQuote scans a Postgres $tag$...$tag$ or $$...$$ string starting +// at q[i]=='$'. The tag follows unquoted-identifier rules (so "$1" is never a +// tag). An opener without a matching closer is not a dollar quote. +func scanDollarQuote(q string, i int) (end int, ok bool) { + n := len(q) + j := i + 1 + if j < n && isDigit(q[j]) { + return 0, false + } + for j < n && (isLetter(q[j]) || isDigit(q[j]) || q[j] == '_' || q[j] >= 0x80) { + j++ + } + if j >= n || q[j] != '$' { + return 0, false + } + tag := q[i : j+1] + k := strings.Index(q[j+1:], tag) + if k < 0 { + return 0, false + } + return j + 1 + k + len(tag), true +} diff --git a/scan_test.go b/scan_test.go new file mode 100644 index 0000000..d4f8c1f --- /dev/null +++ b/scan_test.go @@ -0,0 +1,233 @@ +package sqlx + +import ( + "strings" + "testing" +) + +type inCase struct { + name string + q string + args []interface{} + want string // expected query from the new In; "" when wantErr is set + wantN int // expected len(args) + wantErr string // substring of the expected error, "" for no error +} + +type rebindCase struct { + name string + q string + types []int + want map[int]string +} + +type namedCase struct { + name string + q string + types []int + want map[int]string + names []string + wantErr string +} + +var inInts = []int{1, 2, 3} +var inTwo = []int{1, 2} + +var inCases = []inCase{ + {"plain slice expansion", `SELECT * FROM t WHERE id IN (?)`, []interface{}{inInts}, `SELECT * FROM t WHERE id IN (?, ?, ?)`, 3, ""}, + {"mixed scalar and slice", `SELECT * FROM t WHERE x = ? AND id IN (?) AND y = ?`, []interface{}{"a", inInts, "b"}, `SELECT * FROM t WHERE x = ? AND id IN (?, ?, ?) AND y = ?`, 5, ""}, + {"? in line comment (#961)", "SELECT * -- should we filter by id?\nFROM t WHERE id IN (?)", []interface{}{inInts}, "SELECT * -- should we filter by id?\nFROM t WHERE id IN (?, ?, ?)", 3, ""}, + {"? in block comment", `SELECT /* id=? */ * FROM t WHERE id IN (?)`, []interface{}{inTwo}, `SELECT /* id=? */ * FROM t WHERE id IN (?, ?)`, 2, ""}, + {"? in nested block comment", `SELECT /* a /* b? */ c? */ * FROM t WHERE id IN (?)`, []interface{}{inTwo}, `SELECT /* a /* b? */ c? */ * FROM t WHERE id IN (?, ?)`, 2, ""}, + {"? in single-quoted string (#608)", `INSERT INTO t (a, b, c) VALUES ('?? ?', ?, ?)`, []interface{}{"int", inTwo}, `INSERT INTO t (a, b, c) VALUES ('?? ?', ?, ?, ?)`, 3, ""}, + {"? after doubled quote", `SELECT 'it''s?' FROM t WHERE id IN (?)`, []interface{}{inTwo}, `SELECT 'it''s?' FROM t WHERE id IN (?, ?)`, 2, ""}, + {"? in double-quoted identifier", `SELECT "a?b" FROM t WHERE id IN (?)`, []interface{}{inTwo}, `SELECT "a?b" FROM t WHERE id IN (?, ?)`, 2, ""}, + {"? in backtick identifier", "SELECT `a?b` FROM t WHERE id IN (?)", []interface{}{inTwo}, "SELECT `a?b` FROM t WHERE id IN (?, ?)", 2, ""}, + {"? in dollar-quoted string", `SELECT $$a?b$$ FROM t WHERE id IN (?)`, []interface{}{inTwo}, `SELECT $$a?b$$ FROM t WHERE id IN (?, ?)`, 2, ""}, + {"? in tagged dollar quote", `SELECT $fn$ it's ? $fn$ FROM t WHERE id IN (?)`, []interface{}{inTwo}, `SELECT $fn$ it's ? $fn$ FROM t WHERE id IN (?, ?)`, 2, ""}, + {"regex with (?:) in literal (#487)", `SELECT "IPs" ~ ('(?:^|,)' || ? || '(?:,|$)') FROM r WHERE "RuleID" IN (?)`, []interface{}{"10.0.0.1", inTwo}, `SELECT "IPs" ~ ('(?:^|,)' || ? || '(?:,|$)') FROM r WHERE "RuleID" IN (?, ?)`, 3, ""}, + {"?? escape is not a placeholder (#628)", `SELECT * FROM t WHERE data ?? 'k' AND id IN (?)`, []interface{}{inTwo}, `SELECT * FROM t WHERE data ?? 'k' AND id IN (?, ?)`, 2, ""}, + {"$1 placeholders are left alone", `SELECT * FROM t WHERE a = $1 AND id IN (?)`, []interface{}{inTwo}, `SELECT * FROM t WHERE a = $1 AND id IN (?, ?)`, 2, ""}, + {"E'' string with backslash-quote", `SELECT E'it\'s?' FROM t WHERE id IN (?)`, []interface{}{inTwo}, `SELECT E'it\'s?' FROM t WHERE id IN (?, ?)`, 2, ""}, + {"MySQL backslash-escaped quote", `SELECT 'don\'t?' FROM t WHERE id IN (?)`, []interface{}{inTwo}, `SELECT 'don\'t?' FROM t WHERE id IN (?, ?)`, 2, ""}, + {"LIKE ... ESCAPE '\\' (standard string ending in backslash)", `SELECT * FROM t WHERE name LIKE ? ESCAPE '\' AND id IN (?)`, []interface{}{"a%", inTwo}, `SELECT * FROM t WHERE name LIKE ? ESCAPE '\' AND id IN (?, ?)`, 3, ""}, + {"ARRAY[...] is not an identifier", `SELECT ARRAY[?, ?] FROM t WHERE id IN (?)`, []interface{}{1, 2, inTwo}, `SELECT ARRAY[?, ?] FROM t WHERE id IN (?, ?)`, 4, ""}, + {"trailing line comment without newline", `SELECT * FROM t WHERE id IN (?) -- why?`, []interface{}{inTwo}, `SELECT * FROM t WHERE id IN (?, ?) -- why?`, 2, ""}, + {"unterminated quote degrades to plain text", `SELECT * FROM t WHERE note = 'x AND id IN (?)`, []interface{}{inTwo}, `SELECT * FROM t WHERE note = 'x AND id IN (?, ?)`, 2, ""}, + {"no slices: query returned unparsed", `SELECT '?' FROM t WHERE x = ?`, []interface{}{"a"}, `SELECT '?' FROM t WHERE x = ?`, 1, ""}, + {"more args than bindvars", `SELECT * FROM t WHERE x = ? and y = ?`, []interface{}{"foo", inInts, "bar"}, "", 0, "less than"}, + {"more bindvars than args", `SELECT * FROM t WHERE x = ? AND y in (?)`, []interface{}{inInts}, "", 0, "exceeds"}, + {"empty slice", `SELECT * FROM t WHERE id IN (?)`, []interface{}{[]int{}}, `SELECT * FROM t WHERE id IN (NULL)`, 0, ""}, + {"# is not a comment (documented limitation)", "SELECT * # really?\nFROM t WHERE id IN (?)", []interface{}{inTwo}, "", 0, "exceeds"}, + {"[bracket] identifier not honoured by In (documented limitation)", `SELECT [a?b] FROM t WHERE id IN (?)`, []interface{}{inTwo}, "", 0, "exceeds"}, +} + +var allBindTypes = []int{QUESTION, DOLLAR, NAMED, AT} + +var rebindCases = []rebindCase{ + {"TestRebind q1", `INSERT INTO foo (a, b, c, d, e, f, g, h, i) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, allBindTypes, map[int]string{ + QUESTION: `INSERT INTO foo (a, b, c, d, e, f, g, h, i) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + DOLLAR: `INSERT INTO foo (a, b, c, d, e, f, g, h, i) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, + NAMED: `INSERT INTO foo (a, b, c, d, e, f, g, h, i) VALUES (:arg1, :arg2, :arg3, :arg4, :arg5, :arg6, :arg7, :arg8, :arg9, :arg10)`, + AT: `INSERT INTO foo (a, b, c, d, e, f, g, h, i) VALUES (@p1, @p2, @p3, @p4, @p5, @p6, @p7, @p8, @p9, @p10)`}}, + {"TestRebind q2", `INSERT INTO foo (a, b, c) VALUES (?, ?, "foo"), ("Hi", ?, ?)`, allBindTypes, map[int]string{ + QUESTION: `INSERT INTO foo (a, b, c) VALUES (?, ?, "foo"), ("Hi", ?, ?)`, + DOLLAR: `INSERT INTO foo (a, b, c) VALUES ($1, $2, "foo"), ("Hi", $3, $4)`, + NAMED: `INSERT INTO foo (a, b, c) VALUES (:arg1, :arg2, "foo"), ("Hi", :arg3, :arg4)`, + AT: `INSERT INTO foo (a, b, c) VALUES (@p1, @p2, "foo"), ("Hi", @p3, @p4)`}}, + {"? in single-quoted string", `SELECT '?' AS q, ? FROM t`, allBindTypes, map[int]string{ + QUESTION: `SELECT '?' AS q, ? FROM t`, DOLLAR: `SELECT '?' AS q, $1 FROM t`, NAMED: `SELECT '?' AS q, :arg1 FROM t`, AT: `SELECT '?' AS q, @p1 FROM t`}}, + {"? after doubled quote", `SELECT 'it''s?', ? FROM t`, []int{DOLLAR}, map[int]string{DOLLAR: `SELECT 'it''s?', $1 FROM t`}}, + {"? in double-quoted identifier", `SELECT "what?" FROM t WHERE id = ?`, []int{DOLLAR, AT}, map[int]string{DOLLAR: `SELECT "what?" FROM t WHERE id = $1`, AT: `SELECT "what?" FROM t WHERE id = @p1`}}, + {"? in backtick identifier", "SELECT `what?` FROM t WHERE id = ?", []int{DOLLAR}, map[int]string{DOLLAR: "SELECT `what?` FROM t WHERE id = $1"}}, + {"? in [bracket] identifier (AT only)", `SELECT [what?] FROM t WHERE id = ?`, []int{AT, DOLLAR}, map[int]string{AT: `SELECT [what?] FROM t WHERE id = @p1`, DOLLAR: `SELECT [what$1] FROM t WHERE id = $2`}}, + {"ARRAY[?] subscript (DOLLAR)", `SELECT ARRAY[?, ?], arr[?] FROM t`, []int{DOLLAR}, map[int]string{DOLLAR: `SELECT ARRAY[$1, $2], arr[$3] FROM t`}}, + {"? in line comment", "SELECT ? -- why?\nFROM t", []int{DOLLAR}, map[int]string{DOLLAR: "SELECT $1 -- why?\nFROM t"}}, + {"? in block comment", `SELECT /* ? */ ? FROM t`, []int{DOLLAR}, map[int]string{DOLLAR: `SELECT /* ? */ $1 FROM t`}}, + {"? in nested block comment", `SELECT /* a /* ? */ ? */ ? FROM t`, []int{DOLLAR}, map[int]string{DOLLAR: `SELECT /* a /* ? */ ? */ $1 FROM t`}}, + {"? in dollar quote", `SELECT $$?$$, ? FROM t`, []int{DOLLAR}, map[int]string{DOLLAR: `SELECT $$?$$, $1 FROM t`}}, + {"? in tagged dollar quote", `SELECT $q$it's ?$q$, ? FROM t`, []int{DOLLAR}, map[int]string{DOLLAR: `SELECT $q$it's ?$q$, $1 FROM t`}}, + {"$tag without closer is plain text", `SELECT $a$ , ? FROM t`, []int{DOLLAR}, map[int]string{DOLLAR: `SELECT $a$ , $1 FROM t`}}, + {"identifier containing $ is not a dollar quote", `SELECT a$b$c, ? FROM t WHERE x = ?`, []int{DOLLAR}, map[int]string{DOLLAR: `SELECT a$b$c, $1 FROM t WHERE x = $2`}}, + {"?? escape -> literal ? (jsonb ? operator)", `SELECT * FROM t WHERE data ?? 'k' AND id = ?`, allBindTypes, map[int]string{ + QUESTION: `SELECT * FROM t WHERE data ? 'k' AND id = ?`, DOLLAR: `SELECT * FROM t WHERE data ? 'k' AND id = $1`, NAMED: `SELECT * FROM t WHERE data ? 'k' AND id = :arg1`, AT: `SELECT * FROM t WHERE data ? 'k' AND id = @p1`}}, + {"??| and ??& operators", `SELECT * FROM t WHERE data ??| array['a'] AND data ??& array['b'] AND id = ?`, []int{DOLLAR}, map[int]string{DOLLAR: `SELECT * FROM t WHERE data ?| array['a'] AND data ?& array['b'] AND id = $1`}}, + {"QUESTION without ?? is returned unchanged", `SELECT '?' FROM t WHERE id = ?`, []int{QUESTION, UNKNOWN}, map[int]string{QUESTION: `SELECT '?' FROM t WHERE id = ?`, UNKNOWN: `SELECT '?' FROM t WHERE id = ?`}}, + {"E'' string with backslash-quote", `SELECT E'it\'s?', ? FROM t`, []int{DOLLAR}, map[int]string{DOLLAR: `SELECT E'it\'s?', $1 FROM t`}}, + {"E'' is only a prefix when not part of an identifier", `SELECT SOME'x' , ? FROM t`, []int{DOLLAR}, map[int]string{DOLLAR: `SELECT SOME'x' , $1 FROM t`}}, + {"LIKE ... ESCAPE '\\'", `SELECT * FROM t WHERE name LIKE ? ESCAPE '\' AND id = ?`, []int{DOLLAR}, map[int]string{DOLLAR: `SELECT * FROM t WHERE name LIKE $1 ESCAPE '\' AND id = $2`}}, + {"MySQL backslash-escaped quote (heuristic)", `SELECT 'don\'t?', ? FROM t`, []int{DOLLAR}, map[int]string{DOLLAR: `SELECT 'don\'t?', $1 FROM t`}}, + {"Windows path literal", `SELECT * FROM f WHERE dir = 'C:\' AND id = ?`, []int{DOLLAR}, map[int]string{DOLLAR: `SELECT * FROM f WHERE dir = 'C:\' AND id = $1`}}, + {"unterminated quote degrades to plain text", `SELECT 'x FROM t WHERE id = ?`, []int{DOLLAR}, map[int]string{DOLLAR: `SELECT 'x FROM t WHERE id = $1`}}, + {"# is not a comment (documented limitation)", "SELECT * # what?\nFROM t WHERE id = ?", []int{DOLLAR}, map[int]string{DOLLAR: "SELECT * # what$1\nFROM t WHERE id = $2"}}, + {"jsonb #> operator is untouched", `SELECT data #> '{a}' FROM t WHERE id = ?`, []int{DOLLAR}, map[int]string{DOLLAR: `SELECT data #> '{a}' FROM t WHERE id = $1`}}, + {":name text is untouched by Rebind", `SELECT :name, ? FROM t`, []int{DOLLAR}, map[int]string{DOLLAR: `SELECT :name, $1 FROM t`}}, +} + +var namedCases = []namedCase{ + {"TestCompileQuery row 1", `INSERT INTO foo (a,b,c,d) VALUES (:name, :age, :first, :last)`, allBindTypes, map[int]string{ + QUESTION: `INSERT INTO foo (a,b,c,d) VALUES (?, ?, ?, ?)`, DOLLAR: `INSERT INTO foo (a,b,c,d) VALUES ($1, $2, $3, $4)`, + AT: `INSERT INTO foo (a,b,c,d) VALUES (@p1, @p2, @p3, @p4)`, NAMED: `INSERT INTO foo (a,b,c,d) VALUES (:name, :age, :first, :last)`}, + []string{"name", "age", "first", "last"}, ""}, + {"TestCompileQuery row 2 (name at end, digits)", `SELECT * FROM a WHERE first_name=:name1 AND last_name=:name2`, allBindTypes, map[int]string{ + QUESTION: `SELECT * FROM a WHERE first_name=? AND last_name=?`, DOLLAR: `SELECT * FROM a WHERE first_name=$1 AND last_name=$2`, + AT: `SELECT * FROM a WHERE first_name=@p1 AND last_name=@p2`, NAMED: `SELECT * FROM a WHERE first_name=:name1 AND last_name=:name2`}, + []string{"name1", "name2"}, ""}, + {"TestCompileQuery row 3 (CHANGED: \"::foo\" is verbatim)", `SELECT "::foo" FROM a WHERE first_name=:name1 AND last_name=:name2`, []int{QUESTION, DOLLAR}, map[int]string{ + QUESTION: `SELECT "::foo" FROM a WHERE first_name=? AND last_name=?`, DOLLAR: `SELECT "::foo" FROM a WHERE first_name=$1 AND last_name=$2`}, + []string{"name1", "name2"}, ""}, + {"TestCompileQuery row 4 (CHANGED: string contents verbatim)", `SELECT 'a::b::c' || first_name, '::::ABC::_::' FROM person WHERE first_name=:first_name AND last_name=:last_name`, []int{QUESTION, DOLLAR}, map[int]string{ + QUESTION: `SELECT 'a::b::c' || first_name, '::::ABC::_::' FROM person WHERE first_name=? AND last_name=?`, + DOLLAR: `SELECT 'a::b::c' || first_name, '::::ABC::_::' FROM person WHERE first_name=$1 AND last_name=$2`}, + []string{"first_name", "last_name"}, ""}, + {"TestCompileQuery row 5 (:= assignment)", `SELECT @name := "name", :age, :first, :last`, allBindTypes, map[int]string{ + QUESTION: `SELECT @name := "name", ?, ?, ?`, DOLLAR: `SELECT @name := "name", $1, $2, $3`, + NAMED: `SELECT @name := "name", :age, :first, :last`, AT: `SELECT @name := "name", @p1, @p2, @p3`}, + []string{"age", "first", "last"}, ""}, + {"unicode names (the commented-out TestCompileQuery row)", `INSERT INTO foo (a,b,c,d) VALUES (:あ, :b, :キコ, :名前)`, []int{QUESTION, DOLLAR, NAMED}, map[int]string{ + QUESTION: `INSERT INTO foo (a,b,c,d) VALUES (?, ?, ?, ?)`, DOLLAR: `INSERT INTO foo (a,b,c,d) VALUES ($1, $2, $3, $4)`, NAMED: `INSERT INTO foo (a,b,c,d) VALUES (:あ, :b, :キコ, :名前)`}, + []string{"あ", "b", "キコ", "名前"}, ""}, + {"nested struct name :place.id", `SELECT * FROM pp WHERE place.id=:place.id`, []int{QUESTION}, map[int]string{QUESTION: `SELECT * FROM pp WHERE place.id=?`}, []string{"place.id"}, ""}, + {"trailing dot is not part of the name", `SELECT :a.`, []int{QUESTION}, map[int]string{QUESTION: `SELECT ?.`}, []string{"a"}, ""}, + {"cast attached to a parameter (#983, PR #1002)", `SELECT :boundary::jsonb AS b, '2026-05-01'::timestamptz AS s`, allBindTypes, map[int]string{ + QUESTION: `SELECT ?::jsonb AS b, '2026-05-01'::timestamptz AS s`, DOLLAR: `SELECT $1::jsonb AS b, '2026-05-01'::timestamptz AS s`, + NAMED: `SELECT :boundary::jsonb AS b, '2026-05-01'::timestamptz AS s`, AT: `SELECT @p1::jsonb AS b, '2026-05-01'::timestamptz AS s`}, + []string{"boundary"}, ""}, + {"cast elsewhere (#428 #650 #956)", `SELECT (d.categories->>0)::int AS c, now()::date FROM t WHERE id = :id`, []int{QUESTION, DOLLAR}, map[int]string{ + QUESTION: `SELECT (d.categories->>0)::int AS c, now()::date FROM t WHERE id = ?`, DOLLAR: `SELECT (d.categories->>0)::int AS c, now()::date FROM t WHERE id = $1`}, + []string{"id"}, ""}, + {"CHANGED: :::: is no longer an escape", `SELECT :x ::::jsonb, (1, 'John')::::my_table`, []int{QUESTION}, map[int]string{QUESTION: `SELECT ? ::::jsonb, (1, 'John')::::my_table`}, []string{"x"}, ""}, + {"SQL Server static method geography::Point", `SELECT geography::Point(:lat, :lng, 4326)`, []int{AT}, map[int]string{AT: `SELECT geography::Point(@p1, @p2, 4326)`}, []string{"lat", "lng"}, ""}, + {"time literal in string (#302 #368)", `SELECT * FROM t WHERE date="1970-01-01 00:00:00" AND v=:v`, []int{QUESTION}, map[int]string{QUESTION: `SELECT * FROM t WHERE date="1970-01-01 00:00:00" AND v=?`}, []string{"v"}, ""}, + {"timezone offset in string (#253 #443)", `SELECT CONVERT_TZ(FROM_UNIXTIME(ts), @@session.time_zone, '+00:00') AS s FROM t WHERE id=:id`, []int{QUESTION}, map[int]string{QUESTION: `SELECT CONVERT_TZ(FROM_UNIXTIME(ts), @@session.time_zone, '+00:00') AS s FROM t WHERE id=?`}, []string{"id"}, ""}, + {"IPv6 literal in string (#872)", `INSERT INTO users (name, ipv6) VALUES (:name, '2001:0db8:85a3::8a2e:0370:7334')`, []int{QUESTION}, map[int]string{QUESTION: `INSERT INTO users (name, ipv6) VALUES (?, '2001:0db8:85a3::8a2e:0370:7334')`}, []string{"name"}, ""}, + {"URL in string (#947)", `SELECT id, 'http://example.com' AS link FROM t WHERE id = (:input_id)`, []int{QUESTION}, map[int]string{QUESTION: `SELECT id, 'http://example.com' AS link FROM t WHERE id = (?)`}, []string{"input_id"}, ""}, + {"colon followed by space in string (#206)", `SELECT '1: profiles' AS p, :a FROM profiles`, []int{QUESTION}, map[int]string{QUESTION: `SELECT '1: profiles' AS p, ? FROM profiles`}, []string{"a"}, ""}, + {"colon in line comment (#961 #206 #894)", "SELECT * FROM t WHERE col = :param -- Note: this breaks", []int{QUESTION}, map[int]string{QUESTION: "SELECT * FROM t WHERE col = ? -- Note: this breaks"}, []string{"param"}, ""}, + {"colon in block comment (#920)", `SELECT * FROM person WHERE id=:id /*TODO: handle :colon*/`, []int{QUESTION}, map[int]string{QUESTION: `SELECT * FROM person WHERE id=? /*TODO: handle :colon*/`}, []string{"id"}, ""}, + {"license header with URL (#894)", "-- http://www.apache.org/licenses/LICENSE-2.0\nSELECT * FROM t WHERE id = :id", []int{QUESTION}, map[int]string{QUESTION: "-- http://www.apache.org/licenses/LICENSE-2.0\nSELECT * FROM t WHERE id = ?"}, []string{"id"}, ""}, + {"# comment with colon+space (#394) works via the name-start rule", "# author(chen): some explanation\nINSERT INTO t(foo, bar) VALUE (:foo, :bar)", []int{QUESTION}, map[int]string{QUESTION: "# author(chen): some explanation\nINSERT INTO t(foo, bar) VALUE (?, ?)"}, []string{"foo", "bar"}, ""}, + {"# comment with :word is still misread (documented limitation)", "# see :ticket\nSELECT :a", []int{QUESTION}, map[int]string{QUESTION: "# see ?\nSELECT ?"}, []string{"ticket", "a"}, ""}, + {"trailing underscore at end of query (#441)", `UPDATE t SET name = :name WHERE id_ = :id_`, []int{QUESTION}, map[int]string{QUESTION: `UPDATE t SET name = ? WHERE id_ = ?`}, []string{"name", "id_"}, ""}, + {"lone colon at end of query", `SELECT :`, []int{QUESTION}, map[int]string{QUESTION: `SELECT :`}, []string{}, ""}, + {"colon before ) and ,", `SELECT f(:), g(:, 1)`, []int{QUESTION}, map[int]string{QUESTION: `SELECT f(:), g(:, 1)`}, []string{}, ""}, + {"array slice arr[1:2] is not a parameter", `SELECT arr[1:2], arr[:n] FROM t WHERE id = :id`, []int{QUESTION}, map[int]string{QUESTION: `SELECT arr[1:2], arr[?] FROM t WHERE id = ?`}, []string{"n", "id"}, ""}, + {"CHANGED: digit-start :1 is not a parameter", `SELECT :1, :a`, []int{QUESTION}, map[int]string{QUESTION: `SELECT :1, ?`}, []string{"a"}, ""}, + {"PL/pgSQL assignment", `DO $$ BEGIN x := :v; END $$; SELECT :w`, []int{QUESTION}, map[int]string{QUESTION: `DO $$ BEGIN x := :v; END $$; SELECT ?`}, []string{"w"}, ""}, + {"named param inside dollar quote is text", `SELECT $$:x$$, :a`, []int{QUESTION}, map[int]string{QUESTION: `SELECT $$:x$$, ?`}, []string{"a"}, ""}, + {"E'' string with colon", `SELECT E'a\'b:c', :a`, []int{QUESTION}, map[int]string{QUESTION: `SELECT E'a\'b:c', ?`}, []string{"a"}, ""}, + {"MySQL backslash string with colon", `SELECT 'don\'t:x', :a`, []int{QUESTION}, map[int]string{QUESTION: `SELECT 'don\'t:x', ?`}, []string{"a"}, ""}, + {"TestEscapedColons input (currently skipped)", `SELECT * FROM testtable WHERE timeposted BETWEEN (now() AT TIME ZONE 'utc') AND + (now() AT TIME ZONE 'utc') - interval '01:30:00') AND name = '\'this is a test\'' and id = :id`, []int{DOLLAR}, map[int]string{DOLLAR: `SELECT * FROM testtable WHERE timeposted BETWEEN (now() AT TIME ZONE 'utc') AND + (now() AT TIME ZONE 'utc') - interval '01:30:00') AND name = '\'this is a test\'' and id = $1`}, []string{"id"}, ""}, + {"? placeholders are left alone by Named", `SELECT '?' , ?, :a`, []int{QUESTION}, map[int]string{QUESTION: `SELECT '?' , ?, ?`}, []string{"a"}, ""}, + {"[bracket] identifier with colon (AT only)", `SELECT [a:b] FROM t WHERE id = :id`, []int{AT}, map[int]string{AT: `SELECT [a:b] FROM t WHERE id = @p1`}, []string{"id"}, ""}, + {":first:name still errors", `SELECT * FROM person WHERE first_name=:first:name`, []int{QUESTION}, nil, nil, "unexpected `:` while reading named param at 44"}, + {"param immediately before cast then another param", `SELECT :a::text, :b`, []int{DOLLAR}, map[int]string{DOLLAR: `SELECT $1::text, $2`}, []string{"a", "b"}, ""}, + {"same name twice yields inTwo bindvars", `SELECT :a, :a`, []int{DOLLAR}, map[int]string{DOLLAR: `SELECT $1, $2`}, []string{"a", "a"}, ""}, + {"param followed by ) at end", `INSERT INTO t (a) VALUES (:a)`, []int{QUESTION}, map[int]string{QUESTION: `INSERT INTO t (a) VALUES (?)`}, []string{"a"}, ""}, +} + +func bindTypeName(bt int) string { + return map[int]string{UNKNOWN: "UNKNOWN", QUESTION: "QUESTION", DOLLAR: "DOLLAR", NAMED: "NAMED", AT: "AT"}[bt] +} +func TestInMatrix(t *testing.T) { + for _, c := range inCases { + t.Run(c.name, func(t *testing.T) { + q, a, err := In(c.q, c.args...) + if c.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), c.wantErr) { + t.Fatalf("want error containing %q, got %v (query %q)", c.wantErr, err, q) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if q != c.want { + t.Errorf("query:\n got: %q\nwant: %q", q, c.want) + } + if len(a) != c.wantN { + t.Errorf("args: got %d want %d (%v)", len(a), c.wantN, a) + } + }) + } +} + +func TestRebindMatrix(t *testing.T) { + for _, c := range rebindCases { + for _, bt := range c.types { + t.Run(c.name+"/"+bindTypeName(bt), func(t *testing.T) { + got := Rebind(bt, c.q) + if got != c.want[bt] { + t.Errorf("\n got: %q\nwant: %q", got, c.want[bt]) + } + }) + } + } +} + +func TestNamedMatrix(t *testing.T) { + for _, c := range namedCases { + for _, bt := range c.types { + t.Run(c.name+"/"+bindTypeName(bt), func(t *testing.T) { + q, names, err := compileNamedQuery(c.q, bt) + if c.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), c.wantErr) { + t.Fatalf("want error containing %q, got %v (query %q)", c.wantErr, err, q) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if q != c.want[bt] { + t.Errorf("query:\n got: %q\nwant: %q", q, c.want[bt]) + } + if strings.Join(names, ",") != strings.Join(c.names, ",") { + t.Errorf("names: got %q want %q", names, c.names) + } + }) + } + } +} From cebe09c7601bc8458b7eddda961b7e85b1d595dc Mon Sep 17 00:00:00 2001 From: August Vi Date: Sat, 10 Oct 2026 21:29:39 -0700 Subject: [PATCH 2/2] Add a Postgres test for a cast attached to a named parameter. --- named_cast_test.go | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 named_cast_test.go diff --git a/named_cast_test.go b/named_cast_test.go new file mode 100644 index 0000000..d37798a --- /dev/null +++ b/named_cast_test.go @@ -0,0 +1,38 @@ +package sqlx + +import "testing" + +// A Postgres cast attached to a named parameter binds the parameter and keeps +// the cast (upstream #983); a cast elsewhere in the query is left alone +// (upstream #428, #650, #956). Other databases have no :: operator. +func TestNamedPostgresTypeCast(t *testing.T) { + RunWithSchema(defaultSchema, t, func(db *DB, t *testing.T, now string) { + if db.DriverName() != "postgres" { + return // the :: cast is Postgres syntax + } + loadDefaultFixture(db, t) + + var got struct { + Code string `db:"code"` + Day string `db:"day"` + Count int `db:"count"` + } + rows, err := db.NamedQuery(` + SELECT :tel::text AS code, (now() AT TIME ZONE 'utc')::date::text AS day, count(*)::int AS count + FROM place WHERE telcode = :tel::int`, + map[string]interface{}{"tel": 852}) + if err != nil { + t.Fatal(err) + } + defer rows.Close() + if !rows.Next() { + t.Fatal("expected one row") + } + if err := rows.StructScan(&got); err != nil { + t.Fatal(err) + } + if got.Code != "852" || got.Count != 1 || len(got.Day) != 10 { + t.Errorf("got %+v", got) + } + }) +}