From 83176ae635bc5f4089dbe2e76ce9114e3f3beed0 Mon Sep 17 00:00:00 2001 From: Charlie Tonneslan Date: Fri, 15 May 2026 12:57:45 -0400 Subject: [PATCH] named: support PostgreSQL :: cast directly after a named param MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sqlx.Named was bailing with "unexpected ':' while reading named param" on perfectly valid Postgres queries like: SELECT :boundary::jsonb FROM ... because the colon parser only knew about :: as a way to escape a single colon in a string literal — it had no story for the cast operator glued directly to the trailing edge of a named parameter. Closes #983. After this change, when ':' lands while we've already built up a name, we emit the bindvar for the name and then look at the next byte. If it's another ':' we treat the whole '::' as a Postgres cast and pass both bytes through to the rebound query (and skip the second one). Otherwise the trailing ':' is written as literal text. Also pulled the bindvar-emission switch into an emitName helper so the new path and the existing end-of-name path don't drift. The 'a::b::c' escape behavior inside SQL string literals is unchanged — that case is still ambiguous between Postgres cast and escaped colon and isn't what #983 is about. Signed-off-by: Charlie Tonneslan --- named.go | 83 +++++++++++++++++++++++++++++++++++---------------- named_test.go | 11 +++++++ 2 files changed, 68 insertions(+), 26 deletions(-) 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)