diff --git a/named.go b/named.go index 6ac447771..8e4d64ab3 100644 --- a/named.go +++ b/named.go @@ -337,20 +337,73 @@ func compileNamedQuery(qs []byte, bindType int) (query string, names []string, e currentVar := 1 name := make([]byte, 0, 10) + // emitName closes out the named parameter we've been building up in name + // and writes the appropriate bindvar into rebound. Used both when the + // terminating non-name byte is part of the regular text (the original + // else-if-inName branch below) and when a Postgres "::" cast is glued + // directly to a named parameter such as :boundary::jsonb (see #983). + emitName := func() { + names = append(names, string(name)) + switch bindType { + 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++ + } + } + + skipNext := false for i, b := range qs { + if skipNext { + skipNext = false + continue + } // 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] == ':' { + if inName && i > 0 && qs[i-1] == ':' && len(name) == 0 { rebound = append(rebound, ':') inName = false continue - } else if inName { + } + // A ':' arriving in the middle of a built-up name means we hit + // something like :boundary::jsonb. End the current name first. + // If the next byte is also ':', this is a PostgreSQL cast and + // the whole "::" should pass through to the output (see #983). + // Otherwise treat the trailing colon as literal text. + if inName && len(name) > 0 { + emitName() + if i < last && qs[i+1] == ':' { + rebound = append(rebound, ':', ':') + inName = false + skipNext = true + name = name[:0] + continue + } + rebound = append(rebound, ':') + inName = false + name = name[:0] + continue + } + if inName { err = errors.New("unexpected `:` while reading named param at " + strconv.Itoa(i)) return query, names, err } inName = true - name = []byte{} + name = name[:0] } else if inName && i > 0 && b == '=' && len(name) == 0 { rebound = append(rebound, ':', '=') inName = false @@ -367,29 +420,7 @@ func compileNamedQuery(qs []byte, bindType int) (query string, names []string, e 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++ - } + emitName() // add this byte to string unless it was not part of the name if i != last { rebound = append(rebound, b) diff --git a/named_test.go b/named_test.go index 0ee5b85fa..e727ddd06 100644 --- a/named_test.go +++ b/named_test.go @@ -53,6 +53,17 @@ func TestCompileQuery(t *testing.T) { T: `SELECT @name := "name", @p1, @p2, @p3`, V: []string{"age", "first", "last"}, }, + // Postgres :: cast directly attached to a named parameter + // (see github.com/jmoiron/sqlx#983). The "::" must pass through + // to the rebound query instead of returning "unexpected ':'". + { + Q: `SELECT :boundary::jsonb, :id::text`, + R: `SELECT ?::jsonb, ?::text`, + D: `SELECT $1::jsonb, $2::text`, + N: `SELECT :boundary::jsonb, :id::text`, + T: `SELECT @p1::jsonb, @p2::text`, + V: []string{"boundary", "id"}, + }, /* 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)