From 7ca69dd818e8804b86c2edbc3db76f535362efef Mon Sep 17 00:00:00 2001 From: Burak Keskin Date: Sun, 30 Aug 2026 10:44:20 +0300 Subject: [PATCH] Allow a PostgreSQL type cast immediately after a named parameter. compileNamedQuery treated the first colon of :name::type as an error while still inside the name, so sqlx.Named(":boundary::jsonb") failed before any bind vars were produced. --- named.go | 63 +++++++++++++++++++++++++++++++-------------------- named_test.go | 47 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 24 deletions(-) diff --git a/named.go b/named.go index 6ac447771..0228c784f 100644 --- a/named.go +++ b/named.go @@ -336,10 +336,47 @@ func compileNamedQuery(qs []byte, bindType int) (query string, names []string, e last := len(qs) - 1 currentVar := 1 name := make([]byte, 0, 10) + skipNext := false + + appendNamedBind := func() { + names = append(names, string(name)) + 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 _, nb := range strconv.Itoa(currentVar) { + rebound = append(rebound, byte(nb)) + } + currentVar++ + case AT: + rebound = append(rebound, '@', 'p') + for _, nb := range strconv.Itoa(currentVar) { + rebound = append(rebound, byte(nb)) + } + currentVar++ + } + } for i, b := range qs { - // a ':' while we're in a name is an error + if skipNext { + skipNext = false + continue + } + // a ':' while we're in a name is an error, except PostgreSQL casts if b == ':' { + // :name::type is a named param followed by a PostgreSQL type cast + if inName && len(name) > 0 && i < last && qs[i+1] == ':' { + appendNamedBind() + rebound = append(rebound, ':', ':') + inName = false + skipNext = true + continue + } // if this is the second ':' in a '::' escape sequence, append a ':' if inName && i > 0 && qs[i-1] == ':' { rebound = append(rebound, ':') @@ -367,29 +404,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++ - } + appendNamedBind() // 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..08eaccaa4 100644 --- a/named_test.go +++ b/named_test.go @@ -53,6 +53,23 @@ func TestCompileQuery(t *testing.T) { T: `SELECT @name := "name", @p1, @p2, @p3`, V: []string{"age", "first", "last"}, }, + // PostgreSQL type cast immediately after a named parameter (#983) + { + Q: `SELECT :boundary::jsonb AS boundary`, + R: `SELECT ?::jsonb AS boundary`, + D: `SELECT $1::jsonb AS boundary`, + T: `SELECT @p1::jsonb AS boundary`, + N: `SELECT :boundary::jsonb AS boundary`, + V: []string{"boundary"}, + }, + { + Q: `SELECT :a::uuid, :b::text`, + R: `SELECT ?::uuid, ?::text`, + D: `SELECT $1::uuid, $2::text`, + T: `SELECT @p1::uuid, @p2::text`, + N: `SELECT :a::uuid, :b::text`, + V: []string{"a", "b"}, + }, /* 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) @@ -121,6 +138,36 @@ func (t Test) Errorf(err error, format string, args ...interface{}) { } } +func TestNamedPostgresTypeCast(t *testing.T) { + q, args, err := Named( + `SELECT :boundary::jsonb AS boundary, :id::int AS id`, + map[string]interface{}{ + "boundary": `{"type":"Polygon"}`, + "id": 7, + }, + ) + if err != nil { + t.Fatal(err) + } + want := `SELECT ?::jsonb AS boundary, ?::int AS id` + if q != want { + t.Errorf("query:\n got: %s\nwant: %s", q, want) + } + if len(args) != 2 { + t.Fatalf("got %d args, want 2: %#v", len(args), args) + } + if args[0] != `{"type":"Polygon"}` || args[1] != 7 { + t.Errorf("args: %#v", args) + } +} + +func TestNamedParamSingleColonStillErrors(t *testing.T) { + _, _, err := compileNamedQuery([]byte("SELECT :first:name"), QUESTION) + if err == nil { + t.Fatal("expected error for a single colon inside a named param") + } +} + 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